obsidian-mcp-server 3.7.2 → 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 (110) hide show
  1. package/AGENTS.md +20 -15
  2. package/CLAUDE.md +20 -15
  3. package/README.md +31 -19
  4. package/changelog/3.8.x/3.8.0.md +38 -0
  5. package/changelog/3.9.x/3.9.0.md +43 -0
  6. package/changelog/template.md +10 -3
  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 +30 -19
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +27 -15
  13. package/dist/index.js.map +1 -1
  14. package/dist/instructions.d.ts +4 -3
  15. package/dist/instructions.d.ts.map +1 -1
  16. package/dist/instructions.js +23 -12
  17. package/dist/instructions.js.map +1 -1
  18. package/dist/mcp-server/resources/definitions/index.d.ts +2 -2
  19. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +3 -2
  20. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  21. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +6 -5
  22. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  23. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts +2 -2
  24. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +2 -2
  25. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +15 -6
  27. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +24 -12
  29. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/index.d.ts +250 -230
  31. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/index.js +6 -4
  33. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +8 -2
  35. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +10 -3
  37. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +7 -6
  39. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +7 -7
  41. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +2 -2
  43. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +8 -5
  45. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  46. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -6
  47. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  48. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -8
  49. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  50. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +4 -4
  51. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +7 -7
  52. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +2 -2
  54. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +2 -2
  56. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +2 -2
  58. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +2 -2
  60. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  61. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +2 -2
  62. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +3 -3
  63. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  64. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +8 -2
  65. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  66. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +10 -3
  67. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  68. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +2 -2
  69. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +2 -2
  70. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  71. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +185 -35
  72. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  73. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +145 -82
  74. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  75. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +8 -2
  76. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  77. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +12 -5
  78. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  79. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +33 -8
  80. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -1
  81. package/dist/services/obsidian/jsonlogic-evaluator.js +171 -32
  82. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -1
  83. package/dist/services/obsidian/jsonlogic-worker.d.ts +6 -5
  84. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -1
  85. package/dist/services/obsidian/jsonlogic-worker.js +7 -5
  86. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -1
  87. package/dist/services/obsidian/obsidian-service.d.ts +90 -36
  88. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  89. package/dist/services/obsidian/obsidian-service.js +748 -248
  90. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  91. package/dist/services/obsidian/patch-instruction.d.ts +4 -1
  92. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  93. package/dist/services/obsidian/patch-instruction.js +5 -2
  94. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  95. package/dist/services/obsidian/path-policy.d.ts +67 -23
  96. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  97. package/dist/services/obsidian/path-policy.js +183 -80
  98. package/dist/services/obsidian/path-policy.js.map +1 -1
  99. package/dist/services/obsidian/section-extractor.d.ts +62 -1
  100. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  101. package/dist/services/obsidian/section-extractor.js +387 -51
  102. package/dist/services/obsidian/section-extractor.js.map +1 -1
  103. package/dist/services/obsidian/simple-search.d.ts +62 -0
  104. package/dist/services/obsidian/simple-search.d.ts.map +1 -0
  105. package/dist/services/obsidian/simple-search.js +264 -0
  106. package/dist/services/obsidian/simple-search.js.map +1 -0
  107. package/manifest.json +12 -4
  108. package/package.json +9 -9
  109. package/server.json +23 -11
  110. package/skills/obsidian-guide/SKILL.md +61 -0
@@ -4,22 +4,69 @@
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 { 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
- import { assertTreeDepth, createJsonLogicEvaluation, readsContent } from './jsonlogic-evaluator.js';
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
- import { PathPolicy } from './path-policy.js';
14
- import { atxHeadingMarkers, blockKinds, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
13
+ import { caseFoldedPattern, PathPolicy } from './path-policy.js';
14
+ import { atxHeadingMarkers, blockKinds, deepestAncestor, droppedDefinitionInReach, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
15
+ import { isHighSurrogate, isLowSurrogate, SimpleSearch, simpleSearchTokens, } from './simple-search.js';
15
16
  /**
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.
17
+ * Most note-body bytes (by `stat.size`) a scoped JSONLogic search holds and
18
+ * evaluates at once, so a broad scope never holds every readable body. Each
19
+ * body is its own `GET /vault/{path}`, so the batch size sets memory, not the
20
+ * number of upstream reads.
21
21
  */
22
22
  const CONTENT_BATCH_BYTES = 32 * 1024 * 1024;
23
+ /**
24
+ * Most note-body reads in flight at once, across every search the service
25
+ * runs, so concurrent searches share the bound rather than each taking it.
26
+ * The plugin serves every read from Obsidian's own event loop, so each request
27
+ * in flight queues there; 32 keeps a whole-vault read well inside
28
+ * {@link SCOPED_SEARCH_DEADLINE_MS} without queuing more than that.
29
+ */
30
+ const BODY_READ_CONCURRENCY = 32;
31
+ /**
32
+ * Wall-clock ceiling on one scoped text or JSONLogic search: its upstream
33
+ * search, every body read with its retries, and the matching or evaluation.
34
+ * One deadline for the whole search, not one per request, so a search over a
35
+ * broad scope answers or fails inside the 60 s an MCP client waits for a
36
+ * response by default.
37
+ */
38
+ const SCOPED_SEARCH_DEADLINE_MS = 45_000;
39
+ /**
40
+ * Most characters the plugin's `/search/simple/` response may take for the
41
+ * hits of a scoped text search: V8's longest string (2^29 − 24 on 64-bit),
42
+ * where the plugin's `res.json` fails serializing it. `SimpleSearch` counts
43
+ * that response as the plugin writes it, each match's offsets, escaped window,
44
+ * and framing with every hit's filename and score, so a scoped search fails
45
+ * with `context_length_too_large` where the plugin would fail over the
46
+ * readable notes alone, and never holds more matches than such a response.
47
+ */
48
+ const SCOPED_TEXT_RESPONSE_CHARS = 2 ** 29 - 24;
49
+ /**
50
+ * Run a scoped search under {@link SCOPED_SEARCH_DEADLINE_MS}: `run` gets a
51
+ * signal that aborts with `ctx.signal` or at the deadline, and a failure
52
+ * caused by the deadline alone is replaced by `tooCostly(cause)`.
53
+ */
54
+ async function withScopedDeadline(ctx, tooCostly, run) {
55
+ // A timer rather than `AbortSignal.timeout`, which can fail on a realm mismatch under Bun's stdio transport.
56
+ const deadline = new AbortController();
57
+ const timer = setTimeout(() => deadline.abort(new DOMException('Scoped search deadline', 'TimeoutError')), SCOPED_SEARCH_DEADLINE_MS);
58
+ try {
59
+ return await run(AbortSignal.any([ctx.signal, deadline.signal]));
60
+ }
61
+ catch (err) {
62
+ if (!deadline.signal.aborted || ctx.signal.aborted)
63
+ throw err;
64
+ throw tooCostly(err);
65
+ }
66
+ finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
23
70
  /** 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
71
  function contentBatches(notes) {
25
72
  const batches = [];
@@ -39,16 +86,24 @@ function contentBatches(notes) {
39
86
  return batches;
40
87
  }
41
88
  /**
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.
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.
45
93
  */
46
94
  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. */
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
+ */
48
99
  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;
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;
52
107
  for (const hit of hits) {
53
108
  if (total > limit)
54
109
  break;
@@ -56,6 +111,38 @@ function walkChars(tree, hits, limit) {
56
111
  }
57
112
  return total;
58
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
+ }
59
146
  /** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
60
147
  const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
61
148
  /**
@@ -91,6 +178,17 @@ const HEADING_DELIMITER = '::';
91
178
  * (duplicate `append`, re-run Obsidian command).
92
179
  */
93
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).`;
94
192
  /**
95
193
  * Codes a TLS stack reports when it refuses the server's certificate. Both
96
194
  * runtimes use OpenSSL's names. The first four were each produced on Bun 1.4.0
@@ -145,13 +243,30 @@ export class ObsidianService {
145
243
  #capabilities;
146
244
  /**
147
245
  * The markdown-patch format the installed plugin speaks, read once from its
148
- * capability report. Holds the in-flight promise so concurrent writes share
149
- * 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.
150
249
  */
151
250
  #patchFormat;
152
- /** 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`). */
153
252
  #walks = new Map();
154
253
  #walkChars = 0;
254
+ /**
255
+ * The queue every note-body read of a scoped text or JSONLogic search waits
256
+ * in, so {@link BODY_READ_CONCURRENCY} bounds the reads in flight across
257
+ * concurrent searches rather than per search.
258
+ */
259
+ #bodyReads = createPacer({
260
+ name: 'obsidian-note-bodies',
261
+ maxConcurrent: BODY_READ_CONCURRENCY,
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();
155
270
  /**
156
271
  * @param config - Validated server config (api key, base URL, TLS, timeouts).
157
272
  * @param fetchImpl - Optional fetch override for tests. Defaults to undici's
@@ -165,11 +280,17 @@ export class ObsidianService {
165
280
  * Node honors this dispatcher option and ignores the per-request `tls` one;
166
281
  * Bun's `fetch` does the reverse. Both are sent on every request (see
167
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.
168
289
  */
169
290
  this.#dispatcher = new Agent({
170
291
  connect: { rejectUnauthorized: config.verifySsl },
171
- headersTimeout: config.requestTimeoutMs,
172
- bodyTimeout: config.requestTimeoutMs,
292
+ headersTimeout: 0,
293
+ bodyTimeout: 0,
173
294
  });
174
295
  this.#fetch = fetchImpl ?? undiciFetch;
175
296
  }
@@ -183,14 +304,16 @@ export class ObsidianService {
183
304
  }
184
305
  /**
185
306
  * Close the undici dispatcher, releasing its keep-alive sockets to the Local
186
- * REST API and Omnisearch. Wired through `createApp({ teardown })`; waits for
187
- * in-flight requests to settle rather than aborting them.
307
+ * REST API and Omnisearch, after rejecting the note-body reads still queued.
308
+ * Wired through `createApp({ teardown })`; waits for in-flight requests to
309
+ * settle rather than aborting them.
188
310
  *
189
311
  * Bun resolves `undici` to a built-in shim whose `Agent` is an inert object
190
312
  * with no `close` — Bun's `fetch` pools sockets itself and ignores the
191
313
  * dispatcher — so there is nothing to release there.
192
314
  */
193
315
  async close() {
316
+ this.#bodyReads.dispose();
194
317
  const dispatcher = this.#dispatcher;
195
318
  await dispatcher.close?.();
196
319
  }
@@ -313,14 +436,29 @@ export class ObsidianService {
313
436
  * applied to — identical to `instruction.target` unless a bare heading leaf
314
437
  * was expanded to its full `Parent::Child` path (see `#resolveHeadingTarget`),
315
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.
316
444
  */
317
445
  async patchNote(ctx, target, content, instruction) {
318
446
  const safe = await this.#gateAsWrite(ctx, target);
319
447
  const url = this.#targetToPath(safe);
320
448
  const format = await this.#wireFormat(ctx, safe, instruction);
321
- const { target: resolvedTarget, note } = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
449
+ const { target: resolvedTarget, note, anchored, } = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
322
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
+ };
323
459
  if (format === '1') {
460
+ if (anchored)
461
+ this.#refuseDroppedDefinition(safe, resolved, await readNote());
324
462
  await this.#request(ctx, url, {
325
463
  method: 'PATCH',
326
464
  headers: v1PatchHeaders(resolved),
@@ -330,10 +468,13 @@ export class ObsidianService {
330
468
  });
331
469
  return resolvedTarget;
332
470
  }
333
- 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
+ }
334
475
  const body = continuation
335
476
  ? v2Instruction(resolved, continuation.content, continuation.within)
336
- : v2Instruction(resolved, await this.#sectionRelativeContent(ctx, safe, resolved, content));
477
+ : v2Instruction(resolved, await this.#sectionRelativeContent(safe, resolved, content, readNote));
337
478
  await this.#request(ctx, url, {
338
479
  method: 'PATCH',
339
480
  headers: { 'Content-Type': PATCH_INSTRUCTION_CT, [MARKDOWN_PATCH_VERSION_HEADER]: '2' },
@@ -359,25 +500,18 @@ export class ObsidianService {
359
500
  * coddingtonbear/obsidian-local-rest-api#237). Always prefer delivered
360
501
  * bytes or HEAD over the metadata field.
361
502
  *
362
- * Bypasses retries (a 404 is the answer, not a transient failure) and
363
- * 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.
364
506
  */
365
507
  async tryGetSize(ctx, target) {
366
508
  if (target.type === 'path') {
367
509
  this.#policy.assertReadable(target.path, target.resolvedFrom);
368
510
  }
369
511
  const url = this.#targetToPath(target);
370
- const requestUrl = `${this.#config.baseUrl}${url}`;
371
- const res = await this.#send(ctx, requestUrl, {
372
- method: 'HEAD',
373
- headers: { Authorization: `Bearer ${this.#config.apiKey}` },
374
- });
512
+ const res = await this.#request(ctx, url, { method: 'HEAD', noteRead: true, missingOk: true });
375
513
  if (res.status === 404)
376
514
  return null;
377
- if (!res.ok) {
378
- await this.#throwForStatus(res, url, ctx, { method: 'HEAD', requestedUrl: requestUrl });
379
- }
380
- this.#assertNotDirectory(res, url);
381
515
  return parseContentLength(res, url);
382
516
  }
383
517
  /**
@@ -400,7 +534,7 @@ export class ObsidianService {
400
534
  let url = '/vault/';
401
535
  let normalized = '';
402
536
  if (dirPath) {
403
- normalized = dirPath.replace(/^\/+|\/+$/g, '');
537
+ normalized = trimSlashes(dirPath);
404
538
  if (normalized)
405
539
  url = `/vault/${encodeVaultPath(normalized)}/`;
406
540
  }
@@ -423,9 +557,10 @@ export class ObsidianService {
423
557
  /**
424
558
  * Vault tags with usage counts, hierarchical parents included. Unscoped, the
425
559
  * 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.
560
+ * per-note attribution, so under OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS
561
+ * the tags are read from the readable notes' contexts instead (one upstream
562
+ * search, no bodies), and each one counts the readable notes carrying it or a
563
+ * tag nested under it.
429
564
  */
430
565
  async listTags(ctx) {
431
566
  if (this.#policy.restrictsReads) {
@@ -446,13 +581,18 @@ export class ObsidianService {
446
581
  * (`contextRelativeSpan`), then a multi-token query's spans are merged into
447
582
  * match locations (`mergeIntoLocations`).
448
583
  *
449
- * The upstream materializes one context window per matching note before it
450
- * responds, so an oversized `contextLength` on a broad query exhausts V8's
451
- * string capacity and comes back as an opaque 500 — caught here and
452
- * re-thrown typed, the way `searchOmnisearch` re-throws its own reachability
453
- * failure.
584
+ * The upstream serializes every match in the vault, its context window and
585
+ * about 150 characters of offsets and framing, into one response, so a
586
+ * broad query exhausts V8's string capacity, even at a small
587
+ * `contextLength`, and comes back as an opaque 500 — caught
588
+ * here and re-thrown typed, the way `searchOmnisearch` re-throws its own
589
+ * reachability failure. Under OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS
590
+ * that would make the outcome depend on notes outside the scope, so the
591
+ * search runs here instead (`#searchTextScoped`).
454
592
  */
455
593
  async searchText(ctx, query, contextLength = 100) {
594
+ if (this.#policy.restrictsReads)
595
+ return await this.#searchTextScoped(ctx, query, contextLength);
456
596
  const params = new URLSearchParams({ query, contextLength: String(contextLength) });
457
597
  let res;
458
598
  try {
@@ -467,112 +607,247 @@ export class ObsidianService {
467
607
  * and through it the upstream body `#throwForStatus` attached — on the
468
608
  * server side for the log record.
469
609
  */
470
- throw validationError(`The Local REST API ran out of string capacity assembling match contexts for this search (contextLength ${contextLength}). It builds a context window for every matching note before responding, so the ceiling tracks contextLength multiplied by the number of matches rather than either alone.`, {
610
+ throw validationError(`The Local REST API ran out of string capacity serializing the matches for this search (contextLength ${contextLength}). Each match costs its context window plus about 150 characters of offsets and framing, so the total tracks the number of matches as well as contextLength.`, {
471
611
  reason: 'context_length_too_large',
472
612
  contextLength,
473
613
  }, { cause: err });
474
614
  }
475
615
  const raw = (await res.json());
476
- const tokens = queryTokens(query);
477
- // Upstream returns a constant `score` that carries no ranking signal for
478
- // text mode — drop it on the way out so consumers don't mistake it for
479
- // relevance. Omnisearch is the source of real BM25 ranking.
616
+ const tokens = simpleSearchTokens(query);
617
+ // Upstream's `score` orders the hits but is not a relevance measure — drop
618
+ // it on the way out so consumers don't mistake it for one. Omnisearch is
619
+ // the source of real BM25 ranking.
480
620
  return raw.map((h) => ({
481
621
  filename: h.filename,
482
- matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
622
+ matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength),
483
623
  }));
484
624
  }
625
+ /**
626
+ * Text search under a read scope, with no `/search/simple/` request: one
627
+ * `/search/` guarded by the read scope and, per token, a case-folded
628
+ * `regexp` on `path` or `content` (`#textCandidates`) names the readable
629
+ * notes that may hold every token, their bodies are read one GET each
630
+ * (`#bodyBatches`), and `SimpleSearch` matches them the way the plugin
631
+ * does, in the plugin's order. `context_length_too_large` then counts the
632
+ * plugin's response over the readable notes alone, against
633
+ * `SCOPED_TEXT_RESPONSE_CHARS`. Every request shares `ctx.signal` and one
634
+ * deadline, `SCOPED_SEARCH_DEADLINE_MS`; past it the search fails with
635
+ * `text_search_too_costly`.
636
+ */
637
+ async #searchTextScoped(ctx, query, contextLength) {
638
+ const search = new SimpleSearch(query, contextLength, SCOPED_TEXT_RESPONSE_CHARS);
639
+ await withScopedDeadline(ctx, (cause) => validationError(`The scoped text search ran past ${SCOPED_SEARCH_DEADLINE_MS / 1000} s reading and matching the readable notes.`, { reason: 'text_search_too_costly' }, { cause }), async (signal) => {
640
+ const notes = await this.#textCandidates(ctx, search.tokens, signal);
641
+ // With no token every note matches with no span, and no body can change that.
642
+ if (search.tokens.length === 0)
643
+ for (const note of notes)
644
+ search.add(note.path, '');
645
+ else {
646
+ for await (const batch of this.#bodyBatches(ctx, notes, signal)) {
647
+ for (const note of batch)
648
+ search.add(note.path, note.content);
649
+ }
650
+ }
651
+ });
652
+ return search.hits().map((h) => ({
653
+ filename: h.filename,
654
+ matches: mergeIntoLocations(h.matches.map(({ context, contextStart, match }) => ({
655
+ context,
656
+ match: {
657
+ start: match.start,
658
+ end: match.end,
659
+ contextStart,
660
+ contextEnd: contextStart + match.end - match.start,
661
+ },
662
+ subject: match.source === 'filename' ? 'filename' : 'body',
663
+ })), search.tokens.length, contextLength),
664
+ }));
665
+ }
666
+ /**
667
+ * The readable notes that may hold every token of a scoped text search, in
668
+ * vault order, from one upstream search: the read-scope guard
669
+ * (`PathPolicy.readScopePattern`) and, per token, a case-folded `regexp`
670
+ * (`caseFoldedPattern`) on `path` or `content`, the two places the plugin's
671
+ * search looks. A token with a character the folding cannot express, or a
672
+ * line break that could span the basename and the body the plugin joins,
673
+ * sends no `regexp`, so the notes returned always include every readable
674
+ * match; `filterReadable` decides what is kept. With no `regexp` at all the
675
+ * tree never names `content`, so the plugin reads no bodies.
676
+ */
677
+ async #textCandidates(ctx, tokens, signal) {
678
+ const clauses = [{ regexp: [this.#policy.readScopePattern(), { var: 'path' }] }];
679
+ for (const token of tokens) {
680
+ const pattern = token.includes('\n') ? undefined : caseFoldedPattern(token);
681
+ if (pattern === undefined)
682
+ continue;
683
+ clauses.push({
684
+ or: [{ regexp: [pattern, { var: 'path' }] }, { regexp: [pattern, { var: 'content' }] }],
685
+ });
686
+ }
687
+ const guard = { if: [{ and: clauses }, { var: 'stat' }, null] };
688
+ const res = await this.#postJsonLogic(ctx, guard, signal);
689
+ const hits = (await res.json());
690
+ return this.#policy
691
+ .filterReadable(hits)
692
+ .map(({ filename, result }) => ({ path: filename, stat: result }));
693
+ }
485
694
  /**
486
695
  * 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
696
+ * OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS (`restrictsReads`) the plugin
697
+ * would evaluate it against every note in the vault, denied ones included,
698
+ * so the tree stays here instead: the readable notes' contexts
489
699
  * are fetched and the tree is evaluated over those alone
490
700
  * (`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.
701
+ * result, or error then reads a note outside the read scope. The contexts
702
+ * carry no `links`, `backlinks`, or `unresolvedLinks`, since which note a
703
+ * link resolves to depends on notes outside the scope; a tree that names
704
+ * one fails before any request (`assertNoLinkFields`).
495
705
  *
496
706
  * 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.
707
+ * `"content"` (`readsContent`); such a tree gets them here too, one read
708
+ * per readable note, evaluated in batches of at most `CONTENT_BATCH_BYTES`
709
+ * so a broad scope never holds every readable body at once. Every request
710
+ * the search makes shares `ctx.signal` and one deadline,
711
+ * `SCOPED_SEARCH_DEADLINE_MS`; past it the search fails with
712
+ * `logic_too_costly`.
500
713
  */
501
714
  async searchJsonLogic(ctx, logic) {
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;
715
+ if (!this.#policy.restrictsReads) {
716
+ const res = await this.#postJsonLogic(ctx, logic);
717
+ return (await res.json());
515
718
  }
516
- const res = await this.#postJsonLogic(ctx, logic);
517
- return (await res.json());
719
+ assertTreeDepth(logic);
720
+ assertNoLinkFields(logic);
721
+ return await withScopedDeadline(ctx, (cause) => validationError(`The scoped JSONLogic search ran past ${SCOPED_SEARCH_DEADLINE_MS / 1000} s reading and evaluating the readable notes.`, {
722
+ reason: 'logic_too_costly',
723
+ recovery: {
724
+ hint: 'A tree that names `content` reads the body of every readable note. Test `path`, `tags`, `frontmatter`, or `stat` instead where they can answer, or ask the operator to narrow OBSIDIAN_READ_PATHS or extend OBSIDIAN_DENY_PATHS.',
725
+ },
726
+ }, { cause }), async (signal) => {
727
+ const evaluation = createJsonLogicEvaluation(logic, { signal });
728
+ try {
729
+ const notes = await this.#readableNoteContexts(ctx, signal);
730
+ if (!readsContent(logic))
731
+ return await evaluation.evaluate(notes);
732
+ const hits = [];
733
+ for await (const batch of this.#bodyBatches(ctx, notes, signal)) {
734
+ hits.push(...(await evaluation.evaluate(batch)));
735
+ }
736
+ return hits;
737
+ }
738
+ finally {
739
+ await evaluation.close();
740
+ }
741
+ });
518
742
  }
519
743
  /**
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`;
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`;
524
749
  * a call naming that id with the same tree is served from the stored list
525
750
  * without evaluating. The store belongs to this service, whose read scope
526
751
  * 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.
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.
530
755
  */
531
- async pageJsonLogic(ctx, logic, walk, pageSize) {
756
+ async pageJsonLogic(ctx, logic, walk, pageEnd) {
532
757
  if (!this.#policy.restrictsReads)
533
758
  return { hits: await this.searchJsonLogic(ctx, logic) };
534
759
  // Before serializing it: a tree deep enough overflows `JSON.stringify`'s stack under Bun.
535
760
  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
- }
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 };
544
765
  const hits = await this.searchJsonLogic(ctx, logic);
545
- 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
+ });
546
774
  return id === undefined ? { hits } : { hits, walk: id };
547
775
  }
548
776
  /**
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.
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`.
813
+ */
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.
552
827
  */
553
- #storeWalk(tree, hits) {
554
- const chars = walkChars(tree, hits, WALK_STORE_CHARS);
555
- if (chars > WALK_STORE_CHARS)
828
+ #storeWalk(walk) {
829
+ if (walk.chars > WALK_STORE_CHARS)
556
830
  return;
557
831
  const now = Date.now();
558
832
  // Every walk lives WALK_TTL_MS past its last read, so expired walks lead the last-read order.
559
833
  for (const [id, old] of this.#walks) {
560
- if (old.expiresAt > now && this.#walkChars + chars <= WALK_STORE_CHARS)
834
+ if (old.expiresAt > now && this.#walkChars + walk.chars <= WALK_STORE_CHARS)
561
835
  break;
562
836
  this.#walks.delete(id);
563
837
  this.#walkChars -= old.chars;
564
838
  }
565
839
  const id = crypto.randomUUID();
566
- this.#walks.set(id, { tree, hits, chars, expiresAt: now + WALK_TTL_MS });
567
- this.#walkChars += chars;
840
+ this.#walks.set(id, { ...walk, id, expiresAt: now + WALK_TTL_MS });
841
+ this.#walkChars += walk.chars;
568
842
  return id;
569
843
  }
570
844
  /** One upstream JSONLogic search: `tree` posted to `/search/`. */
571
- #postJsonLogic(ctx, tree) {
845
+ #postJsonLogic(ctx, tree, signal = ctx.signal) {
572
846
  return this.#request(ctx, '/search/', {
573
847
  method: 'POST',
574
848
  headers: { 'Content-Type': JSONLOGIC_CT },
575
849
  body: JSON.stringify(tree),
850
+ signal,
576
851
  });
577
852
  }
578
853
  /**
@@ -580,14 +855,14 @@ export class ObsidianService {
580
855
  * leaves them, in vault order, from one upstream search whose tree is the
581
856
  * server's own: a `regexp` guard on the path (`PathPolicy.readScopePattern`)
582
857
  * 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.
858
+ * kept. Each context carries only the fields the plugin documents, less the
859
+ * link fields (`RawNoteContextHit`).
585
860
  */
586
- async #readableNoteContexts(ctx) {
861
+ async #readableNoteContexts(ctx, signal = ctx.signal) {
587
862
  const guard = {
588
863
  if: [{ regexp: [this.#policy.readScopePattern(), { var: 'path' }] }, { var: '' }, null],
589
864
  };
590
- const res = await this.#postJsonLogic(ctx, guard);
865
+ const res = await this.#postJsonLogic(ctx, guard, signal);
591
866
  const hits = (await res.json());
592
867
  return this.#policy.filterReadable(hits).map(({ filename, result }) => ({
593
868
  tags: result.tags,
@@ -595,35 +870,68 @@ export class ObsidianService {
595
870
  stat: result.stat,
596
871
  path: filename,
597
872
  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
873
  }));
602
874
  }
603
875
  /**
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.
876
+ * The bodies of `notes` — readable notes, already gated — in their order,
877
+ * as runs whose `stat.size` totals at most `CONTENT_BATCH_BYTES` (a larger
878
+ * note gets a run of its own). Each body is one `GET /vault/{path}`, which
879
+ * reads that file alone, where a JSONLogic search naming `content` would
880
+ * read every vault note's body. Reads wait in the service's queue, at most
881
+ * `BODY_READ_CONCURRENCY` in flight. A run is read only when the caller
882
+ * asks for the next one, so one run is held at a time. A note gone by the
883
+ * time it is read has an empty body, as the plugin's search gives it; any
884
+ * other failure stops the run's remaining reads, waits for the ones in
885
+ * flight to settle, and is thrown. Every request carries `signal`.
607
886
  */
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);
887
+ async *#bodyBatches(ctx, notes, signal) {
888
+ for (const run of contentBatches(notes)) {
889
+ const stop = new AbortController();
890
+ const reads = AbortSignal.any([signal, stop.signal]);
891
+ const queue = run.entries();
892
+ const withBodies = [];
893
+ let failure;
894
+ const lane = async () => {
895
+ while (failure === undefined) {
896
+ const next = queue.next();
897
+ if (next.done)
898
+ return;
899
+ const [index, note] = next.value;
900
+ try {
901
+ const content = await this.#bodyReads.run((granted) => this.#noteBody(ctx, note.path, granted), { signal: reads });
902
+ withBodies[index] = { ...note, content };
903
+ }
904
+ catch (error) {
905
+ failure ??= { error };
906
+ stop.abort();
907
+ }
908
+ }
909
+ };
910
+ await Promise.all(Array.from({ length: Math.min(BODY_READ_CONCURRENCY, run.length) }, lane));
911
+ if (failure)
912
+ throw failure.error;
913
+ yield withBodies;
617
914
  }
618
- return bodies;
915
+ }
916
+ /** One note's body, read raw as UTF-8 the way the plugin's search reads it; `""` once the note is gone. */
917
+ async #noteBody(ctx, path, signal) {
918
+ const res = await this.#request(ctx, `/vault/${encodeVaultPath(path)}`, {
919
+ method: 'GET',
920
+ headers: { Accept: 'text/markdown' },
921
+ noteRead: true,
922
+ missingOk: true,
923
+ signal,
924
+ });
925
+ const body = await res.text();
926
+ return res.status === 404 ? '' : body;
619
927
  }
620
928
  /**
621
929
  * One-shot startup probe for the Omnisearch plugin's HTTP endpoint. Returns
622
930
  * `true` only when the response is `HTTP 200`, declares `application/json`,
623
931
  * and the body parses as a JSON array — unrouted paths on the Omnisearch
624
932
  * server also return `200` with an empty body, so status alone is
625
- * insufficient. The entry point passes the return value into the
626
- * `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
627
935
  * `omnisearch` mode.
628
936
  */
629
937
  async probeOmnisearch(signal) {
@@ -652,25 +960,27 @@ export class ObsidianService {
652
960
  * Query the Omnisearch HTTP endpoint. Normalizes the response on the way
653
961
  * out: decodes HTML entities + `<br>` → `\n` in `excerpt`, renames `path`
654
962
  * to `filename`, and drops `vault`. Throws `omnisearch_unreachable`
655
- * (ServiceUnavailable) on network failures or non-2xx responses — the
656
- * plugin can shut down mid-session (Obsidian quits, plugin disabled), and
657
- * 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.
658
968
  */
659
969
  async searchOmnisearch(ctx, query) {
660
970
  const url = `${this.#omnisearchUrl}/search?q=${encodeURIComponent(query)}`;
661
971
  let res;
662
972
  try {
663
- res = await this.#fetch(url, {
973
+ res = await withRequestTimer(ctx.signal, this.#config.requestTimeoutMs, async (signal) => await buffered(await this.#fetch(url, {
664
974
  method: 'GET',
665
975
  dispatcher: this.#dispatcher,
666
976
  ...this.#tlsInit(url),
667
- signal: ctx.signal,
668
- });
977
+ signal,
978
+ })), (expiry) => expiry);
669
979
  }
670
980
  catch (err) {
671
981
  if (ctx.signal?.aborted)
672
982
  throw err;
673
- 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.`, {
674
984
  reason: 'omnisearch_unreachable',
675
985
  url: this.#omnisearchUrl,
676
986
  }, { cause: err });
@@ -791,17 +1101,26 @@ export class ObsidianService {
791
1101
  *
792
1102
  * The report is shared with `#vaultCapabilities`: whichever reads it first
793
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.
794
1110
  */
795
1111
  async #markdownPatchFormat(ctx) {
796
- this.#patchFormat ??= this.#negotiatePatchFormat(ctx);
797
- const pending = this.#patchFormat;
798
- try {
799
- return await pending;
800
- }
801
- catch (err) {
802
- if (this.#patchFormat === pending)
803
- this.#patchFormat = undefined;
804
- 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
+ }
805
1124
  }
806
1125
  }
807
1126
  /**
@@ -811,7 +1130,9 @@ export class ObsidianService {
811
1130
  *
812
1131
  * - Rows under a heading. 2.0 takes table rows only through a block target
813
1132
  * (a heading write carries its payload in `content`, never `value`); the
814
- * 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.
815
1136
  * - Rows through an isolated block id — `^id` on its own line after a blank
816
1137
  * line, the way Obsidian places a table's id. 2.0 resolves the id to that
817
1138
  * marker line's own paragraph rather than the table above it, so the write
@@ -857,9 +1178,10 @@ export class ObsidianService {
857
1178
  * - the section already holds the content and duplicates are refused — a
858
1179
  * `within` write searches only the list, the plain one the whole section.
859
1180
  *
860
- * 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.
861
1183
  */
862
- async #listContinuation(ctx, target, instruction, content) {
1184
+ async #listContinuation(instruction, content, readNote) {
863
1185
  const { operation } = instruction;
864
1186
  if (instruction.targetType !== 'heading' || instruction.contentType === 'json')
865
1187
  return;
@@ -871,8 +1193,7 @@ export class ObsidianService {
871
1193
  return;
872
1194
  if (atxHeadingMarkers(reduced).markers.length > 0)
873
1195
  return;
874
- const { content: note } = await this.#rawGetNoteJson(ctx, target);
875
- const body = sectionBody(note, instruction.target);
1196
+ const body = sectionBody(await readNote(), instruction.target);
876
1197
  if (!body || (operation === 'append' && body.subsections))
877
1198
  return;
878
1199
  if ((operation === 'append' ? body.blocks.at(-1) : body.blocks[0]) !== 'list')
@@ -885,19 +1206,18 @@ export class ObsidianService {
885
1206
  * Heading content with its `#` levels rewritten to the section-relative ones
886
1207
  * markdown-patch 2.0 reads (see `relativeHeadingLevels`), so the note gets
887
1208
  * the levels the caller wrote, as it did under 1.x. The section's level comes
888
- * from the note itself, read only when the content carries an ATX heading.
889
- * Every other payload passes through as written, and so does content for a
890
- * section the note lacks and the write will not create, which the plugin
891
- * 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.
892
1213
  */
893
- async #sectionRelativeContent(ctx, target, instruction, content) {
1214
+ async #sectionRelativeContent(target, instruction, content, readNote) {
894
1215
  if (instruction.targetType !== 'heading' || instruction.contentType === 'json')
895
1216
  return content;
896
1217
  const fragment = atxHeadingMarkers(content);
897
1218
  if (fragment.markers.length === 0)
898
1219
  return content;
899
- const { content: note } = await this.#rawGetNoteJson(ctx, target);
900
- const level = sectionLevel(note, instruction.target, instruction.createTargetIfMissing === true);
1220
+ const level = sectionLevel(await readNote(), instruction.target, instruction.createTargetIfMissing === true);
901
1221
  if (level === undefined)
902
1222
  return content;
903
1223
  const relative = relativeHeadingLevels(fragment, level);
@@ -935,19 +1255,31 @@ export class ObsidianService {
935
1255
  * on the last repeat while a section read returns the first. A write to a
936
1256
  * repeated path is rejected as ambiguous. The count comes from the plugin's
937
1257
  * own parse (`#headingIndex`) wherever the plugin serves one, so the map the
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`.
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.
940
1268
  */
941
1269
  async #resolveHeadingTarget(ctx, target, instruction, format) {
942
1270
  if (instruction.targetType !== 'heading')
943
- return { target: instruction.target, note: undefined };
1271
+ return { target: instruction.target, note: undefined, anchored: false };
944
1272
  const { headings, occurrences, note } = await this.#headingIndex(ctx, target, format);
945
1273
  const resolved = instruction.target.includes(HEADING_DELIMITER)
946
1274
  ? instruction.target
947
1275
  : this.#expandHeadingLeaf(target, instruction.target, headings);
948
1276
  const repeats = occurrences.filter((p) => p === resolved);
949
- if (repeats.length <= 1)
950
- 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
+ }
951
1283
  const display = displayPath(this.#targetToPath(target));
952
1284
  const named = resolved === instruction.target ? `'${resolved}'` : `'${instruction.target}' (${resolved})`;
953
1285
  throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
@@ -975,7 +1307,8 @@ export class ObsidianService {
975
1307
  *
976
1308
  * Both lists run in note order, as `#rawGetDocumentMap` explains, so the
977
1309
  * `candidates` an ambiguity error names, and the first one its hint offers,
978
- * follow the note.
1310
+ * follow the note. On 2.0 the note read that order takes comes back as
1311
+ * `note` too.
979
1312
  */
980
1313
  async #headingIndex(ctx, target, format) {
981
1314
  const url = this.#targetToPath(target);
@@ -987,10 +1320,36 @@ export class ObsidianService {
987
1320
  }
988
1321
  const map = await this.#fetchDocumentMap(ctx, url, format);
989
1322
  const flat = flattenHeadingTree(map.headings);
990
- const occurrences = hasIntegerKey(map.headings)
991
- ? await this.#inNoteOrder(ctx, target, flat)
992
- : flat;
993
- 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}]`] });
994
1353
  }
995
1354
  /**
996
1355
  * Expand a bare heading leaf against the note's heading paths. Resolution
@@ -1065,45 +1424,82 @@ export class ObsidianService {
1065
1424
  return { tls: { rejectUnauthorized: false } };
1066
1425
  }
1067
1426
  /**
1068
- * One request to the Local REST API — the single attempt `#request` hands
1069
- * to `withRetry`, and `tryGetSize`'s HEAD — with the dispatcher, the scoped
1070
- * 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.
1071
1432
  *
1072
- * A rejected `fetch` is classified here, inside the attempt, because the
1073
- * attempt is where the retry decision is made: a certificate rejection comes
1074
- * back as a non-transient `ConfigurationError` and `withRetry` rethrows it
1075
- * on the spot, while an unreachable plugin comes back as a transient
1076
- * `ServiceUnavailable` and keeps its retries (issues #133 and #136). A
1077
- * rejection that is neither passes through untouched, and so does anything
1078
- * thrown after the caller aborted — 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.
1079
1442
  */
1080
- async #send(ctx, url, init) {
1443
+ async #send(ctx, url, init, signal = ctx.signal) {
1444
+ let res;
1081
1445
  try {
1082
- return await this.#fetch(url, {
1446
+ res = await this.#fetch(url, {
1083
1447
  ...init,
1084
1448
  dispatcher: this.#dispatcher,
1085
1449
  ...this.#tlsInit(url),
1086
- signal: ctx.signal,
1450
+ signal,
1087
1451
  });
1088
1452
  }
1089
1453
  catch (err) {
1090
- if (ctx.signal?.aborted)
1454
+ if (signal.aborted)
1455
+ throw err;
1456
+ throw classifyFetchRejection(err, 'request') ?? err;
1457
+ }
1458
+ try {
1459
+ return await buffered(res);
1460
+ }
1461
+ catch (err) {
1462
+ if (signal.aborted)
1091
1463
  throw err;
1092
- throw classifyFetchRejection(err) ?? err;
1464
+ throw classifyFetchRejection(err, 'body') ?? err;
1093
1465
  }
1094
1466
  }
1095
- #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) {
1096
1477
  const url = `${this.#config.baseUrl}${pathAndQuery}`;
1097
1478
  const headers = {
1098
1479
  ...(init.headers ?? {}),
1099
1480
  Authorization: `Bearer ${this.#config.apiKey}`,
1100
1481
  };
1101
- const exec = async () => {
1102
- const res = await this.#send(ctx, url, {
1482
+ const signal = init.signal ?? ctx.signal;
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, {
1103
1497
  method: init.method,
1104
1498
  headers,
1105
1499
  ...(init.body !== undefined ? { body: init.body } : {}),
1106
- });
1500
+ }, timed), (expiry) => this.#timeoutError(boundMs < timeoutMs ? 'call' : 'request', expiry));
1501
+ if (res.status === 404 && init.missingOk)
1502
+ return res;
1107
1503
  if (!res.ok) {
1108
1504
  await this.#throwForStatus(res, pathAndQuery, ctx, {
1109
1505
  method: init.method,
@@ -1120,21 +1516,70 @@ export class ObsidianService {
1120
1516
  return res;
1121
1517
  };
1122
1518
  if (!RETRY_SAFE_METHODS.has(init.method.toUpperCase())) {
1123
- return exec();
1519
+ return await exec({ signal, remainingMs: budgetMs });
1124
1520
  }
1125
- return withRetry(exec, {
1126
- operation: `obsidian.${init.method} ${pathAndQuery}`,
1127
- context: {
1128
- requestId: ctx.requestId,
1129
- timestamp: ctx.timestamp,
1130
- ...(ctx.tenantId !== undefined ? { tenantId: ctx.tenantId } : {}),
1131
- ...(ctx.traceId !== undefined ? { traceId: ctx.traceId } : {}),
1132
- ...(ctx.spanId !== undefined ? { spanId: ctx.spanId } : {}),
1133
- },
1134
- baseDelayMs: 200,
1135
- maxRetries: 3,
1136
- signal: ctx.signal,
1137
- });
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 });
1138
1583
  }
1139
1584
  /**
1140
1585
  * Reject a `2xx` note read that actually served a folder listing. The Local
@@ -1277,7 +1722,8 @@ export class ObsidianService {
1277
1722
  * Issues #104, #116, #122, and #159.
1278
1723
  */
1279
1724
  async #throwForStatus(res, path, ctx, opts) {
1280
- 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();
1281
1727
  const body = parseJsonObject(text);
1282
1728
  const display = displayPath(path);
1283
1729
  /** Classification input and log payload only — never a wire value. */
@@ -1421,9 +1867,11 @@ export class ObsidianService {
1421
1867
  * Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
1422
1868
  * canonical mapping `httpErrorFromResponse` would (the whole 5xx range
1423
1869
  * except 504 → ServiceUnavailable, 504 → Timeout) without that
1424
- * helper's response-derived extras: it builds its message from the
1425
- * upstream's reason phrase and mirrors the body onto
1426
- * `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`.
1427
1875
  *
1428
1876
  * Two `data` fields are re-attached by hand because both steer
1429
1877
  * `withRetry` and neither carries vault content:
@@ -1445,14 +1893,6 @@ export class ObsidianService {
1445
1893
  }
1446
1894
  }
1447
1895
  }
1448
- async #readBodySafe(res) {
1449
- try {
1450
- return await res.text();
1451
- }
1452
- catch {
1453
- return '';
1454
- }
1455
- }
1456
1896
  }
1457
1897
  /**
1458
1898
  * Count, per tag, the notes carrying it or a tag nested under it. Each note's
@@ -1478,14 +1918,29 @@ function countTagsPerNote(notes) {
1478
1918
  }
1479
1919
  return Array.from(counts, ([name, count]) => ({ name, count }));
1480
1920
  }
1921
+ /**
1922
+ * `path` less its leading and trailing `/` runs, as `/^\/+|\/+$/g` leaves it,
1923
+ * found by scanning in from each end: the regex retries at every `/` of a run
1924
+ * inside the path, in time quadratic in the run.
1925
+ */
1926
+ export function trimSlashes(path) {
1927
+ let start = 0;
1928
+ let end = path.length;
1929
+ while (start < end && path[start] === '/')
1930
+ start++;
1931
+ while (end > start && path[end - 1] === '/')
1932
+ end--;
1933
+ return path.slice(start, end);
1934
+ }
1481
1935
  /**
1482
1936
  * Encode a vault-relative path for the URL. Splits on `/` and `\` (so
1483
1937
  * Windows-style separators are honored), URL-encodes each segment, and
1484
1938
  * rejoins with `/` since the Local REST API plugin expects forward slashes.
1485
1939
  *
1486
1940
  * Rejects `.` and `..` segments here rather than relying on the upstream Local
1487
- * REST API plugin to normalize them — `PathPolicy` short-circuits to "allow"
1488
- * when `OBSIDIAN_READ_PATHS` is unset, and `..` is unreserved per RFC 3986 so
1941
+ * REST API plugin to normalize them — with `OBSIDIAN_READ_PATHS` unset,
1942
+ * `PathPolicy` allows every path no deny prefix covers, and `..` is
1943
+ * unreserved per RFC 3986 so
1489
1944
  * `encodeURIComponent` leaves it intact. This is the single chokepoint before
1490
1945
  * URL construction, so guard vault escape here. Backslash is treated as a
1491
1946
  * separator so `..\..\etc` traverses identically to `../../etc` and can't
@@ -1512,15 +1967,6 @@ export function encodeVaultPath(path) {
1512
1967
  }
1513
1968
  return segments.map((seg) => encodeURIComponent(seg)).join('/');
1514
1969
  }
1515
- /**
1516
- * The query as Obsidian's `prepareSimpleSearch` reads it: split on whitespace,
1517
- * case-folded, each distinct token required. Quotes are ordinary characters.
1518
- */
1519
- function queryTokens(query) {
1520
- return [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
1521
- }
1522
- const isHighSurrogate = (cu) => cu >= 0xd800 && cu <= 0xdbff;
1523
- const isLowSurrogate = (cu) => cu >= 0xdc00 && cu <= 0xdfff;
1524
1970
  /**
1525
1971
  * Locate the match span inside the `context` window the upstream ships with
1526
1972
  * it, and name the subject its `start`/`end` index.
@@ -1600,9 +2046,10 @@ function bodyWindowOffset(m, contextLength, reproduces) {
1600
2046
  return !reproduces(base) && reproduces(base + 1) ? base + 1 : base;
1601
2047
  }
1602
2048
  /**
1603
- * Merge a hit's spans into match locations. `prepareSimpleSearch` reports one
1604
- * span per token occurrence and coalesces only spans that touch, so each word
1605
- * of a phrase arrives as its own span with a near-identical window.
2049
+ * Merge a hit's spans into the match locations `TextSearchHit` carries.
2050
+ * `prepareSimpleSearch` coalesces only token occurrences that overlap or
2051
+ * touch, so each word of a phrase arrives as its own span with a
2052
+ * near-identical window.
1606
2053
  *
1607
2054
  * Walking the spans in upstream order, a location takes the next span when all
1608
2055
  * of these hold:
@@ -1624,7 +2071,7 @@ function bodyWindowOffset(m, contextLength, reproduces) {
1624
2071
  */
1625
2072
  function mergeIntoLocations(spans, distinctTokens, contextLength) {
1626
2073
  if (distinctTokens < 2)
1627
- return spans;
2074
+ return spans.map(({ context, match }) => ({ context, match }));
1628
2075
  const locations = [];
1629
2076
  let current;
1630
2077
  for (const span of spans) {
@@ -1643,7 +2090,7 @@ function mergeIntoLocations(spans, distinctTokens, contextLength) {
1643
2090
  }
1644
2091
  if (current)
1645
2092
  locations.push(current.location);
1646
- return locations;
2093
+ return locations.map(({ context, match }) => ({ context, match }));
1647
2094
  }
1648
2095
  function matchedText(span) {
1649
2096
  return span.context.slice(span.match.contextStart, span.match.contextEnd).toLowerCase();
@@ -1843,23 +2290,26 @@ function callerIdentifier(urlPath) {
1843
2290
  return {};
1844
2291
  }
1845
2292
  /**
1846
- * Classify a rejected `fetch` — one that produced no response at all — or
1847
- * 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.
1848
2296
  *
1849
2297
  * The two runtimes put the code in different places: Bun on the thrown error
1850
2298
  * itself, Node's undici one level down on `cause` under a bare `TypeError:
1851
- * fetch failed`. Both levels are read, and the result is built from the code
1852
- * alone, so a Bun and a Node rejection produce the same message and `data`.
1853
- * Neither the runtime's own text nor the URL it names reaches the wire; the
1854
- * rejection rides as `cause`, the channel `#throwForStatus` uses for the same
1855
- * 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.
1856
2306
  *
1857
2307
  * The recovery hint is written inline, as `encodeVaultPath` writes
1858
2308
  * `path_traversal`'s: both failures are the operator's to fix and reachable
1859
2309
  * from every tool and resource — including the two resources that declare no
1860
2310
  * `errors[]` for the framework's contract fill to resolve against.
1861
2311
  */
1862
- function classifyFetchRejection(err) {
2312
+ function classifyFetchRejection(err, phase) {
1863
2313
  const codes = [err, err instanceof Error ? err.cause : undefined].flatMap((e) => {
1864
2314
  const code = e?.code;
1865
2315
  return typeof code === 'string' ? [code] : [];
@@ -1874,15 +2324,64 @@ function classifyFetchRejection(err) {
1874
2324
  }, { cause: err });
1875
2325
  }
1876
2326
  if (codes.some((code) => CONNECTION_FAILURE_CODES.has(code))) {
1877
- return serviceUnavailable('Could not connect to the Obsidian Local REST API.', {
1878
- reason: 'obsidian_unreachable',
1879
- recovery: {
1880
- 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.',
1881
- },
1882
- }, { 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 });
1883
2340
  }
1884
2341
  return;
1885
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
+ }
1886
2385
  /**
1887
2386
  * Convert an internal URL path (e.g. `/vault/Projects/My%20Note.md`) to the
1888
2387
  * vault-relative form a caller would recognize. Used in error messages so the
@@ -1932,12 +2431,13 @@ function parseContentLength(res, url) {
1932
2431
  /**
1933
2432
  * Resolve the Omnisearch URL. Override wins. Otherwise: take the host from
1934
2433
  * `OBSIDIAN_BASE_URL`, force `http:` (Omnisearch is HTTP-only), swap port
1935
- * `27123/27124` → `51361`. `127.0.0.1` is mapped to `localhost` since
1936
- * Omnisearch's current Node listener binds IPv4 only but the platform's
1937
- * loopback resolver is flexible — `localhost` insulates us if a future
1938
- * build switches binding. Falls back to `http://localhost:51361` on any
1939
- * URL parse failure (config validation catches malformed `baseUrl`, so this
1940
- * is belt-and-suspenders).
2434
+ * `27123/27124` → `51361`. `127.0.0.1` is mapped to `localhost` because
2435
+ * Omnisearch (1.31.0) listens on host `localhost` by default, which binds the
2436
+ * one address `localhost` resolves to first — `::1` on a macOS host, so a
2437
+ * literal `127.0.0.1` would be refused there — and requesting `localhost`
2438
+ * reaches whichever address that is. Falls back to `http://localhost:51361`
2439
+ * on any URL parse failure (config validation catches malformed `baseUrl`, so
2440
+ * this is belt-and-suspenders).
1941
2441
  */
1942
2442
  function deriveOmnisearchUrl(config) {
1943
2443
  if (config.omnisearchUrl)
@@ -1964,8 +2464,8 @@ function normalizeOmnisearchHit(raw) {
1964
2464
  /**
1965
2465
  * Normalize Omnisearch's excerpt HTML: `<br>` → `\n`, decode the entities
1966
2466
  * the upstream actually emits (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#039;`,
1967
- * `&apos;`, plus numeric `&#NNN;` / `&#xNN;`). `<mark>` tags are preserved —
1968
- * 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.
1969
2469
  */
1970
2470
  function cleanExcerpt(excerpt) {
1971
2471
  return excerpt