obsidian-mcp-server 3.8.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +12 -10
  2. package/CLAUDE.md +12 -10
  3. package/README.md +12 -8
  4. package/changelog/3.9.x/3.9.0.md +43 -0
  5. package/changelog/template.md +10 -3
  6. package/dist/config/server-config.d.ts.map +1 -1
  7. package/dist/config/server-config.js +6 -4
  8. package/dist/config/server-config.js.map +1 -1
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +22 -15
  11. package/dist/index.js.map +1 -1
  12. package/dist/instructions.d.ts +4 -3
  13. package/dist/instructions.d.ts.map +1 -1
  14. package/dist/instructions.js +6 -9
  15. package/dist/instructions.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +7 -5
  17. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +11 -8
  19. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/index.d.ts +237 -217
  21. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/index.js +6 -4
  23. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +6 -0
  25. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  26. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +8 -1
  27. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  28. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +1 -1
  29. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +1 -1
  31. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +1 -1
  33. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +6 -0
  35. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +8 -1
  37. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +143 -27
  39. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +87 -79
  41. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +6 -0
  43. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +10 -3
  45. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  46. package/dist/services/obsidian/obsidian-service.d.ts +53 -15
  47. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  48. package/dist/services/obsidian/obsidian-service.js +461 -155
  49. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  50. package/dist/services/obsidian/section-extractor.d.ts +62 -6
  51. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  52. package/dist/services/obsidian/section-extractor.js +303 -62
  53. package/dist/services/obsidian/section-extractor.js.map +1 -1
  54. package/manifest.json +3 -3
  55. package/package.json +8 -8
  56. package/server.json +7 -7
  57. package/skills/obsidian-guide/SKILL.md +61 -0
@@ -4,14 +4,14 @@
4
4
  * `target` discriminator, and classifies errors for the framework.
5
5
  * @module services/obsidian/obsidian-service
6
6
  */
7
- import { configurationError, conflict, forbidden, JsonRpcErrorCode, McpError, notFound, serviceUnavailable, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors';
8
- import { createPacer, httpStatusToErrorCode, withRetry } from '@cyanheads/mcp-ts-core/utils';
9
- import { Agent, fetch as undiciFetch } from 'undici';
7
+ import { configurationError, conflict, forbidden, JsonRpcErrorCode, McpError, notFound, serviceUnavailable, timeout, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors';
8
+ import { createPacer, httpStatusToErrorCode, withRetry, } from '@cyanheads/mcp-ts-core/utils';
9
+ import { Agent, Response, fetch as undiciFetch } from 'undici';
10
10
  import { getServerConfig } from '../../config/server-config.js';
11
11
  import { assertNoLinkFields, assertTreeDepth, createJsonLogicEvaluation, readsContent, } from './jsonlogic-evaluator.js';
12
12
  import { canonicalContent, flattenDocumentMap, flattenHeadingTree, hasIntegerKey, inNoteOrder, isIntegerKey, patchFormatFor, relativeHeadingLevels, v1HeadingBody, v1PatchHeaders, v2Instruction, } from './patch-instruction.js';
13
13
  import { caseFoldedPattern, PathPolicy } from './path-policy.js';
14
- import { atxHeadingMarkers, blockKinds, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
14
+ import { atxHeadingMarkers, blockKinds, deepestAncestor, droppedDefinitionInReach, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
15
15
  import { isHighSurrogate, isLowSurrogate, SimpleSearch, simpleSearchTokens, } from './simple-search.js';
16
16
  /**
17
17
  * Most note-body bytes (by `stat.size`) a scoped JSONLogic search holds and
@@ -86,16 +86,24 @@ function contentBatches(notes) {
86
86
  return batches;
87
87
  }
88
88
  /**
89
- * How long a stored scoped JSONLogic walk outlives its last read. A scoped
90
- * evaluation reads every readable note, so a walk's later pages are served
91
- * from the hit list its first page stored rather than evaluated again.
89
+ * How long a stored scoped walk outlives its last read. A scoped text or
90
+ * JSONLogic search reads every readable candidate note, so a walk's later
91
+ * pages are served from the hit list its first page stored rather than
92
+ * searched again.
92
93
  */
93
94
  const WALK_TTL_MS = 5 * 60_000;
94
- /** Most characters all stored walks hold together, each its tree plus its serialized hits; the least recently read goes first. */
95
+ /**
96
+ * Most characters all stored walks of both modes hold together, each its key
97
+ * plus its serialized hits; the least recently read goes first.
98
+ */
95
99
  const WALK_STORE_CHARS = 32 * 1024 * 1024;
96
- /** Size of a stored walk: its serialized tree plus its serialized hits, counted until it passes `limit`. */
97
- function walkChars(tree, hits, limit) {
98
- let total = tree.length;
100
+ /** True when `walk` was stored by a search in `mode`. */
101
+ function isWalkOf(walk, mode) {
102
+ return walk?.mode === mode;
103
+ }
104
+ /** Size of a JSONLogic walk: its key plus its serialized hits, counted until it passes `limit`. */
105
+ function walkChars(key, hits, limit) {
106
+ let total = key.length;
99
107
  for (const hit of hits) {
100
108
  if (total > limit)
101
109
  break;
@@ -103,6 +111,38 @@ function walkChars(tree, hits, limit) {
103
111
  }
104
112
  return total;
105
113
  }
114
+ /**
115
+ * A text walk's hits as it is stored, with its size (its key plus each hit
116
+ * serialized), or `undefined` once the size passes `limit`. Each hit is
117
+ * serialized and parsed back, so a stored context window is a string of its
118
+ * own: the window `SimpleSearch` cuts is a slice of its note body, and on V8
119
+ * and JSC alike a slice keeps the whole body reachable, which the character
120
+ * count would not see. JSONLogic hits need no copy: they arrive from the
121
+ * evaluation worker as copies already.
122
+ */
123
+ function storedTextHits(key, hits, limit) {
124
+ let chars = key.length;
125
+ const copies = [];
126
+ for (const hit of hits) {
127
+ const json = JSON.stringify(hit);
128
+ chars += json.length;
129
+ if (chars > limit)
130
+ return;
131
+ copies.push(JSON.parse(json));
132
+ }
133
+ return { chars, hits: copies };
134
+ }
135
+ /** `hit` with its match locations capped at `cap`. */
136
+ function clipMatches(hit, cap) {
137
+ if (hit.matches.length <= cap)
138
+ return hit;
139
+ return {
140
+ ...hit,
141
+ matches: hit.matches.slice(0, cap),
142
+ truncated: true,
143
+ totalMatches: hit.matches.length,
144
+ };
145
+ }
106
146
  /** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
107
147
  const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
108
148
  /**
@@ -138,6 +178,17 @@ const HEADING_DELIMITER = '::';
138
178
  * (duplicate `append`, re-run Obsidian command).
139
179
  */
140
180
  const RETRY_SAFE_METHODS = new Set(['GET', 'PUT', 'DELETE']);
181
+ /**
182
+ * Wall-clock budget every Local REST API request one tool or resource call
183
+ * makes shares: each attempt, each backoff, and each request the call chains
184
+ * (a write's size reads, a patch's note reads, a folder walk). The budget is
185
+ * this or `OBSIDIAN_REQUEST_TIMEOUT_MS`, whichever is longer, so a call against
186
+ * a stalled or slow plugin settles inside the 60 s an MCP client waits by
187
+ * default, and a timeout an operator raised past it is never undercut.
188
+ */
189
+ const CALL_DEADLINE_MS = 45_000;
190
+ /** The recovery hint every `obsidian_timeout` carries inline, for the reason `classifyFetchRejection` gives. */
191
+ const TIMEOUT_HINT = `Obsidian may be busy or not responding. Retry a read as it is; a write may have applied before the timeout, so read the note before repeating one. If it keeps timing out, check that Obsidian responds, or raise OBSIDIAN_REQUEST_TIMEOUT_MS (a value above ${CALL_DEADLINE_MS / 1000} s also extends the ${CALL_DEADLINE_MS / 1000} s budget the requests of one call share).`;
141
192
  /**
142
193
  * Codes a TLS stack reports when it refuses the server's certificate. Both
143
194
  * runtimes use OpenSSL's names. The first four were each produced on Bun 1.4.0
@@ -192,11 +243,12 @@ export class ObsidianService {
192
243
  #capabilities;
193
244
  /**
194
245
  * The markdown-patch format the installed plugin speaks, read once from its
195
- * capability report. Holds the in-flight promise so concurrent writes share
196
- * one read; a failed read is evicted so the next write tries again.
246
+ * capability report. Holds the in-flight promise, with the call it runs
247
+ * under, so concurrent writes share one read; a failed read is evicted so
248
+ * the next write tries again.
197
249
  */
198
250
  #patchFormat;
199
- /** Stored scoped JSONLogic walks by id, least recently read first (`pageJsonLogic`). */
251
+ /** Stored scoped text and JSONLogic walks by id, least recently read first (`pageText`, `pageJsonLogic`). */
200
252
  #walks = new Map();
201
253
  #walkChars = 0;
202
254
  /**
@@ -208,6 +260,13 @@ export class ObsidianService {
208
260
  name: 'obsidian-note-bodies',
209
261
  maxConcurrent: BODY_READ_CONCURRENCY,
210
262
  });
263
+ /**
264
+ * When each call's request budget ({@link CALL_DEADLINE_MS}) runs out, as an
265
+ * epoch time, keyed by the call's `Context` — the one object every handler
266
+ * hands each service method it calls. Armed by the call's first request; the
267
+ * entry goes with the context.
268
+ */
269
+ #callDeadlines = new WeakMap();
211
270
  /**
212
271
  * @param config - Validated server config (api key, base URL, TLS, timeouts).
213
272
  * @param fetchImpl - Optional fetch override for tests. Defaults to undici's
@@ -221,11 +280,17 @@ export class ObsidianService {
221
280
  * Node honors this dispatcher option and ignores the per-request `tls` one;
222
281
  * Bun's `fetch` does the reverse. Both are sent on every request (see
223
282
  * `#tlsInit`), so one code path covers both runtimes with no runtime sniff.
283
+ *
284
+ * The dispatcher's own `headersTimeout` / `bodyTimeout` are off (`0`): Bun
285
+ * ignores both, and on Node they would race the request timer
286
+ * (`withRequestTimer`), the one clock either runtime honors, with an untyped
287
+ * error of their own — undici's 300 s defaults included, under a timeout an
288
+ * operator raised past them.
224
289
  */
225
290
  this.#dispatcher = new Agent({
226
291
  connect: { rejectUnauthorized: config.verifySsl },
227
- headersTimeout: config.requestTimeoutMs,
228
- bodyTimeout: config.requestTimeoutMs,
292
+ headersTimeout: 0,
293
+ bodyTimeout: 0,
229
294
  });
230
295
  this.#fetch = fetchImpl ?? undiciFetch;
231
296
  }
@@ -371,14 +436,29 @@ export class ObsidianService {
371
436
  * applied to — identical to `instruction.target` unless a bare heading leaf
372
437
  * was expanded to its full `Parent::Child` path (see `#resolveHeadingTarget`),
373
438
  * which callers echo back so an agent can see where the write landed.
439
+ *
440
+ * A heading write placed by a heading the note has is checked against the
441
+ * note's link definitions first (`#refuseDroppedDefinition`). Every step
442
+ * that needs the note's text shares one read of it: the one
443
+ * `#headingIndex` made, or else one made on first need.
374
444
  */
375
445
  async patchNote(ctx, target, content, instruction) {
376
446
  const safe = await this.#gateAsWrite(ctx, target);
377
447
  const url = this.#targetToPath(safe);
378
448
  const format = await this.#wireFormat(ctx, safe, instruction);
379
- const { target: resolvedTarget, note } = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
449
+ const { target: resolvedTarget, note, anchored, } = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
380
450
  const resolved = { ...instruction, target: resolvedTarget };
451
+ let read;
452
+ const readNote = () => {
453
+ read ??=
454
+ note === undefined
455
+ ? this.#rawGetNoteJson(ctx, safe).then((json) => json.content)
456
+ : Promise.resolve(note);
457
+ return read;
458
+ };
381
459
  if (format === '1') {
460
+ if (anchored)
461
+ this.#refuseDroppedDefinition(safe, resolved, await readNote());
382
462
  await this.#request(ctx, url, {
383
463
  method: 'PATCH',
384
464
  headers: v1PatchHeaders(resolved),
@@ -388,10 +468,13 @@ export class ObsidianService {
388
468
  });
389
469
  return resolvedTarget;
390
470
  }
391
- const continuation = await this.#listContinuation(ctx, safe, resolved, content);
471
+ const continuation = await this.#listContinuation(resolved, content, readNote);
472
+ if (anchored) {
473
+ this.#refuseDroppedDefinition(safe, resolved, await readNote(), continuation?.within);
474
+ }
392
475
  const body = continuation
393
476
  ? v2Instruction(resolved, continuation.content, continuation.within)
394
- : v2Instruction(resolved, await this.#sectionRelativeContent(ctx, safe, resolved, content));
477
+ : v2Instruction(resolved, await this.#sectionRelativeContent(safe, resolved, content, readNote));
395
478
  await this.#request(ctx, url, {
396
479
  method: 'PATCH',
397
480
  headers: { 'Content-Type': PATCH_INSTRUCTION_CT, [MARKDOWN_PATCH_VERSION_HEADER]: '2' },
@@ -417,25 +500,18 @@ export class ObsidianService {
417
500
  * coddingtonbear/obsidian-local-rest-api#237). Always prefer delivered
418
501
  * bytes or HEAD over the metadata field.
419
502
  *
420
- * Bypasses retries (a 404 is the answer, not a transient failure) and
421
- * gates readable on path targets before issuing the HEAD.
503
+ * One attempt, never retried (HEAD is not in `RETRY_SAFE_METHODS`; a 404 is
504
+ * the answer, not a transient failure), and gates readable on path targets
505
+ * before issuing the HEAD.
422
506
  */
423
507
  async tryGetSize(ctx, target) {
424
508
  if (target.type === 'path') {
425
509
  this.#policy.assertReadable(target.path, target.resolvedFrom);
426
510
  }
427
511
  const url = this.#targetToPath(target);
428
- const requestUrl = `${this.#config.baseUrl}${url}`;
429
- const res = await this.#send(ctx, requestUrl, {
430
- method: 'HEAD',
431
- headers: { Authorization: `Bearer ${this.#config.apiKey}` },
432
- });
512
+ const res = await this.#request(ctx, url, { method: 'HEAD', noteRead: true, missingOk: true });
433
513
  if (res.status === 404)
434
514
  return null;
435
- if (!res.ok) {
436
- await this.#throwForStatus(res, url, ctx, { method: 'HEAD', requestedUrl: requestUrl });
437
- }
438
- this.#assertNotDirectory(res, url);
439
515
  return parseContentLength(res, url);
440
516
  }
441
517
  /**
@@ -665,54 +741,104 @@ export class ObsidianService {
665
741
  });
666
742
  }
667
743
  /**
668
- * A JSONLogic search for a caller paging through its hits `pageSize` at a
669
- * time. Unscoped, it is `searchJsonLogic`. Under a read scope a call
670
- * without `walk` evaluates fresh and, when the hits run past one page,
671
- * stores the full ordered hit list under a new walk id, returned as `walk`;
744
+ * A JSONLogic search for a caller paging through its hits, whose page this
745
+ * call serves ends at index `pageEnd`. Unscoped, it is `searchJsonLogic`.
746
+ * Under a read scope a call without `walk` evaluates fresh and, when the
747
+ * hits run past `pageEnd`, so a later page will read them, stores the full
748
+ * ordered hit list under a new walk id, returned as `walk`;
672
749
  * a call naming that id with the same tree is served from the stored list
673
750
  * without evaluating. The store belongs to this service, whose read scope
674
751
  * is fixed at construction, so a list is only read under the scope that
675
- * produced it. Anything else — an expired, evicted, or unknown id, or a
676
- * different tree — evaluates fresh, and a call cancelled before it answers
677
- * stores nothing.
752
+ * produced it. Anything else — an expired, evicted, or unknown id, a text
753
+ * walk's id, or a different tree — evaluates fresh, and a call cancelled
754
+ * before it answers stores nothing.
678
755
  */
679
- async pageJsonLogic(ctx, logic, walk, pageSize) {
756
+ async pageJsonLogic(ctx, logic, walk, pageEnd) {
680
757
  if (!this.#policy.restrictsReads)
681
758
  return { hits: await this.searchJsonLogic(ctx, logic) };
682
759
  // Before serializing it: a tree deep enough overflows `JSON.stringify`'s stack under Bun.
683
760
  assertTreeDepth(logic);
684
- const tree = JSON.stringify(logic);
685
- const stored = walk === undefined ? undefined : this.#walks.get(walk);
686
- if (walk !== undefined && stored?.tree === tree && stored.expiresAt > Date.now()) {
687
- // Re-inserted so the map stays in last-read order.
688
- this.#walks.delete(walk);
689
- this.#walks.set(walk, { ...stored, expiresAt: Date.now() + WALK_TTL_MS });
690
- return { hits: stored.hits, walk };
691
- }
761
+ const key = JSON.stringify(logic);
762
+ const stored = this.#readWalk(walk, 'jsonlogic', key);
763
+ if (stored)
764
+ return { hits: stored.hits, walk: stored.id };
692
765
  const hits = await this.searchJsonLogic(ctx, logic);
693
- const id = hits.length > pageSize && !ctx.signal.aborted ? this.#storeWalk(tree, hits) : undefined;
766
+ if (hits.length <= pageEnd || ctx.signal.aborted)
767
+ return { hits };
768
+ const id = this.#storeWalk({
769
+ mode: 'jsonlogic',
770
+ key,
771
+ hits,
772
+ chars: walkChars(key, hits, WALK_STORE_CHARS),
773
+ });
694
774
  return id === undefined ? { hits } : { hits, walk: id };
695
775
  }
696
776
  /**
697
- * Store a walk's hits under a fresh id, first dropping expired walks and,
698
- * while the store would pass {@link WALK_STORE_CHARS}, the least recently
699
- * read. A walk larger than the whole store is not kept.
777
+ * A text search for a caller paging through its hits, whose page this call
778
+ * serves ends at index `pageEnd`: each hit's match locations capped at
779
+ * `maxMatchesPerHit`, and only the hits whose filename starts with
780
+ * `pathPrefix` when one is given. Unscoped, every page runs `searchText`.
781
+ * Under a read scope a call without `walk` searches fresh and, when the
782
+ * prefixed hits run past `pageEnd`, so a later page will read them, stores
783
+ * the capped hits, unprefixed, under a new walk id, returned as
784
+ * `walk`. A call naming that id with the same `query`, `contextLength`, and
785
+ * `maxMatchesPerHit` is served from the stored hits with no request, its own
786
+ * `pathPrefix` applied to them; the scope is fixed for this service, so it
787
+ * is no part of the key. Anything else — an expired, evicted, or unknown
788
+ * id, a JSONLogic walk's id, or a changed input — searches fresh, and a
789
+ * call cancelled before it answers stores nothing.
790
+ */
791
+ async pageText(ctx, search, walk, pageEnd) {
792
+ const { contextLength, maxMatchesPerHit, pathPrefix, query } = search;
793
+ const prefixed = (hits) => pathPrefix ? hits.filter((hit) => hit.filename.startsWith(pathPrefix)) : hits;
794
+ const searchFresh = async () => (await this.searchText(ctx, query, contextLength)).map((hit) => clipMatches(hit, maxMatchesPerHit));
795
+ if (!this.#policy.restrictsReads)
796
+ return { hits: prefixed(await searchFresh()) };
797
+ const key = JSON.stringify([query, contextLength, maxMatchesPerHit]);
798
+ const stored = this.#readWalk(walk, 'text', key);
799
+ if (stored)
800
+ return { hits: prefixed(stored.hits), walk: stored.id };
801
+ const hits = await searchFresh();
802
+ const page = prefixed(hits);
803
+ if (page.length <= pageEnd || ctx.signal.aborted)
804
+ return { hits: page };
805
+ const copies = storedTextHits(key, hits, WALK_STORE_CHARS);
806
+ const id = copies && this.#storeWalk({ mode: 'text', key, ...copies });
807
+ return id === undefined ? { hits: page } : { hits: page, walk: id };
808
+ }
809
+ /**
810
+ * The walk `walk` names when it was stored by a search in `mode` under
811
+ * `key` and has not expired, re-inserted with its lifetime restarted so the
812
+ * map stays in last-read order; otherwise `undefined`.
700
813
  */
701
- #storeWalk(tree, hits) {
702
- const chars = walkChars(tree, hits, WALK_STORE_CHARS);
703
- if (chars > WALK_STORE_CHARS)
814
+ #readWalk(walk, mode, key) {
815
+ const stored = walk === undefined ? undefined : this.#walks.get(walk);
816
+ if (!isWalkOf(stored, mode) || stored.key !== key || stored.expiresAt <= Date.now())
817
+ return;
818
+ this.#walks.delete(stored.id);
819
+ stored.expiresAt = Date.now() + WALK_TTL_MS;
820
+ this.#walks.set(stored.id, stored);
821
+ return stored;
822
+ }
823
+ /**
824
+ * Store a walk under a fresh id, first dropping expired walks and, while the
825
+ * store would pass {@link WALK_STORE_CHARS}, the least recently read. A walk
826
+ * larger than the whole store is not kept.
827
+ */
828
+ #storeWalk(walk) {
829
+ if (walk.chars > WALK_STORE_CHARS)
704
830
  return;
705
831
  const now = Date.now();
706
832
  // Every walk lives WALK_TTL_MS past its last read, so expired walks lead the last-read order.
707
833
  for (const [id, old] of this.#walks) {
708
- if (old.expiresAt > now && this.#walkChars + chars <= WALK_STORE_CHARS)
834
+ if (old.expiresAt > now && this.#walkChars + walk.chars <= WALK_STORE_CHARS)
709
835
  break;
710
836
  this.#walks.delete(id);
711
837
  this.#walkChars -= old.chars;
712
838
  }
713
839
  const id = crypto.randomUUID();
714
- this.#walks.set(id, { tree, hits, chars, expiresAt: now + WALK_TTL_MS });
715
- this.#walkChars += chars;
840
+ this.#walks.set(id, { ...walk, id, expiresAt: now + WALK_TTL_MS });
841
+ this.#walkChars += walk.chars;
716
842
  return id;
717
843
  }
718
844
  /** One upstream JSONLogic search: `tree` posted to `/search/`. */
@@ -804,8 +930,8 @@ export class ObsidianService {
804
930
  * `true` only when the response is `HTTP 200`, declares `application/json`,
805
931
  * and the body parses as a JSON array — unrouted paths on the Omnisearch
806
932
  * server also return `200` with an empty body, so status alone is
807
- * insufficient. The entry point passes the return value into the
808
- * `obsidian_search_notes` factory to decide whether to expose the
933
+ * insufficient. `omnisearchAvailability` calls it, and only when reads are
934
+ * unscoped, to decide between the `offered` and `unreachable` states of the
809
935
  * `omnisearch` mode.
810
936
  */
811
937
  async probeOmnisearch(signal) {
@@ -834,25 +960,27 @@ export class ObsidianService {
834
960
  * Query the Omnisearch HTTP endpoint. Normalizes the response on the way
835
961
  * out: decodes HTML entities + `<br>` → `\n` in `excerpt`, renames `path`
836
962
  * to `filename`, and drops `vault`. Throws `omnisearch_unreachable`
837
- * (ServiceUnavailable) on network failures or non-2xx responses — the
838
- * plugin can shut down mid-session (Obsidian quits, plugin disabled), and
839
- * the tool needs a distinct signal from the upstream's success cases.
963
+ * (ServiceUnavailable) on network failures, on a query that outlasts
964
+ * `OBSIDIAN_REQUEST_TIMEOUT_MS`, and on non-2xx responses — the plugin can
965
+ * shut down mid-session (Obsidian quits, plugin disabled), and the tool
966
+ * needs a distinct signal from the upstream's success cases. One attempt,
967
+ * the body read under the same timer as the fetch.
840
968
  */
841
969
  async searchOmnisearch(ctx, query) {
842
970
  const url = `${this.#omnisearchUrl}/search?q=${encodeURIComponent(query)}`;
843
971
  let res;
844
972
  try {
845
- res = await this.#fetch(url, {
973
+ res = await withRequestTimer(ctx.signal, this.#config.requestTimeoutMs, async (signal) => await buffered(await this.#fetch(url, {
846
974
  method: 'GET',
847
975
  dispatcher: this.#dispatcher,
848
976
  ...this.#tlsInit(url),
849
- signal: ctx.signal,
850
- });
977
+ signal,
978
+ })), (expiry) => expiry);
851
979
  }
852
980
  catch (err) {
853
981
  if (ctx.signal?.aborted)
854
982
  throw err;
855
- throw serviceUnavailable(`Omnisearch unreachable at ${this.#omnisearchUrl}. The plugin may have stopped (Obsidian quit, plugin disabled, or mobile session).`, {
983
+ throw serviceUnavailable(`Omnisearch did not answer at ${this.#omnisearchUrl}. The plugin may have stopped (Obsidian quit, plugin disabled, or mobile session), or the query took longer than ${this.#config.requestTimeoutMs / 1000} s.`, {
856
984
  reason: 'omnisearch_unreachable',
857
985
  url: this.#omnisearchUrl,
858
986
  }, { cause: err });
@@ -973,17 +1101,26 @@ export class ObsidianService {
973
1101
  *
974
1102
  * The report is shared with `#vaultCapabilities`: whichever reads it first
975
1103
  * serves the other.
1104
+ *
1105
+ * Concurrent calls share one in-flight read, which runs under the call that
1106
+ * started it. When that call is cancelled, the read ends for every call
1107
+ * waiting on it, so a waiting call whose own signal is live reads the
1108
+ * report again itself rather than failing with another caller's
1109
+ * cancellation.
976
1110
  */
977
1111
  async #markdownPatchFormat(ctx) {
978
- this.#patchFormat ??= this.#negotiatePatchFormat(ctx);
979
- const pending = this.#patchFormat;
980
- try {
981
- return await pending;
982
- }
983
- catch (err) {
984
- if (this.#patchFormat === pending)
985
- this.#patchFormat = undefined;
986
- throw err;
1112
+ for (;;) {
1113
+ this.#patchFormat ??= { owner: ctx, format: this.#negotiatePatchFormat(ctx) };
1114
+ const pending = this.#patchFormat;
1115
+ try {
1116
+ return await pending.format;
1117
+ }
1118
+ catch (err) {
1119
+ if (this.#patchFormat === pending)
1120
+ this.#patchFormat = undefined;
1121
+ if (pending.owner === ctx || !pending.owner.signal.aborted || ctx.signal.aborted)
1122
+ throw err;
1123
+ }
987
1124
  }
988
1125
  }
989
1126
  /**
@@ -993,7 +1130,9 @@ export class ObsidianService {
993
1130
  *
994
1131
  * - Rows under a heading. 2.0 takes table rows only through a block target
995
1132
  * (a heading write carries its payload in `content`, never `value`); the
996
- * 1.x engine appends them to the table that ends the section.
1133
+ * 1.x engine appends them to a table that opens the section on the line
1134
+ * right after the heading line, and refuses a section that starts any
1135
+ * other way, a blank line included.
997
1136
  * - Rows through an isolated block id — `^id` on its own line after a blank
998
1137
  * line, the way Obsidian places a table's id. 2.0 resolves the id to that
999
1138
  * marker line's own paragraph rather than the table above it, so the write
@@ -1039,9 +1178,10 @@ export class ObsidianService {
1039
1178
  * - the section already holds the content and duplicates are refused — a
1040
1179
  * `within` write searches only the list, the plain one the whole section.
1041
1180
  *
1042
- * The note is read only once the content qualifies.
1181
+ * The note (`readNote`, the write's shared read) is read only once the
1182
+ * content qualifies.
1043
1183
  */
1044
- async #listContinuation(ctx, target, instruction, content) {
1184
+ async #listContinuation(instruction, content, readNote) {
1045
1185
  const { operation } = instruction;
1046
1186
  if (instruction.targetType !== 'heading' || instruction.contentType === 'json')
1047
1187
  return;
@@ -1053,8 +1193,7 @@ export class ObsidianService {
1053
1193
  return;
1054
1194
  if (atxHeadingMarkers(reduced).markers.length > 0)
1055
1195
  return;
1056
- const { content: note } = await this.#rawGetNoteJson(ctx, target);
1057
- const body = sectionBody(note, instruction.target);
1196
+ const body = sectionBody(await readNote(), instruction.target);
1058
1197
  if (!body || (operation === 'append' && body.subsections))
1059
1198
  return;
1060
1199
  if ((operation === 'append' ? body.blocks.at(-1) : body.blocks[0]) !== 'list')
@@ -1067,19 +1206,18 @@ export class ObsidianService {
1067
1206
  * Heading content with its `#` levels rewritten to the section-relative ones
1068
1207
  * markdown-patch 2.0 reads (see `relativeHeadingLevels`), so the note gets
1069
1208
  * the levels the caller wrote, as it did under 1.x. The section's level comes
1070
- * from the note itself, read only when the content carries an ATX heading.
1071
- * Every other payload passes through as written, and so does content for a
1072
- * section the note lacks and the write will not create, which the plugin
1073
- * then reports missing.
1209
+ * from the note itself (`readNote`, the write's shared read), read only when
1210
+ * the content carries an ATX heading. Every other payload passes through as
1211
+ * written, and so does content for a section the note lacks and the write
1212
+ * will not create, which the plugin then reports missing.
1074
1213
  */
1075
- async #sectionRelativeContent(ctx, target, instruction, content) {
1214
+ async #sectionRelativeContent(target, instruction, content, readNote) {
1076
1215
  if (instruction.targetType !== 'heading' || instruction.contentType === 'json')
1077
1216
  return content;
1078
1217
  const fragment = atxHeadingMarkers(content);
1079
1218
  if (fragment.markers.length === 0)
1080
1219
  return content;
1081
- const { content: note } = await this.#rawGetNoteJson(ctx, target);
1082
- const level = sectionLevel(note, instruction.target, instruction.createTargetIfMissing === true);
1220
+ const level = sectionLevel(await readNote(), instruction.target, instruction.createTargetIfMissing === true);
1083
1221
  if (level === undefined)
1084
1222
  return content;
1085
1223
  const relative = relativeHeadingLevels(fragment, level);
@@ -1117,19 +1255,31 @@ export class ObsidianService {
1117
1255
  * on the last repeat while a section read returns the first. A write to a
1118
1256
  * repeated path is rejected as ambiguous. The count comes from the plugin's
1119
1257
  * own parse (`#headingIndex`) wherever the plugin serves one, so the map the
1120
- * PATCH resolves against is also the one that counts its repeats. On 1.x the
1121
- * note read for that count comes back as `note`, for `v1HeadingBody`.
1258
+ * PATCH resolves against is also the one that counts its repeats. A note
1259
+ * read for that parse comes back as `note`, for `v1HeadingBody` and the
1260
+ * rest of the write.
1261
+ *
1262
+ * `anchored` is whether the map places the write by a heading the note has:
1263
+ * the resolved heading itself, or, for a write that creates it, an existing
1264
+ * ancestor. The plugin reads those headings' offsets from the note, so
1265
+ * `patchNote` checks the note's link definitions for these writes alone; a
1266
+ * missing heading the write does not create, or creates at the end of the
1267
+ * note with no ancestor, needs no link-definition check.
1122
1268
  */
1123
1269
  async #resolveHeadingTarget(ctx, target, instruction, format) {
1124
1270
  if (instruction.targetType !== 'heading')
1125
- return { target: instruction.target, note: undefined };
1271
+ return { target: instruction.target, note: undefined, anchored: false };
1126
1272
  const { headings, occurrences, note } = await this.#headingIndex(ctx, target, format);
1127
1273
  const resolved = instruction.target.includes(HEADING_DELIMITER)
1128
1274
  ? instruction.target
1129
1275
  : this.#expandHeadingLeaf(target, instruction.target, headings);
1130
1276
  const repeats = occurrences.filter((p) => p === resolved);
1131
- if (repeats.length <= 1)
1132
- return { target: resolved, note };
1277
+ if (repeats.length <= 1) {
1278
+ const anchored = repeats.length === 1 ||
1279
+ (instruction.createTargetIfMissing === true &&
1280
+ deepestAncestor(occurrences, resolved) !== undefined);
1281
+ return { target: resolved, note, anchored };
1282
+ }
1133
1283
  const display = displayPath(this.#targetToPath(target));
1134
1284
  const named = resolved === instruction.target ? `'${resolved}'` : `'${instruction.target}' (${resolved})`;
1135
1285
  throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
@@ -1157,7 +1307,8 @@ export class ObsidianService {
1157
1307
  *
1158
1308
  * Both lists run in note order, as `#rawGetDocumentMap` explains, so the
1159
1309
  * `candidates` an ambiguity error names, and the first one its hint offers,
1160
- * follow the note.
1310
+ * follow the note. On 2.0 the note read that order takes comes back as
1311
+ * `note` too.
1161
1312
  */
1162
1313
  async #headingIndex(ctx, target, format) {
1163
1314
  const url = this.#targetToPath(target);
@@ -1169,10 +1320,36 @@ export class ObsidianService {
1169
1320
  }
1170
1321
  const map = await this.#fetchDocumentMap(ctx, url, format);
1171
1322
  const flat = flattenHeadingTree(map.headings);
1172
- const occurrences = hasIntegerKey(map.headings)
1173
- ? await this.#inNoteOrder(ctx, target, flat)
1174
- : flat;
1175
- return { headings: [...new Set(occurrences)].filter(Boolean), occurrences };
1323
+ const distinct = (paths) => [...new Set(paths)].filter(Boolean);
1324
+ if (!hasIntegerKey(map.headings))
1325
+ return { headings: distinct(flat), occurrences: flat };
1326
+ const { content } = await this.#rawGetNoteJson(ctx, target);
1327
+ const occurrences = inNoteOrder(flat, listHeadingPaths(content));
1328
+ return { headings: distinct(occurrences), note: content, occurrences };
1329
+ }
1330
+ /**
1331
+ * Refuse a heading write that a link reference definition the plugin's
1332
+ * `marked` drops would misplace (`droppedDefinitionInReach`): both
1333
+ * markdown-patch engines place headings by offsets that fall short below
1334
+ * such a definition, and neither wire format carries a position this
1335
+ * server could correct. `within` is the 2.0 list continuation the write
1336
+ * goes out as, when it does.
1337
+ */
1338
+ #refuseDroppedDefinition(target, instruction, note, within) {
1339
+ const dropped = droppedDefinitionInReach(note, {
1340
+ path: instruction.target,
1341
+ create: instruction.createTargetIfMissing === true,
1342
+ within,
1343
+ });
1344
+ if (!dropped)
1345
+ return;
1346
+ const display = displayPath(this.#targetToPath(target));
1347
+ throw conflict(`Line ${dropped.line} of ${display} defines the link label [${dropped.label}] a second time. The Local REST API's markdown parser drops a repeated definition and misplaces every heading below it, so this write to section '${instruction.target}' would land outside that section.`, {
1348
+ path: display,
1349
+ reason: 'repeated_link_definition',
1350
+ label: dropped.label,
1351
+ line: dropped.line,
1352
+ }, { literals: [`[${dropped.label}]`] });
1176
1353
  }
1177
1354
  /**
1178
1355
  * Expand a bare heading leaf against the note's heading paths. Resolution
@@ -1247,22 +1424,26 @@ export class ObsidianService {
1247
1424
  return { tls: { rejectUnauthorized: false } };
1248
1425
  }
1249
1426
  /**
1250
- * One request to the Local REST API — the single attempt `#request` hands
1251
- * to `withRetry`, and `tryGetSize`'s HEAD — with the dispatcher, the scoped
1252
- * TLS relaxation, and the caller's signal attached.
1427
+ * One attempt at a Local REST API request — what `#request` runs once, or
1428
+ * hands to `withRetry` — with the dispatcher, the scoped TLS relaxation, and
1429
+ * the attempt's signal attached. The body is read here too, into memory
1430
+ * (`buffered`), so the signal that bounds the fetch bounds the transfer: a
1431
+ * body that stalls fails the attempt the way stalled headers do.
1253
1432
  *
1254
- * A rejected `fetch` is classified here, inside the attempt, because the
1255
- * attempt is where the retry decision is made: a certificate rejection comes
1256
- * back as a non-transient `ConfigurationError` and `withRetry` rethrows it
1257
- * on the spot, while an unreachable plugin comes back as a transient
1258
- * `ServiceUnavailable` and keeps its retries (issues #133 and #136). A
1259
- * rejection that is neither passes through untouched, and so does anything
1260
- * thrown after the request's signal aborted — `ctx.signal`, or the signal of
1261
- * the search it belongs to — since a cancellation is not a network fault.
1433
+ * A rejection is classified here, inside the attempt, because the attempt is
1434
+ * where the retry decision is made: a certificate rejection comes back as a
1435
+ * non-transient `ConfigurationError` and `withRetry` rethrows it on the spot,
1436
+ * while an unreachable plugin, or a connection dropped mid-body, comes back as
1437
+ * a transient `ServiceUnavailable` and keeps its retries (issues #133 and
1438
+ * #136). A rejection that is neither passes through untouched, and so does
1439
+ * anything thrown after the attempt's signal aborted — `ctx.signal`, a
1440
+ * search's deadline, the retry deadline, or the request timer — since none of
1441
+ * those is a network fault; `#request` tells them apart.
1262
1442
  */
1263
1443
  async #send(ctx, url, init, signal = ctx.signal) {
1444
+ let res;
1264
1445
  try {
1265
- return await this.#fetch(url, {
1446
+ res = await this.#fetch(url, {
1266
1447
  ...init,
1267
1448
  dispatcher: this.#dispatcher,
1268
1449
  ...this.#tlsInit(url),
@@ -1272,22 +1453,51 @@ export class ObsidianService {
1272
1453
  catch (err) {
1273
1454
  if (signal.aborted)
1274
1455
  throw err;
1275
- throw classifyFetchRejection(err) ?? err;
1456
+ throw classifyFetchRejection(err, 'request') ?? err;
1457
+ }
1458
+ try {
1459
+ return await buffered(res);
1460
+ }
1461
+ catch (err) {
1462
+ if (signal.aborted)
1463
+ throw err;
1464
+ throw classifyFetchRejection(err, 'body') ?? err;
1276
1465
  }
1277
1466
  }
1278
- #request(ctx, pathAndQuery, init) {
1467
+ /**
1468
+ * One Local REST API request, status-checked, its body already read: each
1469
+ * attempt (`#send`) runs under a timer of `OBSIDIAN_REQUEST_TIMEOUT_MS`, the
1470
+ * methods in `RETRY_SAFE_METHODS` retry it, and every attempt and backoff
1471
+ * fits in what is left of the call's budget (`#callRemainingMs`). Whichever
1472
+ * clock ends the request — the timer on an attempt, the budget across them,
1473
+ * or a budget already spent before it starts — it fails as `obsidian_timeout`.
1474
+ * A caller's cancellation passes through as thrown, never retried.
1475
+ */
1476
+ async #request(ctx, pathAndQuery, init) {
1279
1477
  const url = `${this.#config.baseUrl}${pathAndQuery}`;
1280
1478
  const headers = {
1281
1479
  ...(init.headers ?? {}),
1282
1480
  Authorization: `Bearer ${this.#config.apiKey}`,
1283
1481
  };
1284
1482
  const signal = init.signal ?? ctx.signal;
1285
- const exec = async () => {
1286
- const res = await this.#send(ctx, url, {
1483
+ const timeoutMs = this.#config.requestTimeoutMs;
1484
+ /**
1485
+ * A scoped search hands its requests a signal of its own carrying the
1486
+ * search's deadline (`withScopedDeadline`): 45 s from the search's start,
1487
+ * which bounds every request it makes. Those leave the call budget alone,
1488
+ * since two clocks ending at the same instant would race, and the budget
1489
+ * winning would relabel the search's own `*_too_costly` as a timeout.
1490
+ */
1491
+ const budgetMs = signal === ctx.signal ? this.#callRemainingMs(ctx) : Number.POSITIVE_INFINITY;
1492
+ if (budgetMs <= 0)
1493
+ throw this.#timeoutError('call');
1494
+ const exec = async ({ signal: attempt, remainingMs, }) => {
1495
+ const boundMs = Math.min(timeoutMs, remainingMs);
1496
+ const res = await withRequestTimer(attempt, boundMs, (timed) => this.#send(ctx, url, {
1287
1497
  method: init.method,
1288
1498
  headers,
1289
1499
  ...(init.body !== undefined ? { body: init.body } : {}),
1290
- }, signal);
1500
+ }, timed), (expiry) => this.#timeoutError(boundMs < timeoutMs ? 'call' : 'request', expiry));
1291
1501
  if (res.status === 404 && init.missingOk)
1292
1502
  return res;
1293
1503
  if (!res.ok) {
@@ -1306,21 +1516,70 @@ export class ObsidianService {
1306
1516
  return res;
1307
1517
  };
1308
1518
  if (!RETRY_SAFE_METHODS.has(init.method.toUpperCase())) {
1309
- return exec();
1519
+ return await exec({ signal, remainingMs: budgetMs });
1310
1520
  }
1311
- return withRetry(exec, {
1312
- operation: `obsidian.${init.method} ${pathAndQuery}`,
1313
- context: {
1314
- requestId: ctx.requestId,
1315
- timestamp: ctx.timestamp,
1316
- ...(ctx.tenantId !== undefined ? { tenantId: ctx.tenantId } : {}),
1317
- ...(ctx.traceId !== undefined ? { traceId: ctx.traceId } : {}),
1318
- ...(ctx.spanId !== undefined ? { spanId: ctx.spanId } : {}),
1319
- },
1320
- baseDelayMs: 200,
1321
- maxRetries: 3,
1322
- signal,
1323
- });
1521
+ try {
1522
+ return await withRetry(exec, {
1523
+ operation: `obsidian.${init.method} ${pathAndQuery}`,
1524
+ context: {
1525
+ requestId: ctx.requestId,
1526
+ timestamp: ctx.timestamp,
1527
+ ...(ctx.tenantId !== undefined ? { tenantId: ctx.tenantId } : {}),
1528
+ ...(ctx.traceId !== undefined ? { traceId: ctx.traceId } : {}),
1529
+ ...(ctx.spanId !== undefined ? { spanId: ctx.spanId } : {}),
1530
+ },
1531
+ baseDelayMs: 200,
1532
+ maxRetries: 3,
1533
+ signal,
1534
+ ...(Number.isFinite(budgetMs) ? { deadlineMs: budgetMs } : {}),
1535
+ });
1536
+ }
1537
+ catch (err) {
1538
+ /**
1539
+ * `withRetry`'s expiry (`retry_deadline_exceeded`) is the call budget
1540
+ * running out mid-attempt or mid-backoff, so it fails as this service's
1541
+ * own `obsidian_timeout`, attempt count kept. The exception is an attempt
1542
+ * that failed some other typed way (an unreachable plugin, a 5xx) with no
1543
+ * time left to retry it: that failure says more than the clock does.
1544
+ */
1545
+ if (!(err instanceof McpError) || err.data?.reason !== 'retry_deadline_exceeded')
1546
+ throw err;
1547
+ const last = err.cause;
1548
+ if (last instanceof McpError && last.data?.reason !== 'obsidian_timeout')
1549
+ throw last;
1550
+ const attempts = err.data.retryAttempts;
1551
+ throw this.#timeoutError('call', err, typeof attempts === 'number' ? attempts : undefined);
1552
+ }
1553
+ }
1554
+ /** The budget one call's requests share: {@link CALL_DEADLINE_MS}, or the request timeout when that is longer. */
1555
+ get #callBudgetMs() {
1556
+ return Math.max(CALL_DEADLINE_MS, this.#config.requestTimeoutMs);
1557
+ }
1558
+ /** What is left of `ctx`'s call budget, in ms, arming it on the call's first request. */
1559
+ #callRemainingMs(ctx) {
1560
+ let deadline = this.#callDeadlines.get(ctx);
1561
+ if (deadline === undefined) {
1562
+ deadline = Date.now() + this.#callBudgetMs;
1563
+ this.#callDeadlines.set(ctx, deadline);
1564
+ }
1565
+ return deadline - Date.now();
1566
+ }
1567
+ /**
1568
+ * `obsidian_timeout`, bounded by the request timer on one attempt
1569
+ * (`request`) or by the budget a call's requests share (`call`). Authored
1570
+ * here with the hint inline, like `obsidian_unreachable`: it reaches every
1571
+ * tool and the resources that declare no `errors[]`, and its fix is the
1572
+ * operator's. The timer's reason and the retry expiry ride as `cause`.
1573
+ */
1574
+ #timeoutError(bound, cause, retryAttempts) {
1575
+ const message = bound === 'request'
1576
+ ? `The Obsidian Local REST API did not respond within ${this.#config.requestTimeoutMs / 1000} s.`
1577
+ : `This call's requests to the Obsidian Local REST API did not finish within its ${this.#callBudgetMs / 1000} s budget.`;
1578
+ return timeout(message, {
1579
+ reason: 'obsidian_timeout',
1580
+ ...(retryAttempts !== undefined ? { retryAttempts } : {}),
1581
+ recovery: { hint: TIMEOUT_HINT },
1582
+ }, cause === undefined ? undefined : { cause });
1324
1583
  }
1325
1584
  /**
1326
1585
  * Reject a `2xx` note read that actually served a folder listing. The Local
@@ -1463,7 +1722,8 @@ export class ObsidianService {
1463
1722
  * Issues #104, #116, #122, and #159.
1464
1723
  */
1465
1724
  async #throwForStatus(res, path, ctx, opts) {
1466
- const text = await this.#readBodySafe(res);
1725
+ // Read inside the attempt (`#send`), so this is the body already in memory.
1726
+ const text = await res.text();
1467
1727
  const body = parseJsonObject(text);
1468
1728
  const display = displayPath(path);
1469
1729
  /** Classification input and log payload only — never a wire value. */
@@ -1607,9 +1867,11 @@ export class ObsidianService {
1607
1867
  * Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
1608
1868
  * canonical mapping `httpErrorFromResponse` would (the whole 5xx range
1609
1869
  * except 504 → ServiceUnavailable, 504 → Timeout) without that
1610
- * helper's response-derived extras: it builds its message from the
1611
- * upstream's reason phrase and mirrors the body onto
1612
- * `data.body`/`data.responseBody`, which is the #104 leak.
1870
+ * helper's response-derived extras: its message ends in the upstream's
1871
+ * reason phrase and `data.statusText` repeats it, upstream-authored
1872
+ * text the containment invariant above keeps off the wire. The body
1873
+ * it captures stays server-side by default, but this method has read
1874
+ * the body already to classify it, and keeps it on `cause`.
1613
1875
  *
1614
1876
  * Two `data` fields are re-attached by hand because both steer
1615
1877
  * `withRetry` and neither carries vault content:
@@ -1631,14 +1893,6 @@ export class ObsidianService {
1631
1893
  }
1632
1894
  }
1633
1895
  }
1634
- async #readBodySafe(res) {
1635
- try {
1636
- return await res.text();
1637
- }
1638
- catch {
1639
- return '';
1640
- }
1641
- }
1642
1896
  }
1643
1897
  /**
1644
1898
  * Count, per tag, the notes carrying it or a tag nested under it. Each note's
@@ -2036,23 +2290,26 @@ function callerIdentifier(urlPath) {
2036
2290
  return {};
2037
2291
  }
2038
2292
  /**
2039
- * Classify a rejected `fetch` — one that produced no response at all — or
2040
- * return `undefined` to leave it as thrown.
2293
+ * Classify a rejected `fetch` (`phase: 'request'`, no response at all) or a
2294
+ * rejected body read after the headers arrived (`phase: 'body'`), or return
2295
+ * `undefined` to leave it as thrown.
2041
2296
  *
2042
2297
  * The two runtimes put the code in different places: Bun on the thrown error
2043
2298
  * itself, Node's undici one level down on `cause` under a bare `TypeError:
2044
- * fetch failed`. Both levels are read, and the result is built from the code
2045
- * alone, so a Bun and a Node rejection produce the same message and `data`.
2046
- * Neither the runtime's own text nor the URL it names reaches the wire; the
2047
- * rejection rides as `cause`, the channel `#throwForStatus` uses for the same
2048
- * reason.
2299
+ * fetch failed` (or `terminated`, mid-body). Both levels are read, and the
2300
+ * result is built from the code alone, so a Bun and a Node rejection produce
2301
+ * the same message and `data`. Neither the runtime's own text nor the URL it
2302
+ * names reaches the wire; the rejection rides as `cause`, the channel
2303
+ * `#throwForStatus` uses for the same reason. A connection that fails mid-body
2304
+ * is the same `obsidian_unreachable`, worded for a plugin that answered and
2305
+ * then went away rather than one never reached.
2049
2306
  *
2050
2307
  * The recovery hint is written inline, as `encodeVaultPath` writes
2051
2308
  * `path_traversal`'s: both failures are the operator's to fix and reachable
2052
2309
  * from every tool and resource — including the two resources that declare no
2053
2310
  * `errors[]` for the framework's contract fill to resolve against.
2054
2311
  */
2055
- function classifyFetchRejection(err) {
2312
+ function classifyFetchRejection(err, phase) {
2056
2313
  const codes = [err, err instanceof Error ? err.cause : undefined].flatMap((e) => {
2057
2314
  const code = e?.code;
2058
2315
  return typeof code === 'string' ? [code] : [];
@@ -2067,15 +2324,64 @@ function classifyFetchRejection(err) {
2067
2324
  }, { cause: err });
2068
2325
  }
2069
2326
  if (codes.some((code) => CONNECTION_FAILURE_CODES.has(code))) {
2070
- return serviceUnavailable('Could not connect to the Obsidian Local REST API.', {
2071
- reason: 'obsidian_unreachable',
2072
- recovery: {
2073
- hint: 'Confirm Obsidian is running with the Local REST API plugin enabled, and that OBSIDIAN_BASE_URL names the host and port the plugin listens on.',
2074
- },
2075
- }, { cause: err });
2327
+ return phase === 'request'
2328
+ ? serviceUnavailable('Could not connect to the Obsidian Local REST API.', {
2329
+ reason: 'obsidian_unreachable',
2330
+ recovery: {
2331
+ hint: 'Confirm Obsidian is running with the Local REST API plugin enabled, and that OBSIDIAN_BASE_URL names the host and port the plugin listens on.',
2332
+ },
2333
+ }, { cause: err })
2334
+ : serviceUnavailable('The Obsidian Local REST API closed the connection before its response finished.', {
2335
+ reason: 'obsidian_unreachable',
2336
+ recovery: {
2337
+ hint: 'Obsidian may have quit, or reloaded the Local REST API plugin, while answering. Confirm it is running with the plugin enabled; a write it was answering may already have applied, so read the note before repeating one.',
2338
+ },
2339
+ }, { cause: err });
2076
2340
  }
2077
2341
  return;
2078
2342
  }
2343
+ /**
2344
+ * Run one request attempt — its fetch and its body read — under a timer of
2345
+ * `timeoutMs` joined to `signal`. The timer aborts with a reason held here,
2346
+ * and both runtimes reject the pending fetch or body read with that very
2347
+ * instance, so its expiry is told from every other abort by identity, never
2348
+ * by the `TimeoutError` name a caller's cancellation or a scoped search's
2349
+ * deadline can carry too: `onExpiry` replaces only that rejection. The timer
2350
+ * is cleared however the attempt ends. A timer rather than
2351
+ * `AbortSignal.timeout`, for the reason `withScopedDeadline` gives.
2352
+ */
2353
+ async function withRequestTimer(signal, timeoutMs, run, onExpiry) {
2354
+ const timer = new AbortController();
2355
+ const expiry = new DOMException(`Request timer of ${timeoutMs} ms expired`, 'TimeoutError');
2356
+ const id = setTimeout(() => timer.abort(expiry), timeoutMs);
2357
+ try {
2358
+ return await run(AbortSignal.any([signal, timer.signal]));
2359
+ }
2360
+ catch (err) {
2361
+ throw err === expiry ? onExpiry(expiry) : err;
2362
+ }
2363
+ finally {
2364
+ clearTimeout(id);
2365
+ }
2366
+ }
2367
+ /**
2368
+ * `res` with its body read into memory, as an equivalent response a caller
2369
+ * reads with no I/O left. Called inside the attempt, so the signal that bounds
2370
+ * the fetch bounds the transfer too: the request timer, a caller's
2371
+ * cancellation, and a dropped connection all land while the attempt can still
2372
+ * classify and retry them, rather than in a caller's `res.json()` afterwards.
2373
+ * Parsing stays with the caller, outside the retry loop, since a body that
2374
+ * does not parse reads the same on every attempt.
2375
+ */
2376
+ async function buffered(res) {
2377
+ const bytes = await res.arrayBuffer();
2378
+ // A null-body status (204, 304) refuses any body, an empty one included.
2379
+ return new Response(bytes.byteLength > 0 ? bytes : null, {
2380
+ status: res.status,
2381
+ statusText: res.statusText,
2382
+ headers: res.headers,
2383
+ });
2384
+ }
2079
2385
  /**
2080
2386
  * Convert an internal URL path (e.g. `/vault/Projects/My%20Note.md`) to the
2081
2387
  * vault-relative form a caller would recognize. Used in error messages so the
@@ -2158,8 +2464,8 @@ function normalizeOmnisearchHit(raw) {
2158
2464
  /**
2159
2465
  * Normalize Omnisearch's excerpt HTML: `<br>` → `\n`, decode the entities
2160
2466
  * the upstream actually emits (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#039;`,
2161
- * `&apos;`, plus numeric `&#NNN;` / `&#xNN;`). `<mark>` tags are preserved —
2162
- * they highlight the match span and are interpretable as emphasis.
2467
+ * `&apos;`, plus numeric `&#NNN;` / `&#xNN;`). Omnisearch's HTTP API adds no
2468
+ * highlight markup; it marks matches only in its own UI.
2163
2469
  */
2164
2470
  function cleanExcerpt(excerpt) {
2165
2471
  return excerpt