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.
- package/AGENTS.md +12 -10
- package/CLAUDE.md +12 -10
- package/README.md +12 -8
- package/changelog/3.9.x/3.9.0.md +43 -0
- package/changelog/template.md +10 -3
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +6 -4
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +22 -15
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +4 -3
- package/dist/instructions.d.ts.map +1 -1
- package/dist/instructions.js +6 -9
- package/dist/instructions.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +7 -5
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +11 -8
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +237 -217
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/index.js +6 -4
- package/dist/mcp-server/tools/definitions/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +8 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +8 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +143 -27
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +87 -79
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +10 -3
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/obsidian-service.d.ts +53 -15
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +461 -155
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/section-extractor.d.ts +62 -6
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +303 -62
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/manifest.json +3 -3
- package/package.json +8 -8
- package/server.json +7 -7
- 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
|
|
90
|
-
*
|
|
91
|
-
* from the hit list its first page stored rather than
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
97
|
-
function
|
|
98
|
-
|
|
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
|
|
196
|
-
* one read; a failed read is evicted so
|
|
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:
|
|
228
|
-
bodyTimeout:
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
421
|
-
* gates readable on path targets
|
|
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
|
|
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
|
|
669
|
-
*
|
|
670
|
-
* without `walk` evaluates fresh and, when the
|
|
671
|
-
*
|
|
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,
|
|
676
|
-
* different tree — evaluates fresh, and a call cancelled
|
|
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,
|
|
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
|
|
685
|
-
const stored =
|
|
686
|
-
if (
|
|
687
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
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
|
-
#
|
|
702
|
-
const
|
|
703
|
-
if (
|
|
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, {
|
|
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.
|
|
808
|
-
*
|
|
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
|
|
838
|
-
*
|
|
839
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
this.#patchFormat
|
|
986
|
-
|
|
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
|
|
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
|
|
1181
|
+
* The note (`readNote`, the write's shared read) is read only once the
|
|
1182
|
+
* content qualifies.
|
|
1043
1183
|
*/
|
|
1044
|
-
async #listContinuation(
|
|
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
|
|
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
|
|
1071
|
-
* Every other payload passes through as
|
|
1072
|
-
* section the note lacks and the write
|
|
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(
|
|
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
|
|
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.
|
|
1121
|
-
*
|
|
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
|
-
|
|
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
|
|
1173
|
-
|
|
1174
|
-
: flat;
|
|
1175
|
-
|
|
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
|
|
1251
|
-
* to `withRetry
|
|
1252
|
-
*
|
|
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
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
* `ServiceUnavailable` and keeps its retries (issues #133 and
|
|
1259
|
-
* rejection that is neither passes through untouched, and so does
|
|
1260
|
-
* thrown after the
|
|
1261
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1286
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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:
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
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`
|
|
2040
|
-
*
|
|
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
|
|
2045
|
-
* alone, so a Bun and a Node rejection produce
|
|
2046
|
-
* Neither the runtime's own text nor the URL it
|
|
2047
|
-
* rejection rides as `cause`, the channel
|
|
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
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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 (`&`, `<`, `>`, `"`, `'`,
|
|
2161
|
-
* `'`, plus numeric `&#NNN;` / `&#xNN;`).
|
|
2162
|
-
*
|
|
2467
|
+
* `'`, 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
|