obsidian-mcp-server 3.7.0 → 3.7.2

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 (94) hide show
  1. package/AGENTS.md +31 -11
  2. package/CLAUDE.md +31 -11
  3. package/Dockerfile +10 -3
  4. package/README.md +30 -91
  5. package/changelog/3.7.x/3.7.1.md +40 -0
  6. package/changelog/3.7.x/3.7.2.md +34 -0
  7. package/dist/config/server-config.js +1 -1
  8. package/dist/config/server-config.js.map +1 -1
  9. package/dist/index.js +13 -32
  10. package/dist/index.js.map +1 -1
  11. package/dist/instructions.d.ts +20 -0
  12. package/dist/instructions.d.ts.map +1 -0
  13. package/dist/instructions.js +40 -0
  14. package/dist/instructions.js.map +1 -0
  15. package/dist/mcp-server/tools/definitions/_shared/markdown.d.ts +14 -0
  16. package/dist/mcp-server/tools/definitions/_shared/markdown.d.ts.map +1 -0
  17. package/dist/mcp-server/tools/definitions/_shared/markdown.js +17 -0
  18. package/dist/mcp-server/tools/definitions/_shared/markdown.js.map +1 -0
  19. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +39 -0
  20. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +63 -2
  22. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/index.d.ts +127 -116
  24. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +6 -0
  26. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +15 -6
  28. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +5 -4
  31. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js +2 -1
  34. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +51 -40
  37. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js +7 -4
  40. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js.map +1 -1
  41. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +7 -6
  43. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
  45. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +3 -2
  46. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  47. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  48. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +13 -9
  49. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  50. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  51. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +9 -5
  52. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
  54. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +2 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  56. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +5 -4
  58. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
  60. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +12 -4
  61. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  62. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +26 -2
  63. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  64. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +61 -34
  65. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  66. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +5 -0
  67. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  68. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +24 -6
  69. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  70. package/dist/services/obsidian/frontmatter-ops.d.ts +15 -1
  71. package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
  72. package/dist/services/obsidian/frontmatter-ops.js +55 -10
  73. package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
  74. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +83 -0
  75. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -0
  76. package/dist/services/obsidian/jsonlogic-evaluator.js +192 -0
  77. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -0
  78. package/dist/services/obsidian/jsonlogic-worker.d.ts +53 -0
  79. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -0
  80. package/dist/services/obsidian/jsonlogic-worker.js +237 -0
  81. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -0
  82. package/dist/services/obsidian/obsidian-service.d.ts +56 -8
  83. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  84. package/dist/services/obsidian/obsidian-service.js +254 -48
  85. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  86. package/dist/services/obsidian/path-policy.d.ts +33 -5
  87. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  88. package/dist/services/obsidian/path-policy.js +99 -17
  89. package/dist/services/obsidian/path-policy.js.map +1 -1
  90. package/dist/services/obsidian/types.d.ts +12 -3
  91. package/dist/services/obsidian/types.d.ts.map +1 -1
  92. package/manifest.json +2 -2
  93. package/package.json +20 -15
  94. package/server.json +5 -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 { assertTreeDepth, createJsonLogicEvaluation, readsContent } from './jsonlogic-evaluator.js';
11
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);
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)) };
213
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);
@@ -308,7 +364,7 @@ export class ObsidianService {
308
364
  */
309
365
  async tryGetSize(ctx, target) {
310
366
  if (target.type === 'path') {
311
- this.#policy.assertReadable(target.path);
367
+ this.#policy.assertReadable(target.path, target.resolvedFrom);
312
368
  }
313
369
  const url = this.#targetToPath(target);
314
370
  const requestUrl = `${this.#config.baseUrl}${url}`;
@@ -318,8 +374,9 @@ export class ObsidianService {
318
374
  });
319
375
  if (res.status === 404)
320
376
  return null;
321
- if (!res.ok)
322
- await this.#throwForStatus(res, url, ctx, { requestedUrl: requestUrl });
377
+ if (!res.ok) {
378
+ await this.#throwForStatus(res, url, ctx, { method: 'HEAD', requestedUrl: requestUrl });
379
+ }
323
380
  this.#assertNotDirectory(res, url);
324
381
  return parseContentLength(res, url);
325
382
  }
@@ -366,14 +423,13 @@ export class ObsidianService {
366
423
  /**
367
424
  * Vault tags with usage counts, hierarchical parents included. Unscoped, the
368
425
  * plugin's `/tags/` with its occurrence counts. That payload carries no
369
- * per-note attribution, so under OBSIDIAN_READ_PATHS the tags are collected
370
- * per note with one JsonLogic search instead, kept to readable notes, and
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
371
428
  * each one counts the readable notes carrying it or a tag nested under it.
372
429
  */
373
430
  async listTags(ctx) {
374
431
  if (this.#policy.restrictsReads) {
375
- const notes = await this.searchJsonLogic(ctx, { var: 'tags' });
376
- return countTagsPerNote(this.#policy.filterReadable(notes));
432
+ return countTagsPerNote(await this.#readableNoteContexts(ctx));
377
433
  }
378
434
  const res = await this.#request(ctx, '/tags/', { method: 'GET' });
379
435
  const body = (await res.json());
@@ -426,13 +482,140 @@ export class ObsidianService {
426
482
  matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
427
483
  }));
428
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
+ */
429
501
  async searchJsonLogic(ctx, logic) {
430
- 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/', {
431
573
  method: 'POST',
432
574
  headers: { 'Content-Type': JSONLOGIC_CT },
433
- body: JSON.stringify(logic),
575
+ body: JSON.stringify(tree),
434
576
  });
435
- 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;
436
619
  }
437
620
  /**
438
621
  * One-shot startup probe for the Omnisearch plugin's HTTP endpoint. Returns
@@ -541,15 +724,15 @@ export class ObsidianService {
541
724
  */
542
725
  async #gateAsWrite(ctx, target) {
543
726
  if (target.type === 'path') {
544
- this.#policy.assertWritable(target.path);
727
+ this.#policy.assertWritable(target.path, target.resolvedFrom);
545
728
  return target;
546
729
  }
547
730
  if (this.#policy.isUnrestricted) {
548
731
  return target;
549
732
  }
550
- const note = await this.#rawGetNoteJson(ctx, target);
551
- this.#policy.assertWritable(note.path);
552
- 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;
553
736
  }
554
737
  /** Raw NoteJson fetch — bypasses path-policy. Used by gate helpers to learn the resolved path. */
555
738
  async #rawGetNoteJson(ctx, target) {
@@ -923,6 +1106,7 @@ export class ObsidianService {
923
1106
  });
924
1107
  if (!res.ok) {
925
1108
  await this.#throwForStatus(res, pathAndQuery, ctx, {
1109
+ method: init.method,
926
1110
  listRead: init.listRead,
927
1111
  requestedUrl: url,
928
1112
  });
@@ -1087,7 +1271,10 @@ export class ObsidianService {
1087
1271
  * malformed `OBSIDIAN_BASE_URL` is otherwise invisible behind a clean-looking
1088
1272
  * 404 (issue #122).
1089
1273
  *
1090
- * 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.
1091
1278
  */
1092
1279
  async #throwForStatus(res, path, ctx, opts) {
1093
1280
  const text = await this.#readBodySafe(res);
@@ -1211,6 +1398,25 @@ export class ObsidianService {
1211
1398
  if (res.status === 409 && /\balready contains the content\b/i.test(upstreamMsg)) {
1212
1399
  throw contentPreexists();
1213
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
+ }
1214
1420
  /**
1215
1421
  * Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
1216
1422
  * canonical mapping `httpErrorFromResponse` would (the whole 5xx range
@@ -1249,17 +1455,17 @@ export class ObsidianService {
1249
1455
  }
1250
1456
  }
1251
1457
  /**
1252
- * Count, per tag, the notes carrying it or a tag nested under it. Each hit's
1253
- * `result` is the plugin's per-note tag list for `{"var": "tags"}` — strings,
1254
- * deduplicated, `#` stripped, not parent-expanded, untagged notes omitted — so
1255
- * one note carrying `work/a` and `work/b` adds 1 to `work`. Tags come back in
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
1256
1462
  * order of first appearance.
1257
1463
  */
1258
1464
  function countTagsPerNote(notes) {
1259
1465
  const counts = new Map();
1260
- for (const { result } of notes) {
1466
+ for (const { tags } of notes) {
1261
1467
  const noteTags = new Set();
1262
- for (const tag of result) {
1468
+ for (const tag of tags) {
1263
1469
  const parts = tag.split('/');
1264
1470
  for (let depth = 1; depth <= parts.length; depth++) {
1265
1471
  noteTags.add(parts.slice(0, depth).join('/'));