obsidian-mcp-server 3.5.4 → 3.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +6 -2
  2. package/CLAUDE.md +6 -2
  3. package/README.md +10 -9
  4. package/changelog/3.5.x/3.5.5.md +20 -0
  5. package/dist/mcp-server/tools/definitions/_shared/schemas.js +1 -1
  6. package/dist/mcp-server/tools/definitions/_shared/schemas.js.map +1 -1
  7. package/dist/mcp-server/tools/definitions/index.d.ts +64 -64
  8. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +2 -2
  9. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +3 -3
  10. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +4 -3
  13. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +4 -2
  15. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +5 -3
  17. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +2 -2
  19. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +3 -3
  20. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +6 -5
  22. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +26 -24
  24. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +2 -2
  26. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +10 -10
  28. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  29. package/dist/services/obsidian/frontmatter-ops.d.ts +6 -4
  30. package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
  31. package/dist/services/obsidian/frontmatter-ops.js +194 -16
  32. package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
  33. package/dist/services/obsidian/obsidian-service.d.ts +9 -5
  34. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  35. package/dist/services/obsidian/obsidian-service.js +390 -64
  36. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  37. package/dist/services/obsidian/section-extractor.d.ts +33 -3
  38. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  39. package/dist/services/obsidian/section-extractor.js +115 -75
  40. package/dist/services/obsidian/section-extractor.js.map +1 -1
  41. package/dist/services/obsidian/types.d.ts +11 -4
  42. package/dist/services/obsidian/types.d.ts.map +1 -1
  43. package/manifest.json +1 -1
  44. package/package.json +2 -2
  45. package/server.json +3 -3
@@ -4,11 +4,12 @@
4
4
  * `target` discriminator, and classifies errors for the framework.
5
5
  * @module services/obsidian/obsidian-service
6
6
  */
7
- import { conflict, forbidden, JsonRpcErrorCode, McpError, notFound, serviceUnavailable, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors';
7
+ import { configurationError, conflict, forbidden, JsonRpcErrorCode, McpError, notFound, serviceUnavailable, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors';
8
8
  import { httpStatusToErrorCode, withRetry } from '@cyanheads/mcp-ts-core/utils';
9
9
  import { Agent, fetch as undiciFetch } from 'undici';
10
10
  import { getServerConfig } from '../../config/server-config.js';
11
11
  import { PathPolicy } from './path-policy.js';
12
+ import { listHeadingPaths } from './section-extractor.js';
12
13
  /** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
13
14
  const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
14
15
  /**
@@ -44,12 +45,58 @@ const MARKDOWN_PATCH_VERSION_HEADER = 'Markdown-Patch-Version';
44
45
  const MARKDOWN_PATCH_VERSION = '1';
45
46
  /** Delimiter joining ancestor headings into a single PATCH heading target. */
46
47
  const HEADING_DELIMITER = '::';
48
+ /**
49
+ * The suffix markdown-patch 2.0 appends to the key of the second and later
50
+ * occurrences of a sibling heading: U+FC750, then the occurrence index minus
51
+ * one in hex, each digit written as one of U+F6440–U+F644F. The plugin refuses
52
+ * a note whose own heading text ends this way, so stripping it is exact.
53
+ */
54
+ const DUPLICATE_SUFFIX = /\u{FC750}[\u{F6440}-\u{F644F}]+$/u;
47
55
  /**
48
56
  * Methods safe to retry on transient errors. POST/PATCH are excluded — a
49
57
  * successful upstream write with a lost response would double-apply on retry
50
58
  * (duplicate `append`, re-run Obsidian command).
51
59
  */
52
60
  const RETRY_SAFE_METHODS = new Set(['GET', 'PUT', 'DELETE']);
61
+ /**
62
+ * Codes a TLS stack reports when it refuses the server's certificate. Both
63
+ * runtimes use OpenSSL's names. The first four were each produced on Bun 1.4.0
64
+ * and Node 26.5.0 (a self-signed leaf, an untrusted issuer, a SAN mismatch, an
65
+ * expired leaf); the rest are the same verifier's documented neighbours.
66
+ */
67
+ const CERTIFICATE_REJECTION_CODES = new Set([
68
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
69
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
70
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
71
+ 'CERT_HAS_EXPIRED',
72
+ 'CERT_NOT_YET_VALID',
73
+ 'SELF_SIGNED_CERT_IN_CHAIN',
74
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
75
+ ]);
76
+ /**
77
+ * Codes for a connection that failed before any response. Node reports errno
78
+ * names plus undici's own (`UND_ERR_SOCKET` is a socket closed with no bytes);
79
+ * Bun reports a refused connection as the non-errno `ConnectionRefused` and the
80
+ * others by errno name. Refused, host-not-found, and reset were produced on
81
+ * both runtimes; the unreachable-network and connect-timeout codes are the same
82
+ * failure reached over a route that drops packets instead of refusing them.
83
+ */
84
+ const CONNECTION_FAILURE_CODES = new Set([
85
+ 'ECONNREFUSED',
86
+ 'ConnectionRefused',
87
+ 'ENOTFOUND',
88
+ 'EAI_AGAIN',
89
+ 'ECONNRESET',
90
+ 'UND_ERR_SOCKET',
91
+ 'EHOSTUNREACH',
92
+ 'ENETUNREACH',
93
+ 'UND_ERR_CONNECT_TIMEOUT',
94
+ ]);
95
+ /**
96
+ * Route prefixes that address a note. An error on one of these echoes the
97
+ * caller's note under `data.path`; no other route carries a vault path.
98
+ */
99
+ const NOTE_ROUTE_PREFIXES = ['/vault/', '/open/', '/active/', '/periodic/'];
53
100
  export class ObsidianService {
54
101
  #config;
55
102
  #dispatcher;
@@ -253,12 +300,9 @@ export class ObsidianService {
253
300
  }
254
301
  const url = this.#targetToPath(target);
255
302
  const requestUrl = `${this.#config.baseUrl}${url}`;
256
- const res = await this.#fetch(requestUrl, {
303
+ const res = await this.#send(ctx, requestUrl, {
257
304
  method: 'HEAD',
258
305
  headers: { Authorization: `Bearer ${this.#config.apiKey}` },
259
- dispatcher: this.#dispatcher,
260
- ...this.#tlsInit(requestUrl),
261
- signal: ctx.signal,
262
306
  });
263
307
  if (res.status === 404)
264
308
  return null;
@@ -316,11 +360,15 @@ export class ObsidianService {
316
360
  }
317
361
  // ── Search ───────────────────────────────────────────────────────────────
318
362
  /**
319
- * Text search. The upstream materializes one context window per matching
320
- * note before it responds, so an oversized `contextLength` on a broad query
321
- * exhausts V8's string capacity and comes back as an opaque 500 — caught
322
- * here and re-thrown typed, the way `searchOmnisearch` re-throws its own
323
- * reachability failure.
363
+ * Text search. Each upstream span gets its `context` offsets resolved
364
+ * (`contextRelativeSpan`), then a multi-token query's spans are merged into
365
+ * match locations (`mergeIntoLocations`).
366
+ *
367
+ * The upstream materializes one context window per matching note before it
368
+ * responds, so an oversized `contextLength` on a broad query exhausts V8's
369
+ * string capacity and comes back as an opaque 500 — caught here and
370
+ * re-thrown typed, the way `searchOmnisearch` re-throws its own reachability
371
+ * failure.
324
372
  */
325
373
  async searchText(ctx, query, contextLength = 100) {
326
374
  const params = new URLSearchParams({ query, contextLength: String(contextLength) });
@@ -344,15 +392,13 @@ export class ObsidianService {
344
392
  }, { cause: err });
345
393
  }
346
394
  const raw = (await res.json());
395
+ const tokens = queryTokens(query);
347
396
  // Upstream returns a constant `score` that carries no ranking signal for
348
397
  // text mode — drop it on the way out so consumers don't mistake it for
349
398
  // relevance. Omnisearch is the source of real BM25 ranking.
350
399
  return raw.map((h) => ({
351
400
  filename: h.filename,
352
- matches: h.matches.map((m) => ({
353
- context: m.context,
354
- match: { ...m.match, ...contextRelativeSpan(m, h.filename, contextLength, query) },
355
- })),
401
+ matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
356
402
  }));
357
403
  }
358
404
  async searchJsonLogic(ctx, logic) {
@@ -510,31 +556,86 @@ export class ObsidianService {
510
556
  * leaf name reaches the same section on writes that it already reaches on
511
557
  * reads. `extractSection` matches a heading at any depth, so an agent that
512
558
  * read `## Sibling` by its bare name carries that name to a write, where
513
- * upstream targeting wants the whole `Parent::Child` chain.
559
+ * upstream targeting wants the whole `Parent::Child` chain. Locators that
560
+ * already carry the delimiter pass through unexpanded; non-heading targets
561
+ * skip resolution entirely.
514
562
  *
515
- * Resolution order: an exact map entry wins (preserving upstream's own
516
- * interpretation), then a unique leaf match is expanded, then several leaf
517
- * matches are rejected as ambiguous rather than silently writing to the first.
518
- * A leaf absent from the map passes through untouched so
519
- * `Create-Target-If-Missing` still creates it and the upstream's own
520
- * target-miss error still surfaces. Non-heading targets and locators that
521
- * already carry the delimiter skip the lookup entirely.
563
+ * The resolved path must then name exactly one heading. The 1.x map the
564
+ * PATCH resolves against keys headings by full path, so a path that repeats
565
+ * in the note is listed once, at its last occurrence — the PATCH would land
566
+ * on the last repeat while a section read returns the first. A write to a
567
+ * repeated path is rejected as ambiguous. The count comes from the plugin's
568
+ * own parse (`#headingIndex`), not a local scan: the two disagree on
569
+ * headings inside list items, HTML blocks, and setext underlines, and a local
570
+ * count there rejected writes the PATCH would have landed correctly.
522
571
  */
523
572
  async #resolveHeadingTarget(ctx, target, headers) {
524
- if (headers.targetType !== 'heading' || headers.target.includes(HEADING_DELIMITER)) {
573
+ if (headers.targetType !== 'heading')
525
574
  return headers.target;
575
+ const { headings, occurrences } = await this.#headingIndex(ctx, target);
576
+ const resolved = headers.target.includes(HEADING_DELIMITER)
577
+ ? headers.target
578
+ : this.#expandHeadingLeaf(target, headers.target, headings);
579
+ const repeats = occurrences.filter((p) => p === resolved);
580
+ if (repeats.length <= 1)
581
+ return resolved;
582
+ const display = displayPath(this.#targetToPath(target));
583
+ const named = resolved === headers.target ? `'${resolved}'` : `'${headers.target}' (${resolved})`;
584
+ throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
585
+ path: display,
586
+ reason: 'ambiguous_section',
587
+ candidates: repeats,
588
+ recovery: {
589
+ hint: `Every heading in \`candidates\` has the same full path, so no section locator picks one. Rename all but one of them so each path is unique — obsidian_replace_in_note can rewrite a heading line — then retry. obsidian_get_note with format "section" reads the first occurrence.`,
590
+ },
591
+ });
592
+ }
593
+ /**
594
+ * The note's heading paths as the plugin parses them, in one request:
595
+ * `headings` lists each distinct path the way the 1.x document map does
596
+ * (what a leaf expands against), and `occurrences` lists every heading,
597
+ * repeats included (what the repeat count reads).
598
+ *
599
+ * Both come from the markdown-patch 2.0 document map, whose tree keeps a key
600
+ * per repeat; flattened with the repeat suffixes stripped and collapsed, it
601
+ * is the 1.x `headings` array exactly, since both formats read the same
602
+ * parser's top-level heading tokens. Plugin v4.x ignores the version header
603
+ * and answers with the 1.x array, which cannot count repeats, so there the
604
+ * note body is fetched and scanned locally (`listHeadingPaths`).
605
+ */
606
+ async #headingIndex(ctx, target) {
607
+ const res = await this.#request(ctx, this.#targetToPath(target), {
608
+ method: 'GET',
609
+ headers: { Accept: DOCUMENT_MAP_ACCEPT, [MARKDOWN_PATCH_VERSION_HEADER]: '2' },
610
+ noteRead: true,
611
+ });
612
+ const map = (await res.json());
613
+ if (Array.isArray(map.headings)) {
614
+ const { content } = await this.#rawGetNoteJson(ctx, target);
615
+ return { headings: map.headings, occurrences: listHeadingPaths(content) };
526
616
  }
527
- const map = await this.#rawGetDocumentMap(ctx, target);
528
- if (map.headings.includes(headers.target))
529
- return headers.target;
530
- const matches = map.headings.filter((h) => h.split(HEADING_DELIMITER).pop() === headers.target);
617
+ const occurrences = flattenHeadingTree(map.headings);
618
+ return { headings: [...new Set(occurrences)].filter(Boolean), occurrences };
619
+ }
620
+ /**
621
+ * Expand a bare heading leaf against the note's heading paths. Resolution
622
+ * order: an exact entry wins (preserving upstream's own interpretation),
623
+ * then a unique leaf match is expanded, then several leaf matches are
624
+ * rejected as ambiguous rather than silently writing to the first. A leaf
625
+ * absent from the paths passes through untouched so `Create-Target-If-Missing`
626
+ * still creates it and the upstream's own target-miss error still surfaces.
627
+ */
628
+ #expandHeadingLeaf(target, leaf, headings) {
629
+ if (headings.includes(leaf))
630
+ return leaf;
631
+ const matches = headings.filter((h) => h.split(HEADING_DELIMITER).pop() === leaf);
531
632
  const [first] = matches;
532
633
  if (first === undefined)
533
- return headers.target;
634
+ return leaf;
534
635
  if (matches.length === 1)
535
636
  return first;
536
637
  const display = displayPath(this.#targetToPath(target));
537
- throw conflict(`Heading '${headers.target}' is ambiguous in ${display} — ${matches.length} headings share that name: ${matches.join(', ')}.`, {
638
+ throw conflict(`Heading '${leaf}' is ambiguous in ${display} — ${matches.length} headings share that name: ${matches.join(', ')}.`, {
538
639
  path: display,
539
640
  reason: 'ambiguous_section',
540
641
  candidates: matches,
@@ -616,6 +717,34 @@ export class ObsidianService {
616
717
  return {};
617
718
  return { tls: { rejectUnauthorized: false } };
618
719
  }
720
+ /**
721
+ * One request to the Local REST API — the single attempt `#request` hands
722
+ * to `withRetry`, and `tryGetSize`'s HEAD — with the dispatcher, the scoped
723
+ * TLS relaxation, and the caller's signal attached.
724
+ *
725
+ * A rejected `fetch` is classified here, inside the attempt, because the
726
+ * attempt is where the retry decision is made: a certificate rejection comes
727
+ * back as a non-transient `ConfigurationError` and `withRetry` rethrows it
728
+ * on the spot, while an unreachable plugin comes back as a transient
729
+ * `ServiceUnavailable` and keeps its retries (issues #133 and #136). A
730
+ * rejection that is neither passes through untouched, and so does anything
731
+ * thrown after the caller aborted — a cancellation is not a network fault.
732
+ */
733
+ async #send(ctx, url, init) {
734
+ try {
735
+ return await this.#fetch(url, {
736
+ ...init,
737
+ dispatcher: this.#dispatcher,
738
+ ...this.#tlsInit(url),
739
+ signal: ctx.signal,
740
+ });
741
+ }
742
+ catch (err) {
743
+ if (ctx.signal?.aborted)
744
+ throw err;
745
+ throw classifyFetchRejection(err) ?? err;
746
+ }
747
+ }
619
748
  #request(ctx, pathAndQuery, init) {
620
749
  const url = `${this.#config.baseUrl}${pathAndQuery}`;
621
750
  const headers = {
@@ -623,13 +752,10 @@ export class ObsidianService {
623
752
  Authorization: `Bearer ${this.#config.apiKey}`,
624
753
  };
625
754
  const exec = async () => {
626
- const res = await this.#fetch(url, {
755
+ const res = await this.#send(ctx, url, {
627
756
  method: init.method,
628
757
  headers,
629
758
  ...(init.body !== undefined ? { body: init.body } : {}),
630
- dispatcher: this.#dispatcher,
631
- ...this.#tlsInit(url),
632
- signal: ctx.signal,
633
759
  });
634
760
  if (!res.ok) {
635
761
  await this.#throwForStatus(res, pathAndQuery, ctx, {
@@ -759,10 +885,12 @@ export class ObsidianService {
759
885
  * Classify an upstream error response and throw.
760
886
  *
761
887
  * **Containment invariant.** Four things leave this method on the wire and
762
- * nothing else: a message authored in this file, the request path the caller
763
- * supplied (`data.path`), the HTTP status (`data.status`, default branch),
764
- * and the calling tool's own contract `reason` + `recovery`. The upstream's
765
- * response body never crosses, in any branch, under any key.
888
+ * nothing else: a message authored in this file, the caller's own identifier
889
+ * (`data.path` on a note route, `data.commandId` on a command, neither on a
890
+ * route that carries no caller input — see `callerIdentifier`), the HTTP
891
+ * status (`data.status`, default branch), and the calling tool's own
892
+ * contract `reason` + `recovery`. The upstream's response body never
893
+ * crosses, in any branch, under any key.
766
894
  *
767
895
  * That is stricter than redaction, deliberately. The Local REST API
768
896
  * interleaves genuine diagnostics with vault data in one string: a rejected
@@ -804,7 +932,7 @@ export class ObsidianService {
804
932
  const upstreamMsg = typeof body?.message === 'string' ? body.message : text.trim();
805
933
  const cause = new UpstreamErrorText(upstreamMsg, opts.requestedUrl);
806
934
  const data = (reason) => ({
807
- path: display,
935
+ ...callerIdentifier(path),
808
936
  ...(reason !== undefined ? { reason, ...ctx.recoveryFor(reason) } : {}),
809
937
  });
810
938
  switch (res.status) {
@@ -967,41 +1095,180 @@ export function encodeVaultPath(path) {
967
1095
  return segments.map((seg) => encodeURIComponent(seg)).join('/');
968
1096
  }
969
1097
  /**
970
- * Locate the match span inside the `context` window the upstream ships with it.
1098
+ * Every heading in a markdown-patch 2.0 heading tree as its `::`-joined full
1099
+ * path, in tree order, one entry per occurrence. A top-level untitled heading
1100
+ * is `""`, and its children read `::Child`, as the 1.x map writes them.
1101
+ */
1102
+ function flattenHeadingTree(tree, parent) {
1103
+ return Object.entries(tree).flatMap(([key, children]) => {
1104
+ const text = key.replace(DUPLICATE_SUFFIX, '');
1105
+ const path = parent === undefined ? text : `${parent}${HEADING_DELIMITER}${text}`;
1106
+ return [path, ...flattenHeadingTree(children, path)];
1107
+ });
1108
+ }
1109
+ /**
1110
+ * The query as Obsidian's `prepareSimpleSearch` reads it: split on whitespace,
1111
+ * case-folded, each distinct token required. Quotes are ordinary characters.
1112
+ */
1113
+ function queryTokens(query) {
1114
+ return [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
1115
+ }
1116
+ const isHighSurrogate = (cu) => cu >= 0xd800 && cu <= 0xdbff;
1117
+ const isLowSurrogate = (cu) => cu >= 0xdc00 && cu <= 0xdfff;
1118
+ /**
1119
+ * Locate the match span inside the `context` window the upstream ships with
1120
+ * it, and name the subject its `start`/`end` index.
971
1121
  *
972
- * `start`/`end` are offsets into whichever subject the plugin matched, and
973
- * there are two. For a **body match** the subject is the note text and the
974
- * window is `body.slice(max(0, start - contextLength), min(len, end + contextLength))`,
1122
+ * There are two subjects. For a **body match** the subject is the note text
1123
+ * and the window is `body.slice(max(0, start - contextLength), end + contextLength)`,
975
1124
  * so the span sits `min(start, contextLength)` characters into `context`. For a
976
1125
  * **filename match** the subject is the note's basename and the plugin returns
977
1126
  * that basename whole as `context`, so the span sits at `start` — which runs
978
1127
  * past `min(start, contextLength)` as soon as the name is longer than the
979
- * window. A single hit's `matches[]` can carry both kinds. Verified against
980
- * Local REST API v5.0.3.
1128
+ * window. A single hit's `matches[]` can carry both kinds.
1129
+ *
1130
+ * `match.source` (`"filename"` / `"content"`, plugin 5.0.3+) names the subject
1131
+ * outright. Without it, `context === basename` is the cheap separator, but it
1132
+ * is not decisive: a body window whose left edge is trimmed can coincide with
1133
+ * the basename (a note whose own name is quoted in its body), and then the two
1134
+ * readings disagree. Where they do, the text settles it — the correct reading
1135
+ * reproduces the match — and an inconclusive check keeps the filename reading.
981
1136
  *
982
- * `context === basename` is the cheap separator, but it is not decisive: a body
983
- * window whose left edge is trimmed can coincide with the basename (a note whose
984
- * own name is quoted in its body, matched so the window lands on that quote),
985
- * and then the two readings disagree and the filename one slices the wrong text
986
- * — often past the end of `context` entirely. Where they disagree, settle it on
987
- * the text rather than the coincidence: the plugin matches whole query tokens,
988
- * so the correct reading reproduces one. An inconclusive check keeps the
989
- * filename reading, which is what the coincidence test alone would have picked.
1137
+ * Plugin 5.1.0+ widens a body window by one code unit rather than split a
1138
+ * surrogate pair (`widenToCodePointBoundaries`), so the window can start one
1139
+ * character before `start - contextLength` — see `bodyWindowOffset`.
990
1140
  */
991
- function contextRelativeSpan(m, filename, contextLength, query) {
992
- const span = m.match.end - m.match.start;
993
- const bodyStart = Math.min(m.match.start, contextLength);
1141
+ function contextRelativeSpan(m, filename, contextLength, tokens) {
1142
+ const { start, end, source } = m.match;
1143
+ const span = end - start;
1144
+ const at = (subject, i) => ({
1145
+ context: m.context,
1146
+ match: { start, end, contextStart: i, contextEnd: i + span },
1147
+ subject,
1148
+ });
1149
+ /**
1150
+ * The matched text is a run of coalesced token occurrences, so it opens with
1151
+ * one token and closes with one. A reading shifted by a character fails
1152
+ * that: upstream reports every occurrence, so a token beginning one
1153
+ * character early, or ending one character late, would have been coalesced
1154
+ * into this span already.
1155
+ */
1156
+ const reproduces = (i) => {
1157
+ const slice = m.context.slice(i, i + span).toLowerCase();
1158
+ return (slice.length === span &&
1159
+ tokens.some((t) => slice.startsWith(t)) &&
1160
+ tokens.some((t) => slice.endsWith(t)));
1161
+ };
1162
+ if (source === 'filename')
1163
+ return at('filename', start);
1164
+ const bodyStart = bodyWindowOffset(m, contextLength, reproduces);
1165
+ if (source === 'content')
1166
+ return at('body', bodyStart);
994
1167
  const basename = (filename.split('/').pop() ?? filename).replace(/\.[^./]+$/, '');
995
- const at = (i) => ({ contextStart: i, contextEnd: i + span });
996
- if (m.context !== basename || m.match.start === bodyStart)
997
- return at(bodyStart);
998
- const reproducesToken = (i) => {
999
- const slice = m.context.slice(i, i + span);
1000
- return slice.length === span && query.toLowerCase().includes(slice.toLowerCase());
1168
+ if (m.context !== basename)
1169
+ return at('body', bodyStart);
1170
+ if (start === bodyStart)
1171
+ return at('filename', start);
1172
+ return reproduces(start) || !reproduces(bodyStart)
1173
+ ? at('filename', start)
1174
+ : at('body', bodyStart);
1175
+ }
1176
+ /**
1177
+ * Where a body match sits in its window: `min(start, contextLength)`, or one
1178
+ * further when upstream widened the left edge to keep a surrogate pair whole.
1179
+ *
1180
+ * The widening happens only when the window is not clipped at the note start
1181
+ * and its unwidened edge fell on a low surrogate, and it leaves a window that
1182
+ * opens on a whole pair. A window that simply begins on a pair looks the same
1183
+ * from `context` alone, so between the two candidate offsets the one that
1184
+ * reproduces the match wins; a tie keeps the unwidened reading, which is also
1185
+ * correct for plugins that predate the widening.
1186
+ */
1187
+ function bodyWindowOffset(m, contextLength, reproduces) {
1188
+ const base = Math.min(m.match.start, contextLength);
1189
+ if (m.match.start <= contextLength)
1190
+ return base;
1191
+ if (!isHighSurrogate(m.context.charCodeAt(0)) || !isLowSurrogate(m.context.charCodeAt(1))) {
1192
+ return base;
1193
+ }
1194
+ return !reproduces(base) && reproduces(base + 1) ? base + 1 : base;
1195
+ }
1196
+ /**
1197
+ * Merge a hit's spans into match locations. `prepareSimpleSearch` reports one
1198
+ * span per token occurrence and coalesces only spans that touch, so each word
1199
+ * of a phrase arrives as its own span with a near-identical window.
1200
+ *
1201
+ * Walking the spans in upstream order, a location takes the next span when all
1202
+ * of these hold:
1203
+ *
1204
+ * - Same subject. Body and basename offsets have different origins.
1205
+ * - `next.start - location.end ≤ 2 × contextLength`, which is when the two
1206
+ * windows overlap or abut. The union window is then contiguous and no longer
1207
+ * than the two it replaces, so a merge never grows the response.
1208
+ * - The location does not already hold that span's case-folded text. Without
1209
+ * this bound a common token chains transitively across a whole note into a
1210
+ * single unbounded window that counts once against `maxMatchesPerHit`.
1211
+ * - The windows agree on the text they share. The stitch is computed from
1212
+ * each window's real extent, so it follows edge clipping and surrogate
1213
+ * widening; a disagreement means the offsets are wrong, and refusing beats
1214
+ * fabricating note text.
1215
+ *
1216
+ * A query with a single distinct token is left alone, so single-word results
1217
+ * keep one match per occurrence.
1218
+ */
1219
+ function mergeIntoLocations(spans, distinctTokens, contextLength) {
1220
+ if (distinctTokens < 2)
1221
+ return spans;
1222
+ const locations = [];
1223
+ let current;
1224
+ for (const span of spans) {
1225
+ const text = matchedText(span);
1226
+ if (current && !current.texts.has(text)) {
1227
+ const joined = joinLocation(current.location, span, contextLength);
1228
+ if (joined) {
1229
+ current.location = joined;
1230
+ current.texts.add(text);
1231
+ continue;
1232
+ }
1233
+ }
1234
+ if (current)
1235
+ locations.push(current.location);
1236
+ current = { location: span, texts: new Set([text]) };
1237
+ }
1238
+ if (current)
1239
+ locations.push(current.location);
1240
+ return locations;
1241
+ }
1242
+ function matchedText(span) {
1243
+ return span.context.slice(span.match.contextStart, span.match.contextEnd).toLowerCase();
1244
+ }
1245
+ /** `location` extended through `next`, or `undefined` when the two may not merge. */
1246
+ function joinLocation(location, next, contextLength) {
1247
+ if (next.subject !== location.subject)
1248
+ return;
1249
+ const gap = next.match.start - location.match.end;
1250
+ if (gap < 0 || gap > 2 * contextLength)
1251
+ return;
1252
+ /** Subject offsets of each window's first character. */
1253
+ const left = location.match.start - location.match.contextStart;
1254
+ const nextLeft = next.match.start - next.match.contextStart;
1255
+ const offset = nextLeft - left;
1256
+ const shared = location.context.length - offset;
1257
+ if (offset < 0 || shared < 0)
1258
+ return;
1259
+ const head = next.context.slice(0, shared);
1260
+ if (location.context.slice(offset, offset + head.length) !== head)
1261
+ return;
1262
+ return {
1263
+ context: location.context + next.context.slice(shared),
1264
+ match: {
1265
+ start: location.match.start,
1266
+ end: next.match.end,
1267
+ contextStart: location.match.contextStart,
1268
+ contextEnd: next.match.end - left,
1269
+ },
1270
+ subject: location.subject,
1001
1271
  };
1002
- return reproducesToken(m.match.start) || !reproducesToken(bodyStart)
1003
- ? at(m.match.start)
1004
- : at(bodyStart);
1005
1272
  }
1006
1273
  /**
1007
1274
  * The upstream error body and the URL it answered, carried as the `cause` of
@@ -1117,6 +1384,65 @@ function periodOf(urlPath) {
1117
1384
  function routeOf(urlPath) {
1118
1385
  return urlPath.split('?')[0] ?? urlPath;
1119
1386
  }
1387
+ /**
1388
+ * The caller's identifier for an error on `urlPath`, keyed by the input it
1389
+ * echoes — the same convention `nameRegex` and `contextLength` follow. A note
1390
+ * route carries the note as `path`; `/commands/<id>/` carries the ID as
1391
+ * `commandId`, which is what `obsidian_execute_command` calls it. Every other
1392
+ * route (`/`, `/tags/`, `/commands/`, `/search/`, `/search/simple/`) carries
1393
+ * no caller identifier, so it gets no key — `path` there used to hold the
1394
+ * route string itself, which names no note (issue #130).
1395
+ */
1396
+ function callerIdentifier(urlPath) {
1397
+ if (NOTE_ROUTE_PREFIXES.some((prefix) => urlPath.startsWith(prefix))) {
1398
+ return { path: displayPath(urlPath) };
1399
+ }
1400
+ if (/^\/commands\/[^/]+\/?$/.test(routeOf(urlPath))) {
1401
+ return { commandId: displayPath(urlPath) };
1402
+ }
1403
+ return {};
1404
+ }
1405
+ /**
1406
+ * Classify a rejected `fetch` — one that produced no response at all — or
1407
+ * return `undefined` to leave it as thrown.
1408
+ *
1409
+ * The two runtimes put the code in different places: Bun on the thrown error
1410
+ * itself, Node's undici one level down on `cause` under a bare `TypeError:
1411
+ * fetch failed`. Both levels are read, and the result is built from the code
1412
+ * alone, so a Bun and a Node rejection produce the same message and `data`.
1413
+ * Neither the runtime's own text nor the URL it names reaches the wire; the
1414
+ * rejection rides as `cause`, the channel `#throwForStatus` uses for the same
1415
+ * reason.
1416
+ *
1417
+ * The recovery hint is written inline, as `encodeVaultPath` writes
1418
+ * `path_traversal`'s: both failures are the operator's to fix and reachable
1419
+ * from every tool and resource — including the two resources that declare no
1420
+ * `errors[]` for `ctx.recoveryFor` to resolve against.
1421
+ */
1422
+ function classifyFetchRejection(err) {
1423
+ const codes = [err, err instanceof Error ? err.cause : undefined].flatMap((e) => {
1424
+ const code = e?.code;
1425
+ return typeof code === 'string' ? [code] : [];
1426
+ });
1427
+ const certificate = codes.find((code) => CERTIFICATE_REJECTION_CODES.has(code));
1428
+ if (certificate !== undefined) {
1429
+ return configurationError(`The Obsidian Local REST API's TLS certificate failed verification (${certificate}).`, {
1430
+ reason: 'certificate_rejected',
1431
+ recovery: {
1432
+ hint: "OBSIDIAN_VERIFY_SSL=true accepts only a certificate this runtime trusts, and the Local REST API's HTTPS port serves a self-signed one by default. Set OBSIDIAN_VERIFY_SSL=false, or point OBSIDIAN_BASE_URL at the plugin's plain-HTTP port (27123 by default).",
1433
+ },
1434
+ }, { cause: err });
1435
+ }
1436
+ if (codes.some((code) => CONNECTION_FAILURE_CODES.has(code))) {
1437
+ return serviceUnavailable('Could not connect to the Obsidian Local REST API.', {
1438
+ reason: 'obsidian_unreachable',
1439
+ recovery: {
1440
+ 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.',
1441
+ },
1442
+ }, { cause: err });
1443
+ }
1444
+ return;
1445
+ }
1120
1446
  /**
1121
1447
  * Convert an internal URL path (e.g. `/vault/Projects/My%20Note.md`) to the
1122
1448
  * vault-relative form a caller would recognize. Used in error messages so the