UNPKG

obsidian-mcp-server

Version:

MCP server for Obsidian vaults — read, write, search, and surgically edit notes, tags, and frontmatter via the Local REST API plugin. STDIO or Streamable HTTP.

743 lines 33.5 kB
/** * @fileoverview Obsidian Local REST API service. Wraps every upstream HTTP * endpoint we use, builds the right URL/headers/body for the consolidated * `target` discriminator, and classifies errors for the framework. * @module services/obsidian/obsidian-service */ import { forbidden, notFound, serviceUnavailable, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors'; import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils'; import { Agent, fetch as undiciFetch } from 'undici'; import { getServerConfig } from '../../config/server-config.js'; import { PathPolicy } from './path-policy.js'; /** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */ const OMNISEARCH_PROBE_TIMEOUT_MS = 500; const OMNISEARCH_DEFAULT_PORT = '51361'; const NOTE_JSON_ACCEPT = 'application/vnd.olrapi.note+json'; const DOCUMENT_MAP_ACCEPT = 'application/vnd.olrapi.document-map+json'; const JSONLOGIC_CT = 'application/vnd.olrapi.jsonlogic+json'; /** * Methods safe to retry on transient errors. POST/PATCH are excluded — a * successful upstream write with a lost response would double-apply on retry * (duplicate `append`, re-run Obsidian command). */ const RETRY_SAFE_METHODS = new Set(['GET', 'PUT', 'DELETE']); export class ObsidianService { #config; #dispatcher; #fetch; #policy; #omnisearchUrl; /** * @param config - Validated server config (api key, base URL, TLS, timeouts). * @param fetchImpl - Optional fetch override for tests. Defaults to undici's * `fetch`, which honors the constructed TLS dispatcher in production. */ constructor(config, fetchImpl) { this.#config = config; this.#policy = new PathPolicy(config); this.#omnisearchUrl = deriveOmnisearchUrl(config); /** * Bun's runtime ignores undici's per-dispatcher `connect.rejectUnauthorized` * option, so the only reliable opt-out under Bun is the process-wide * `NODE_TLS_REJECT_UNAUTHORIZED=0` flag. Node honors the dispatcher option * (set below), so the env var fallback is scoped to Bun to avoid mutating * process-wide TLS behavior on Node. Default Obsidian Local REST API ships * a self-signed cert, so most users run with `OBSIDIAN_VERIFY_SSL=false`. */ if (!config.verifySsl && typeof globalThis.Bun !== 'undefined') { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } this.#dispatcher = new Agent({ connect: { rejectUnauthorized: config.verifySsl }, headersTimeout: config.requestTimeoutMs, bodyTimeout: config.requestTimeoutMs, }); this.#fetch = fetchImpl ?? undiciFetch; } /** Path-policy accessor — used by `obsidian_search_notes` to filter hits. */ get policy() { return this.#policy; } /** Resolved Omnisearch URL (derived from baseUrl or OBSIDIAN_OMNISEARCH_URL override). */ get omnisearchUrl() { return this.#omnisearchUrl; } // ── Status ─────────────────────────────────────────────────────────────── async getStatus(ctx) { const res = await this.#request(ctx, '/', { method: 'GET', skipAuth: true }); return (await res.json()); } /** * Probe whether the configured `OBSIDIAN_API_KEY` is accepted. Hits the * authenticated `/vault/` listing endpoint and reports `true` only on a 2xx * response. Network/auth errors yield `false` — the resource caller wants a * boolean, not an exception. Aborts are re-thrown so cancellation/timeout * doesn't masquerade as an auth failure. */ async probeAuthenticated(ctx) { try { const res = await this.#fetch(`${this.#config.baseUrl}/vault/`, { method: 'GET', headers: { Authorization: `Bearer ${this.#config.apiKey}` }, dispatcher: this.#dispatcher, signal: ctx.signal, }); return res.ok; } catch (err) { if (ctx.signal?.aborted) throw err; return false; } } // ── Notes ──────────────────────────────────────────────────────────────── async getNoteContent(ctx, target) { if (target.type === 'path') { this.#policy.assertReadable(target.path); const url = this.#targetToPath(target); const res = await this.#request(ctx, url, { method: 'GET', headers: { Accept: 'text/markdown' }, }); return await res.text(); } /** * Non-path target with restrictions: route via JSON to learn the resolved * path, gate it, then return the content. Costs a single JSON fetch * instead of the markdown one — only paid by users who configured a path * scope. */ if (!this.#policy.isUnrestricted) { const note = await this.getNoteJson(ctx, target); return note.content; } const url = this.#targetToPath(target); const res = await this.#request(ctx, url, { method: 'GET', headers: { Accept: 'text/markdown' }, }); return await res.text(); } async getNoteJson(ctx, target) { if (target.type === 'path') { this.#policy.assertReadable(target.path); } const note = await this.#rawGetNoteJson(ctx, target); if (target.type !== 'path') { this.#policy.assertReadable(note.path); } return note; } /** * Resolve `target` to a vault-relative path. For path targets this is a * no-op; for `active` and `periodic` targets we have to ask upstream which * concrete file is currently in play. */ async resolvePath(ctx, target) { if (target.type === 'path') return target.path; return (await this.getNoteJson(ctx, target)).path; } async getDocumentMap(ctx, target) { if (target.type === 'path') { this.#policy.assertReadable(target.path); return this.#rawGetDocumentMap(ctx, target); } if (this.#policy.isUnrestricted) { return this.#rawGetDocumentMap(ctx, target); } /** * Restricted + non-path: parallel-fetch the document map and resolve the * path so we can gate. If the gate denies, the parallel fetch result is * discarded — acceptable cost given the rarity of this configuration. */ const [path, map] = await Promise.all([ this.resolvePath(ctx, target), this.#rawGetDocumentMap(ctx, target), ]); this.#policy.assertReadable(path); return map; } async writeNote(ctx, target, content, contentType = 'markdown') { const safe = await this.#gateAsWrite(ctx, target); const url = this.#targetToPath(safe); await this.#request(ctx, url, { method: 'PUT', headers: { 'Content-Type': contentType === 'json' ? 'application/json' : 'text/markdown' }, body: content, }); } async appendToNote(ctx, target, content, contentType = 'markdown') { const safe = await this.#gateAsWrite(ctx, target); const url = this.#targetToPath(safe); await this.#request(ctx, url, { method: 'POST', headers: { 'Content-Type': contentType === 'json' ? 'application/json' : 'text/markdown' }, body: content, }); } async patchNote(ctx, target, content, headers) { const safe = await this.#gateAsWrite(ctx, target); const url = this.#targetToPath(safe); await this.#request(ctx, url, { method: 'PATCH', headers: this.#buildPatchHeaders(headers), body: content, }); } async deleteNote(ctx, target) { const safe = await this.#gateAsWrite(ctx, target); const url = this.#targetToPath(safe); await this.#request(ctx, url, { method: 'DELETE' }); } /** * Byte size of a note at `target`, derived from the HEAD `Content-Length` * header. Returns `null` on 404 — distinct from a 0-byte file. * * Source-of-truth rule for note byte sizes across mutating tools: * 1. HEAD `Content-Length` (this method) — when no GET is in flight. * 2. `Buffer.byteLength(deliveredContent)` — when a GET happens anyway (free). * 3. `note.stat.size` from the JSON envelope — REJECTED: shares the upstream * `getAbstractFileByPath` cache path with the rest of the envelope, so it * can't act as an independent cross-check (cache-desync scenario in * coddingtonbear/obsidian-local-rest-api#237). Always prefer delivered * bytes or HEAD over the metadata field. * * Bypasses retries (a 404 is the answer, not a transient failure) and * gates readable on path targets before issuing the HEAD. */ async tryGetSize(ctx, target) { if (target.type === 'path') { this.#policy.assertReadable(target.path); } const url = this.#targetToPath(target); const res = await this.#fetch(`${this.#config.baseUrl}${url}`, { method: 'HEAD', headers: { Authorization: `Bearer ${this.#config.apiKey}` }, dispatcher: this.#dispatcher, signal: ctx.signal, }); if (res.status === 404) return null; if (!res.ok) await this.#throwForStatus(res, url, ctx); return parseContentLength(res, url); } /** * Like `tryGetSize`, but throws `note_missing` on 404 — for verification * reads that come *after* a write where the file is expected to exist. */ async getSize(ctx, target) { const size = await this.tryGetSize(ctx, target); if (size === null) { const display = target.type === 'path' ? target.path : '(target)'; throw notFound(`Note not found: ${display}`, { path: display, reason: 'note_missing', ...ctx.recoveryFor('note_missing'), }); } return size; } // ── Listings ───────────────────────────────────────────────────────────── async listFiles(ctx, dirPath) { let url = '/vault/'; let normalized = ''; if (dirPath) { normalized = dirPath.replace(/^\/+|\/+$/g, ''); if (normalized) url = `/vault/${encodeVaultPath(normalized)}/`; } /** * Gate the directory itself when it's non-empty — root listings always * pass so users can navigate into their scope. Children aren't filtered * here; the per-file read gate on `getNoteContent` etc. catches access to * out-of-scope individual notes. */ if (normalized) { this.#policy.assertReadable(normalized); } const res = await this.#request(ctx, url, { method: 'GET' }); return (await res.json()); } async listTags(ctx) { const res = await this.#request(ctx, '/tags/', { method: 'GET' }); const body = (await res.json()); return body.tags ?? []; } async listCommands(ctx) { const res = await this.#request(ctx, '/commands/', { method: 'GET' }); const body = (await res.json()); return body.commands ?? []; } // ── Search ─────────────────────────────────────────────────────────────── async searchText(ctx, query, contextLength = 100) { const params = new URLSearchParams({ query, contextLength: String(contextLength) }); const res = await this.#request(ctx, `/search/simple/?${params}`, { method: 'POST' }); const raw = (await res.json()); // Upstream returns a constant `score` that carries no ranking signal for // text mode — drop it on the way out so consumers don't mistake it for // relevance. Omnisearch is the source of real BM25 ranking. return raw.map((h) => ({ filename: h.filename, matches: h.matches })); } async searchJsonLogic(ctx, logic) { const res = await this.#request(ctx, '/search/', { method: 'POST', headers: { 'Content-Type': JSONLOGIC_CT }, body: JSON.stringify(logic), }); return (await res.json()); } /** * One-shot startup probe for the Omnisearch plugin's HTTP endpoint. Returns * `true` only when the response is `HTTP 200`, declares `application/json`, * and the body parses as a JSON array — unrouted paths on the Omnisearch * server also return `200` with an empty body, so status alone is * insufficient. The entry point passes the return value into the * `obsidian_search_notes` factory to decide whether to expose the * `omnisearch` mode. */ async probeOmnisearch(signal) { const probeSignal = signal ?? AbortSignal.timeout(OMNISEARCH_PROBE_TIMEOUT_MS); try { const res = await this.#fetch(`${this.#omnisearchUrl}/search?q=`, { method: 'GET', dispatcher: this.#dispatcher, signal: probeSignal, }); if (!res.ok) return false; const contentType = res.headers.get('content-type') ?? ''; if (!contentType.toLowerCase().includes('application/json')) return false; const body = await res.json().catch(() => undefined); return Array.isArray(body); } catch { return false; } } /** * Query the Omnisearch HTTP endpoint. Normalizes the response on the way * out: decodes HTML entities + `<br>` → `\n` in `excerpt`, renames `path` * to `filename`, and drops `vault`. Throws `omnisearch_unreachable` * (ServiceUnavailable) on network failures or non-2xx responses — the * plugin can shut down mid-session (Obsidian quits, plugin disabled), and * the tool needs a distinct signal from the upstream's success cases. */ async searchOmnisearch(ctx, query) { const url = `${this.#omnisearchUrl}/search?q=${encodeURIComponent(query)}`; let res; try { res = await this.#fetch(url, { method: 'GET', dispatcher: this.#dispatcher, signal: ctx.signal, }); } catch (err) { if (ctx.signal?.aborted) throw err; throw serviceUnavailable(`Omnisearch unreachable at ${this.#omnisearchUrl}. The plugin may have stopped (Obsidian quit, plugin disabled, or mobile session).`, { reason: 'omnisearch_unreachable', url: this.#omnisearchUrl, ...ctx.recoveryFor('omnisearch_unreachable'), }, { cause: err }); } if (!res.ok) { throw serviceUnavailable(`Omnisearch returned HTTP ${res.status} at ${this.#omnisearchUrl}.`, { reason: 'omnisearch_unreachable', url: this.#omnisearchUrl, status: res.status, ...ctx.recoveryFor('omnisearch_unreachable'), }); } const body = (await res.json()); return body.map(normalizeOmnisearchHit); } // ── UI / commands ──────────────────────────────────────────────────────── async executeCommand(ctx, commandId) { await this.#request(ctx, `/commands/${encodeURIComponent(commandId)}/`, { method: 'POST' }); } async openInUi(ctx, path, opts) { /** Gated as a read — opening a note in the UI doesn't mutate its content. */ this.#policy.assertReadable(path); const params = new URLSearchParams(); if (opts?.newLeaf) params.set('newLeaf', 'true'); const qs = params.toString(); await this.#request(ctx, `/open/${encodeVaultPath(path)}${qs ? `?${qs}` : ''}`, { method: 'POST', }); } // ── Internals ──────────────────────────────────────────────────────────── /** * Resolve a write target to a gated path target. For path inputs, gates * `target.path` as a write before any upstream call. For non-path inputs * (`active` / `periodic`) when restrictions are active, a JSON resolution * fetch happens first (without gating, since the user has write authority * on the resolved path or fails here), then the resolved path is gated. */ async #gateAsWrite(ctx, target) { if (target.type === 'path') { this.#policy.assertWritable(target.path); return target; } if (this.#policy.isUnrestricted) { return target; } const note = await this.#rawGetNoteJson(ctx, target); this.#policy.assertWritable(note.path); return { type: 'path', path: note.path }; } /** Raw NoteJson fetch — bypasses path-policy. Used by gate helpers to learn the resolved path. */ async #rawGetNoteJson(ctx, target) { const url = this.#targetToPath(target); const res = await this.#request(ctx, url, { method: 'GET', headers: { Accept: NOTE_JSON_ACCEPT }, }); return (await res.json()); } /** Raw document-map fetch — bypasses path-policy. Caller must gate. */ async #rawGetDocumentMap(ctx, target) { const url = this.#targetToPath(target); const res = await this.#request(ctx, url, { method: 'GET', headers: { Accept: DOCUMENT_MAP_ACCEPT }, }); return (await res.json()); } #targetToPath(target) { switch (target.type) { case 'path': return `/vault/${encodeVaultPath(target.path)}`; case 'active': return '/active/'; case 'periodic': { if (target.date) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(target.date); if (!m) { throw validationError(`Invalid date '${target.date}', expected YYYY-MM-DD.`); } const [, y, mo, d] = m; return `/periodic/${target.period}/${y}/${mo}/${d}/`; } return `/periodic/${target.period}/`; } } } #buildPatchHeaders(p) { const headers = { Operation: p.operation, 'Target-Type': p.targetType, Target: encodeURIComponent(p.target), 'Content-Type': p.contentType === 'json' ? 'application/json' : 'text/markdown', }; if (p.targetDelimiter) headers['Target-Delimiter'] = p.targetDelimiter; if (p.createTargetIfMissing) headers['Create-Target-If-Missing'] = 'true'; /** * Sense inversion: markdown-patch 1.0 (shipped with Local REST API v4.0.0) * renamed `Apply-If-Content-Preexists` to `Reject-If-Content-Preexists` * and flipped the default — patches now apply regardless of duplicates * unless the caller opts into rejection. We keep `applyIfContentPreexists` * on the public schema for caller stability and translate here: a falsy * value (the public default) sends the new Reject header to preserve the * historical idempotent-by-default behavior. Replace operations are * exempt at the plugin layer regardless of this flag. */ if (!p.applyIfContentPreexists) headers['Reject-If-Content-Preexists'] = 'true'; if (p.trimTargetWhitespace) headers['Trim-Target-Whitespace'] = 'true'; return headers; } #request(ctx, pathAndQuery, init) { const url = `${this.#config.baseUrl}${pathAndQuery}`; const headers = { ...(init.headers ?? {}) }; if (!init.skipAuth) { headers.Authorization = `Bearer ${this.#config.apiKey}`; } const exec = async () => { const res = await this.#fetch(url, { method: init.method, headers, ...(init.body !== undefined ? { body: init.body } : {}), dispatcher: this.#dispatcher, signal: ctx.signal, }); if (!res.ok) { await this.#throwForStatus(res, pathAndQuery, ctx); } return res; }; if (!RETRY_SAFE_METHODS.has(init.method.toUpperCase())) { return exec(); } return withRetry(exec, { operation: `obsidian.${init.method} ${pathAndQuery}`, context: { requestId: ctx.requestId, timestamp: ctx.timestamp, ...(ctx.tenantId !== undefined ? { tenantId: ctx.tenantId } : {}), ...(ctx.traceId !== undefined ? { traceId: ctx.traceId } : {}), ...(ctx.spanId !== undefined ? { spanId: ctx.spanId } : {}), }, baseDelayMs: 200, maxRetries: 3, signal: ctx.signal, }); } async #throwForStatus(res, path, ctx) { const text = await this.#readBodySafe(res); const body = parseJsonObject(text); const display = displayPath(path); const upstream = safeUpstream(body, text); const data = (reason) => ({ path: display, ...(reason !== undefined ? { reason, ...ctx.recoveryFor(reason) } : {}), ...(upstream ? { upstream } : {}), }); switch (res.status) { case 401: throw unauthorized('Obsidian Local REST API rejected the API key. Verify OBSIDIAN_API_KEY matches the value in Obsidian → Settings → Local REST API.', data()); case 403: throw forbidden('Obsidian Local REST API forbids this request. Check the plugin permissions.', data()); case 404: { if (path.startsWith('/active/')) { throw notFound('No file is currently active in Obsidian — open a file in the app first.', data('no_active_file')); } if (path.startsWith('/periodic/')) { const periodMatch = /^\/periodic\/(daily|weekly|monthly|quarterly|yearly)\//.exec(path); const period = periodMatch?.[1] ?? 'periodic'; const dateMatch = /\/(\d{4})\/(\d{2})\/(\d{2})\/?$/.exec(path); const suffix = dateMatch ? ` for ${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}` : ''; throw notFound(`No ${period} note found${suffix}. Check that the Periodic Notes plugin is enabled and the note exists.`, data('periodic_not_found')); } if (path.startsWith('/commands/')) { throw notFound(`Unknown Obsidian command: ${display}. Use \`obsidian_list_commands\` to discover valid command IDs.`, data('command_unknown')); } throw notFound(`Not found: ${display}`, data('note_missing')); } case 405: throw validationError(`${display} cannot accept this method (often: the path is a directory, not a file).`, data('path_is_directory')); case 400: { const upstreamMsg = body?.message ?? `Bad request to ${display}`; // Content-preexists is a more specific case nested inside the broader // "could not be applied" family — branch on it first so retries with // identical content surface the right reason and recovery (toggle // `applyIfContentPreexists`) instead of misleading section-miss copy. if (/content-already-preexists-in-target/i.test(upstreamMsg)) { throw validationError(`The supplied content already appears at the target in ${display}. Pass \`applyIfContentPreexists: true\` to force-apply, or change the content.`, data('content_preexists')); } // The Local REST API returns a "could not be applied to the target // content" / "invalid-target" message when a PATCH names a section that // doesn't exist. Translate to actionable guidance. const isTargetMiss = /\bcould not be applied\b|\binvalid-target\b/i.test(upstreamMsg); if (isTargetMiss) { throw validationError(`Section target not found in ${display}. Use \`obsidian_get_note\` with \`format: "document-map"\` to list available headings, blocks, and frontmatter fields. Nested headings need \`Parent::Child\` syntax.`, data('section_target_missing')); } // Periodic Notes plugin returns 400 with "Specified period is not enabled" // when the requested period (daily/weekly/monthly/...) is disabled in the // user's plugin settings. Distinct from periodic_not_found (404) — caller // can enable it or fall back to an explicit path target. if (path.startsWith('/periodic/') && /\bnot enabled\b/i.test(upstreamMsg)) { throw validationError(upstreamMsg, data('periodic_disabled')); } throw validationError(upstreamMsg, data()); } default: { /** * Unhandled 4xx and all 5xx — route through the framework helper so we * get the canonical status→code mapping (500/501→InternalError, * 502/503→ServiceUnavailable, 504→Timeout) and Retry-After capture. * Body has already been consumed above, so disable the helper's read * and pass the truncated body in via `data`. */ const truncated = text ? (text.length > 500 ? `${text.slice(0, 500)}…` : text) : undefined; throw await httpErrorFromResponse(res, { service: 'Obsidian Local REST API', captureBody: false, data: { ...data(), ...(truncated !== undefined ? { body: truncated } : {}), }, }); } } } async #readBodySafe(res) { try { return await res.text(); } catch { return ''; } } } /** * Encode a vault-relative path for the URL. Splits on `/` and `\` (so * Windows-style separators are honored), URL-encodes each segment, and * rejoins with `/` since the Local REST API plugin expects forward slashes. * * Rejects `.` and `..` segments here rather than relying on the upstream Local * REST API plugin to normalize them — `PathPolicy` short-circuits to "allow" * when `OBSIDIAN_READ_PATHS` is unset, and `..` is unreserved per RFC 3986 so * `encodeURIComponent` leaves it intact. This is the single chokepoint before * URL construction, so guard vault escape here. Backslash is treated as a * separator so `..\..\etc` traverses identically to `../../etc` and can't * sneak past as a single opaque segment. */ export function encodeVaultPath(path) { const segments = path.split(/[/\\]/).filter((seg) => seg.length > 0); for (const seg of segments) { if (seg === '.' || seg === '..') { throw validationError(`Path traversal not allowed: '${path}'`, { path, reason: 'path_traversal', }); } } return segments.map((seg) => encodeURIComponent(seg)).join('/'); } /** * Convert an internal URL path (e.g. `/vault/Projects/My%20Note.md`) to the * vault-relative form a caller would recognize. Used in error messages so the * user sees the same path they sent in. */ function displayPath(urlPath) { if (urlPath.startsWith('/active/')) return '(active file)'; const noQuery = urlPath.split('?')[0] ?? urlPath; let decoded; try { decoded = decodeURIComponent(noQuery); } catch { decoded = noQuery; } const periodic = /^\/periodic\/(daily|weekly|monthly|quarterly|yearly)\/(?:(\d{4})\/(\d{2})\/(\d{2})\/?)?$/.exec(decoded); if (periodic) { const [, period, y, mo, d] = periodic; return y && mo && d ? `${period} note for ${y}-${mo}-${d}` : `${period} note for the current period`; } for (const prefix of ['/vault/', '/open/', '/commands/']) { if (decoded.startsWith(prefix)) { return decoded.slice(prefix.length).replace(/\/+$/, '') || decoded; } } return decoded; } /** * Trim the upstream error body down to a safe, user-presentable shape — drops * `errorCode` and any other plugin-internal fields that would otherwise leak * into JSON-RPC `error.data`. */ function safeUpstream(body, text) { if (body?.message) return { message: body.message }; const trimmed = text.trim(); if (trimmed) return { message: trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed }; return; } /** * Read the `Content-Length` header from a HEAD response and parse it as a * non-negative integer byte count. Throws when the upstream omits the header * or returns a non-numeric value — the size helpers don't fall back to GET. */ function parseContentLength(res, url) { const raw = res.headers.get('content-length'); if (raw === null) { throw new Error(`Obsidian Local REST API HEAD response missing Content-Length header for ${url}.`); } const n = Number(raw); if (!Number.isInteger(n) || n < 0) { throw new Error(`Obsidian Local REST API returned invalid Content-Length '${raw}' for ${url}.`); } return n; } /** * Resolve the Omnisearch URL. Override wins. Otherwise: take the host from * `OBSIDIAN_BASE_URL`, force `http:` (Omnisearch is HTTP-only), swap port * `27123/27124` → `51361`. `127.0.0.1` is mapped to `localhost` since * Omnisearch's current Node listener binds IPv4 only but the platform's * loopback resolver is flexible — `localhost` insulates us if a future * build switches binding. Falls back to `http://localhost:51361` on any * URL parse failure (config validation catches malformed `baseUrl`, so this * is belt-and-suspenders). */ function deriveOmnisearchUrl(config) { if (config.omnisearchUrl) return config.omnisearchUrl.replace(/\/+$/, ''); try { const u = new URL(config.baseUrl); const host = u.hostname === '127.0.0.1' ? 'localhost' : u.hostname; return `http://${host}:${OMNISEARCH_DEFAULT_PORT}`; } catch { return `http://localhost:${OMNISEARCH_DEFAULT_PORT}`; } } function normalizeOmnisearchHit(raw) { return { basename: raw.basename, excerpt: cleanExcerpt(raw.excerpt), filename: raw.path, foundWords: raw.foundWords, matches: raw.matches, score: raw.score, }; } /** * Normalize Omnisearch's excerpt HTML: `<br>` → `\n`, decode the entities * the upstream actually emits (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#039;`, * `&apos;`, plus numeric `&#NNN;` / `&#xNN;`). `<mark>` tags are preserved — * they highlight the match span and are interpretable as emphasis. */ function cleanExcerpt(excerpt) { return excerpt .replace(/<br\s*\/?>/gi, '\n') .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => safeCodePoint(parseInt(n, 16))) .replace(/&#(\d+);/g, (_, n) => safeCodePoint(Number(n))) .replace(/&apos;/g, "'") .replace(/&quot;/g, '"') .replace(/&lt;/g, '<') .replace(/&gt;/g, '>') .replace(/&amp;/g, '&'); } function safeCodePoint(cp) { if (!Number.isInteger(cp) || cp < 0 || cp > 0x10ffff) return ''; return String.fromCodePoint(cp); } function parseJsonObject(text) { if (!text) return; try { const v = JSON.parse(text); return v && typeof v === 'object' ? v : undefined; } catch { return; } } let _service; export function initObsidianService(config = getServerConfig(), fetchImpl) { _service = new ObsidianService(config, fetchImpl); } /** Test-only: directly install an instance (e.g., one backed by a stub fetch). */ export function setObsidianService(service) { _service = service; } export function getObsidianService() { if (!_service) { throw new Error('ObsidianService not initialized — call initObsidianService() in setup().'); } return _service; } //# sourceMappingURL=obsidian-service.js.map