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.
- package/AGENTS.md +20 -15
- package/CLAUDE.md +20 -15
- package/README.md +31 -19
- package/changelog/3.8.x/3.8.0.md +38 -0
- package/changelog/3.9.x/3.9.0.md +43 -0
- package/changelog/template.md +10 -3
- package/dist/config/server-config.d.ts +1 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +30 -19
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +27 -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 +23 -12
- package/dist/instructions.js.map +1 -1
- package/dist/mcp-server/resources/definitions/index.d.ts +2 -2
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +3 -2
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +6 -5
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts +2 -2
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +2 -2
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +15 -6
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +24 -12
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +250 -230
- 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 +8 -2
- 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 +10 -3
- 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.d.ts +7 -6
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +7 -7
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +8 -5
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -6
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -8
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +4 -4
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +7 -7
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +3 -3
- 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 +8 -2
- 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 +10 -3
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +185 -35
- 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 +145 -82
- 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 +8 -2
- 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 +12 -5
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/jsonlogic-evaluator.d.ts +33 -8
- package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -1
- package/dist/services/obsidian/jsonlogic-evaluator.js +171 -32
- package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -1
- package/dist/services/obsidian/jsonlogic-worker.d.ts +6 -5
- package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -1
- package/dist/services/obsidian/jsonlogic-worker.js +7 -5
- package/dist/services/obsidian/jsonlogic-worker.js.map +1 -1
- package/dist/services/obsidian/obsidian-service.d.ts +90 -36
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +748 -248
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/patch-instruction.d.ts +4 -1
- package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
- package/dist/services/obsidian/patch-instruction.js +5 -2
- package/dist/services/obsidian/patch-instruction.js.map +1 -1
- package/dist/services/obsidian/path-policy.d.ts +67 -23
- package/dist/services/obsidian/path-policy.d.ts.map +1 -1
- package/dist/services/obsidian/path-policy.js +183 -80
- package/dist/services/obsidian/path-policy.js.map +1 -1
- package/dist/services/obsidian/section-extractor.d.ts +62 -1
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +387 -51
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/dist/services/obsidian/simple-search.d.ts +62 -0
- package/dist/services/obsidian/simple-search.d.ts.map +1 -0
- package/dist/services/obsidian/simple-search.js +264 -0
- package/dist/services/obsidian/simple-search.js.map +1 -0
- package/manifest.json +12 -4
- package/package.json +9 -9
- package/server.json +23 -11
- 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
|
|
17
|
-
* evaluates at once
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
43
|
-
*
|
|
44
|
-
* 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.
|
|
45
93
|
*/
|
|
46
94
|
const WALK_TTL_MS = 5 * 60_000;
|
|
47
|
-
/**
|
|
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
|
-
/**
|
|
50
|
-
function
|
|
51
|
-
|
|
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
|
|
149
|
-
* 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.
|
|
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:
|
|
172
|
-
bodyTimeout:
|
|
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
|
|
187
|
-
* in-flight requests to
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
363
|
-
* 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.
|
|
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
|
|
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
|
|
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
|
|
427
|
-
* the readable notes' contexts instead (one upstream
|
|
428
|
-
* each one counts the readable notes carrying it or a
|
|
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
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
|
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 =
|
|
477
|
-
// Upstream
|
|
478
|
-
//
|
|
479
|
-
//
|
|
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)
|
|
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
|
|
488
|
-
*
|
|
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
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
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,
|
|
498
|
-
*
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
|
521
|
-
*
|
|
522
|
-
* without `walk` evaluates fresh and, when the
|
|
523
|
-
*
|
|
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,
|
|
528
|
-
* different tree — evaluates fresh, and a call cancelled
|
|
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,
|
|
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
|
|
537
|
-
const stored =
|
|
538
|
-
if (
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
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(
|
|
554
|
-
|
|
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, {
|
|
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,
|
|
584
|
-
* fields
|
|
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
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
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
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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.
|
|
626
|
-
*
|
|
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
|
|
656
|
-
*
|
|
657
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
this.#patchFormat
|
|
804
|
-
|
|
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
|
|
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
|
|
1181
|
+
* The note (`readNote`, the write's shared read) is read only once the
|
|
1182
|
+
* content qualifies.
|
|
861
1183
|
*/
|
|
862
|
-
async #listContinuation(
|
|
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
|
|
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
|
|
889
|
-
* Every other payload passes through as
|
|
890
|
-
* section the note lacks and the write
|
|
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(
|
|
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
|
|
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.
|
|
939
|
-
*
|
|
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
|
-
|
|
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
|
|
991
|
-
|
|
992
|
-
: flat;
|
|
993
|
-
|
|
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
|
|
1069
|
-
* to `withRetry
|
|
1070
|
-
*
|
|
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
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
* `ServiceUnavailable` and keeps its retries (issues #133 and
|
|
1077
|
-
* rejection that is neither passes through untouched, and so does
|
|
1078
|
-
* thrown after the
|
|
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
|
-
|
|
1446
|
+
res = await this.#fetch(url, {
|
|
1083
1447
|
...init,
|
|
1084
1448
|
dispatcher: this.#dispatcher,
|
|
1085
1449
|
...this.#tlsInit(url),
|
|
1086
|
-
signal
|
|
1450
|
+
signal,
|
|
1087
1451
|
});
|
|
1088
1452
|
}
|
|
1089
1453
|
catch (err) {
|
|
1090
|
-
if (
|
|
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
|
-
|
|
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
|
|
1102
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
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:
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
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 — `
|
|
1488
|
-
*
|
|
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
|
|
1604
|
-
*
|
|
1605
|
-
* of a phrase arrives as its own span with a
|
|
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`
|
|
1847
|
-
*
|
|
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
|
|
1852
|
-
* alone, so a Bun and a Node rejection produce
|
|
1853
|
-
* Neither the runtime's own text nor the URL it
|
|
1854
|
-
* rejection rides as `cause`, the channel
|
|
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
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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`
|
|
1936
|
-
* Omnisearch
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
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 (`&`, `<`, `>`, `"`, `'`,
|
|
1967
|
-
* `'`, plus numeric `&#NNN;` / `&#xNN;`).
|
|
1968
|
-
*
|
|
2467
|
+
* `'`, 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
|