@mulmoclaude/core 5.8.0 → 5.8.1
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/dist/collection/firestore.cjs.map +1 -1
- package/dist/collection/firestore.js.map +1 -1
- package/dist/collection/index.cjs +3 -3
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +3 -3
- package/dist/collection/registry/server/index.cjs +3 -3
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +3 -3
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/index.cjs +2 -2
- package/dist/collection/server/index.js +2 -2
- package/dist/collection-watchers/index.cjs +4 -4
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +4 -4
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-XoYJI2Ot.js → discovery-CbwHtcwk.js} +3 -3
- package/dist/{discovery-XoYJI2Ot.js.map → discovery-CbwHtcwk.js.map} +1 -1
- package/dist/{discovery-CwBXvtyn.cjs → discovery-DgZRmO7F.cjs} +3 -3
- package/dist/discovery-DgZRmO7F.cjs.map +1 -0
- package/dist/{dist-Gj7ygaW2.cjs → dist-BZ62pKuU.cjs} +11 -1
- package/dist/dist-BZ62pKuU.cjs.map +1 -0
- package/dist/{dist-BzoA9pDR.js → dist-MFUV9FYo.js} +6 -2
- package/dist/dist-MFUV9FYo.js.map +1 -0
- package/dist/errorMessage-OEQzRwsi.cjs +1 -0
- package/dist/errorMessage-VEiXHRnX.js +1 -0
- package/dist/{errors-CdXsfFCF.cjs → errors-B0xloJ3E.cjs} +2 -2
- package/dist/errors-B0xloJ3E.cjs.map +1 -0
- package/dist/{errors-C7TI_t_4.js → errors-zTAsW7dS.js} +2 -2
- package/dist/{errors-C7TI_t_4.js.map → errors-zTAsW7dS.js.map} +1 -1
- package/dist/feeds/paths.cjs.map +1 -1
- package/dist/feeds/server/index.cjs +4 -4
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +4 -4
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/file-change/index.cjs.map +1 -1
- package/dist/files/index.cjs.map +1 -1
- package/dist/files/index.js.map +1 -1
- package/dist/global-config/index.cjs.map +1 -1
- package/dist/google/index.cjs +3 -3
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +3 -3
- package/dist/google/index.js.map +1 -1
- package/dist/graph-CURotEkH.cjs.map +1 -1
- package/dist/ingestTypes-DS9HiR2I.cjs.map +1 -1
- package/dist/{listen-Dca-NoBi.js → listen-DUlzSOzX.js} +2 -2
- package/dist/{listen-Dca-NoBi.js.map → listen-DUlzSOzX.js.map} +1 -1
- package/dist/{listen-BXthb-9C.cjs → listen-N_8XdKz_.cjs} +2 -2
- package/dist/listen-N_8XdKz_.cjs.map +1 -0
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/{notifier-B65Mvngb.cjs → notifier-CiemUClt.cjs} +3 -6
- package/dist/notifier-CiemUClt.cjs.map +1 -0
- package/dist/{notifier-vAp5t6wL.js → notifier-dgkC-h80.js} +2 -5
- package/dist/notifier-dgkC-h80.js.map +1 -0
- package/dist/paths-C8-jxR0s.cjs.map +1 -1
- package/dist/plugin-vue/i18n.cjs.map +1 -1
- package/dist/plugin-vue/index.cjs +82 -16
- package/dist/plugin-vue/index.cjs.map +1 -1
- package/dist/plugin-vue/index.js +82 -16
- package/dist/plugin-vue/index.js.map +1 -1
- package/dist/{promptSafety-BGV1wrE6.cjs → promptSafety-auQEatax.cjs} +2 -2
- package/dist/promptSafety-auQEatax.cjs.map +1 -0
- package/dist/{promptSafety-B5uGroik.js → promptSafety-yG60Vof8.js} +2 -2
- package/dist/promptSafety-yG60Vof8.js.map +1 -0
- package/dist/relPath-CTAjGdCL.cjs.map +1 -1
- package/dist/remote-host/index.cjs +1 -1
- package/dist/remote-host/index.js +1 -1
- package/dist/remote-host/server/index.cjs +4 -4
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +4 -4
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{remote-host-DhAHBYzN.cjs → remote-host-BMjh_F-6.cjs} +2 -2
- package/dist/remote-host-BMjh_F-6.cjs.map +1 -0
- package/dist/{remote-host-Cjox2F2J.js → remote-host-CoNX9JEC.js} +2 -2
- package/dist/remote-host-CoNX9JEC.js.map +1 -0
- package/dist/remote-view/index.cjs +1 -1
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.js +1 -1
- package/dist/root-BvwBUcol.cjs.map +1 -1
- package/dist/scheduler/index.cjs +2 -2
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/scheduler/index.js.map +1 -1
- package/dist/{server-CLhww8NL.cjs → server-RsnFqPt7.cjs} +5 -5
- package/dist/server-RsnFqPt7.cjs.map +1 -0
- package/dist/{server-CTkTHqEL.js → server-iYXYObKb.js} +5 -5
- package/dist/server-iYXYObKb.js.map +1 -0
- package/dist/skill-bridge/index.cjs.map +1 -1
- package/dist/utils/index.cjs +2 -2
- package/dist/utils/index.js +2 -2
- package/dist/whisper/index.cjs +2 -2
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +2 -2
- package/dist/whisper/index.js.map +1 -1
- package/dist/wiki/index.cjs +1 -1
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +1 -1
- package/dist/wiki/index.js.map +1 -1
- package/dist/wiki/paths.cjs.map +1 -1
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/workspace-setup/index.js.map +1 -1
- package/package.json +6 -6
- package/dist/discovery-CwBXvtyn.cjs.map +0 -1
- package/dist/dist-BzoA9pDR.js.map +0 -1
- package/dist/dist-Gj7ygaW2.cjs.map +0 -1
- package/dist/errorMessage-BQNpSYdT.js +0 -1
- package/dist/errorMessage-CT-va_Pv.cjs +0 -1
- package/dist/errors-CdXsfFCF.cjs.map +0 -1
- package/dist/listen-BXthb-9C.cjs.map +0 -1
- package/dist/notifier-B65Mvngb.cjs.map +0 -1
- package/dist/notifier-vAp5t6wL.js.map +0 -1
- package/dist/promptSafety-B5uGroik.js.map +0 -1
- package/dist/promptSafety-BGV1wrE6.cjs.map +0 -1
- package/dist/remote-host-Cjox2F2J.js.map +0 -1
- package/dist/remote-host-DhAHBYzN.cjs.map +0 -1
- package/dist/server-CLhww8NL.cjs.map +0 -1
- package/dist/server-CTkTHqEL.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../src/feeds/server/host.ts","../../../src/feeds/server/registry.ts","../../../src/feeds/server/retrievers/index.ts","../../../../common/dist/ssrf.js","../../../src/feeds/server/fetch/httpClient.ts","../../../src/feeds/server/fetch/rssParser.ts","../../../src/feeds/server/pathResolver.ts","../../../src/feeds/server/projectItem.ts","../../../src/feeds/server/retrievers/rss.ts","../../../src/feeds/server/retrievers/httpJson.ts","../../../src/feeds/server/state.ts","../../../src/feeds/server/agentIngest.ts","../../../src/feeds/server/engine.ts","../../../src/feeds/server/scheduledRefresh.ts"],"sourcesContent":["// Host injection seam for the Feeds engine — mirrors `configureCollectionHost`\n// / `configureScheduler`. The engine is host-agnostic: the workspace root, the\n// logger, an atomic file writer, and the hidden/visible agent-ingest worker\n// launcher are injected once at boot via `configureFeedsHost`. Everything else\n// the engine needs (collection IO, the notifier) is a sibling `@mulmoclaude/core`\n// subpath, imported directly. Both MulmoClaude and MulmoTerminal supply their\n// own host shim.\n\nimport { createForwardingLogger, createHostSlot, type StructuredLogger } from \"../../host/hostSlot.js\";\n\n/** Outcome of launching one hidden/visible agent-ingest worker. `chatId` lets\n * the caller register a completion hook so a failed refresh doesn't die\n * silently. */\nexport type AgentWorkerResult = { ok: true; chatId: string } | { ok: false; error: string };\n\n/** Launches a worker chat. Injected at boot to keep the feeds engine from\n * importing a host's routes/session layer. `hidden` chooses an invisible\n * system worker (scheduled refresh) vs a visible session the user can watch\n * (manual Refresh — debuggable). `onComplete` is a one-shot completion hook\n * (only honoured for hidden workers) so the dispatcher learns success/failure.\n * Returns `ok:false` on the concurrency-cap miss or a launch error — the caller\n * leaves state untouched and retries next tick. */\nexport type AgentWorkerRunner = (args: {\n message: string;\n roleId: string;\n hidden: boolean;\n /** Absolute root this refresh is for — the root `refreshViaAgent` was called\n * with, forwarded because the seed prompt's `dataPath` is RELATIVE to it.\n *\n * A multi-root host must spawn the worker THERE (as its cwd), or the worker\n * resolves `data/collections/<slug>/items` against whatever root it happens\n * to run in and writes another project's records — silently, since both\n * paths exist and neither side errors.\n *\n * Optional so a single-workspace host's runner (MulmoClaude's) can ignore it\n * and behave exactly as before: there is only one root, and it is the one the\n * worker already runs in. */\n workspaceRoot?: string | undefined;\n onComplete?: ((outcome: { didError: boolean }) => void | Promise<void>) | undefined;\n}) => Promise<AgentWorkerResult>;\n\n/** Public alias of the shared `StructuredLogger` — same shape as `CollectionLogger`. */\nexport type FeedsLogger = StructuredLogger;\n\nexport interface FeedsHost {\n /** Absolute workspace root — the default for `refreshDue()` and state paths. */\n workspaceRoot: string;\n /** Host logger. */\n log: FeedsLogger;\n /** Host atomic file writer (state files). */\n writeFileAtomic: (filePath: string, content: string) => Promise<void>;\n /** Launches the agent-ingest worker (was `setAgentWorkerRunner`). */\n spawnWorker: AgentWorkerRunner;\n}\n\nconst hostSlot = createHostSlot<FeedsHost>(\"@mulmoclaude/core/feeds: configureFeedsHost()\");\n\n/** Wire the feeds engine to a host. Call once at startup, before any refresh. */\nexport function configureFeedsHost(host: FeedsHost): void {\n hostSlot.set(host);\n}\n\n/** The configured host, or throw if `configureFeedsHost` was never called. */\nexport function requireFeedsHost(): FeedsHost {\n return hostSlot.get();\n}\n\n/** Test-only: clear the configured host. */\nexport function resetFeedsHostForTesting(): void {\n hostSlot.reset();\n}\n\n/** Forwarding logger so engine modules can `import { log }` without each\n * reaching for `requireFeedsHost().log`. Non-critical: calls before the host\n * is wired are dropped (not thrown), matching the collection/google seams. */\nexport const log: FeedsLogger = createForwardingLogger(() => hostSlot.peek()?.log ?? null);\n","// List the registered data-source feeds. Feeds are CREATED / REMOVED by\n// the agent writing / deleting `feeds/<slug>/schema.json` directly (see\n// config/helps/feeds.md) — the host only discovers + retrieves them.\n// icon / dataPath defaults for agent-authored feed schemas are applied in\n// `collection/server/discovery.ts` (source === \"feed\").\n\nimport { rm } from \"node:fs/promises\";\nimport { discoverCollections, resolveDataDir, safeSlugName, type LoadedCollection } from \"../../collection/server/index.js\";\nimport { log, requireFeedsHost } from \"./host.js\";\nimport { feedDir } from \"../paths.js\";\n\n/** Every registered feed, as a discovered collection (carrying its\n * validated schema, `ingest`, and resolved `dataDir`). */\nexport async function listFeeds(workspaceRoot: string = requireFeedsHost().workspaceRoot): Promise<LoadedCollection[]> {\n const all = await discoverCollections({ workspaceRoot });\n return all.filter((collection) => collection.source === \"feed\");\n}\n\n/** Delete a feed entirely: its records AND its `feeds/<slug>/` directory\n * (schema + state). Idempotent. Host-side only (backs the UI delete\n * button); the agent removes a feed by deleting both directories itself.\n *\n * The records dir is derived from the SLUG (`data/feeds/<slug>`), never\n * from the schema's `dataPath` — feeds are forced into that namespace at\n * discovery, so a malformed/hostile `dataPath` can't redirect this delete\n * at another app's data (e.g. `data/wiki`). `resolveDataDir` also rejects\n * any path that escapes the workspace. */\nexport async function removeFeed(workspaceRoot: string, slug: string): Promise<boolean> {\n const safe = safeSlugName(slug);\n if (safe === null) return false;\n const recordsDir = resolveDataDir(`data/feeds/${safe}`, workspaceRoot);\n try {\n if (recordsDir) await rm(recordsDir, { recursive: true, force: true });\n await rm(feedDir(safe, workspaceRoot), { recursive: true, force: true });\n log.info(\"feeds\", \"feed + records removed\", { slug: safe });\n return true;\n } catch (error) {\n log.warn(\"feeds\", \"feed remove failed\", { slug: safe, error: String(error) });\n return false;\n }\n}\n","// Pluggable retriever registry. Each declarative `ingest.kind` maps to one\n// RetrieveFn that fetches the endpoint and returns projected records.\n// Side-effect registration keeps the engine decoupled from the kinds.\n// The `agent` kind is NOT a retriever (it dispatches a hidden worker before\n// the engine consults this registry); a future `code` kind would register here.\n\nimport type { CollectionItem, CollectionSchema } from \"../../../collection/index.js\";\nimport type { DeclarativeIngestSpec } from \"../../ingestTypes.js\";\nimport type { FeedState } from \"../state.js\";\n\nexport interface RetrieveResult {\n /** Projected records, keyed by primaryKey (the engine upserts them). */\n items: CollectionItem[];\n /** Updated retriever cursor to persist (incremental fetches). */\n cursor: Record<string, string>;\n}\n\n// Declarative-only: the engine branches `agent` ingest off BEFORE looking up a\n// retriever, so a RetrieveFn never sees a non-fetch spec (and rss/http-json can\n// read `ingest.url`/`map` without union narrowing).\nexport type RetrieveFn = (ingest: DeclarativeIngestSpec, schema: CollectionSchema, state: FeedState) => Promise<RetrieveResult>;\n\nconst registry = new Map<string, RetrieveFn>();\n\nexport function registerRetriever(kind: string, retriever: RetrieveFn): void {\n registry.set(kind, retriever);\n}\n\nexport function getRetriever(kind: string): RetrieveFn | undefined {\n return registry.get(kind);\n}\n","// The single SSRF deny-list for the monorepo: address classification and\n// URL-shape checks shared by every guard that fetches model-authored /\n// remote-user-supplied URLs (mastodon bridge urlGuard, core feeds httpClient).\n// Before #2459 the CIDR table was hand-copied per guard and had drifted.\n//\n// Everything here is pure and isomorphic — no `node:` builtins. The parts that\n// need the network (DNS resolution, per-redirect re-checks) stay in each\n// consumer's thin wrapper.\n/* eslint-disable sonarjs/no-hardcoded-ip -- the blocked ranges ARE this module's\n specification; writing them as literals is the point, not an oversight. */\nconst ALLOWED_PROTOCOLS = new Set([\"http:\", \"https:\"]);\nconst BLOCKED_HOSTNAMES = new Set([\"localhost\", \"ip6-localhost\", \"ip6-loopback\"]);\nconst BLOCKED_HOSTNAME_SUFFIXES = [\".localhost\", \".local\", \".internal\"];\n/** IPv4 ranges that must never be fetched, as [firstAddress, prefixLength].\n * The union of the pre-#2459 per-guard tables — removing an entry weakens\n * every guard in the repo at once, so treat edits as security changes. */\nexport const BLOCKED_IPV4_RANGES = [\n [\"0.0.0.0\", 8], // \"this network\"\n [\"10.0.0.0\", 8], // RFC1918\n [\"100.64.0.0\", 10], // CGNAT\n [\"127.0.0.0\", 8], // loopback\n [\"169.254.0.0\", 16], // link-local — includes the 169.254.169.254 metadata endpoint\n [\"172.16.0.0\", 12], // RFC1918\n [\"192.0.0.0\", 24], // IETF protocol assignments\n [\"192.168.0.0\", 16], // RFC1918\n [\"198.18.0.0\", 15], // benchmarking\n [\"224.0.0.0\", 4], // multicast\n [\"240.0.0.0\", 4], // reserved / broadcast\n];\n/** Dotted-decimal IPv4 → unsigned 32-bit value; null for anything else\n * (hex / octal octets, whitespace, wrong part count). */\nexport function ipv4ToInt(address) {\n const parts = address.split(\".\");\n if (parts.length !== 4)\n return null;\n const octets = parts.map((part) => (/^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN));\n if (octets.some((octet) => Number.isNaN(octet) || octet > 255))\n return null;\n return octets.reduce((value, octet) => value * 256 + octet, 0);\n}\nfunction isBlockedIpv4Value(value) {\n return BLOCKED_IPV4_RANGES.some(([base, prefix]) => {\n const baseValue = ipv4ToInt(base);\n if (baseValue === null)\n return false;\n // `>>> 0` keeps the mask unsigned; a /0 shift would be a no-op anyway.\n const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;\n return (value & mask) === (baseValue & mask);\n });\n}\n/** True when this parses as IPv4 AND falls inside a blocked range. Non-IPv4\n * input is false — \"not a blocked literal\", not \"safe\"; a caller that must\n * reject non-IP input entirely does so at its own boundary. */\nexport function isBlockedIpv4(address) {\n const value = ipv4ToInt(address);\n if (value === null)\n return false;\n return isBlockedIpv4Value(value);\n}\n/** `URL.hostname` keeps the brackets on an IPv6 literal (`[::1]`); strip them\n * so the address checks see the bare address. */\nexport function stripIpv6Brackets(hostname) {\n return hostname.replace(/^\\[|\\]$/g, \"\");\n}\n/** The 32-bit value inside an IPv4-mapped IPv6 literal, or null when the\n * address is not v4-mapped. The hex spelling matters: WHATWG URL serializes\n * `[::ffff:127.0.0.1]` as `::ffff:7f00:1`, so matching only the dotted form\n * would wave the mapped loopback through any URL-sourced check. */\nfunction mappedIpv4Value(lower) {\n const dotted = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/.exec(lower)?.[1];\n if (dotted !== undefined)\n return ipv4ToInt(dotted);\n const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);\n const [, high, low] = hex ?? [];\n if (high !== undefined && low !== undefined)\n return Number.parseInt(high, 16) * 0x10000 + Number.parseInt(low, 16);\n return null;\n}\n/** Blocks loopback/unspecified, fc00::/7 unique-local, fe80::/10 link-local,\n * and IPv4-mapped forms of blocked v4 addresses. Mask-based on the leading\n * group — prefix string matching would miss e.g. fe9a:: (inside fe80::/10). */\nexport function isBlockedIpv6(address) {\n const lower = stripIpv6Brackets(address.toLowerCase());\n if (lower === \"::1\" || lower === \"::\")\n return true;\n // IPv4-mapped re-enters the v4 rules.\n const mapped = mappedIpv4Value(lower);\n if (mapped !== null)\n return isBlockedIpv4Value(mapped);\n const [head = \"\"] = lower.split(\":\");\n if (head.length === 0)\n return false;\n const leading = Number.parseInt(head, 16);\n if (Number.isNaN(leading))\n return false;\n if ((leading & 0xfe00) === 0xfc00)\n return true; // fc00::/7 unique-local\n if ((leading & 0xffc0) === 0xfe80)\n return true; // fe80::/10 link-local\n return false;\n}\n/** True when this literal address must never be fetched. Non-IP input is\n * false — see `isBlockedIpv4`. */\nexport function isBlockedIp(address) {\n return address.includes(\":\") ? isBlockedIpv6(address) : isBlockedIpv4(address);\n}\n/** Names that are internal by convention (localhost, *.local, *.internal…),\n * rejected without waiting for DNS to prove it. */\nexport function isBlockedHostname(hostname) {\n const lower = hostname.toLowerCase().replace(/\\.$/, \"\");\n if (BLOCKED_HOSTNAMES.has(lower))\n return true;\n return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => lower.endsWith(suffix));\n}\nfunction parseUrl(raw) {\n try {\n return new URL(raw);\n }\n catch {\n return null;\n }\n}\n/** Parse and apply every check that doesn't need the network: shape, scheme,\n * obviously-internal hostname, and literal addresses. Returns null on reject.\n * Callers must still vet what non-literal hostnames resolve to. */\nexport function parseSafeUrlShape(raw) {\n const url = parseUrl(raw);\n if (url === null)\n return null;\n if (!ALLOWED_PROTOCOLS.has(url.protocol))\n return null;\n const hostname = stripIpv6Brackets(url.hostname);\n if (hostname.length === 0)\n return null;\n if (isBlockedHostname(hostname))\n return null;\n if (isBlockedIp(hostname))\n return null;\n return url;\n}\n","// Minimal HTTP client for feed retrievers. Feed URLs are model-authored /\n// user-supplied, so beyond a User-Agent + timeout this guards against SSRF:\n// every URL (and every redirect hop) is DNS-resolved and rejected if it\n// points at a loopback / private / link-local / cloud-metadata address.\n// The deny-list itself is the repo-wide one in `@mulmoclaude/common/ssrf`\n// (#2459); this wrapper adds the DNS resolution and manual redirect loop.\n// Redirects are followed MANUALLY so a public URL can't 302 to an internal\n// one and bypass the guard. It does NOT do robots.txt / rate limiting (the\n// engine fetches feeds sequentially to stay gentle).\n//\n// Residual risk (same as the mastodon urlGuard): the DNS answer we validate is\n// not necessarily the one the subsequent fetch() uses — it re-resolves — so a\n// rebinding attacker with a very short TTL can still slip through. Pinning the\n// resolved address into the connection would need a custom agent.\n\nimport { lookup } from \"node:dns/promises\";\nimport { isIP } from \"node:net\";\nimport { isBlockedIpv4, isBlockedIpv6, parseSafeUrlShape, stripIpv6Brackets } from \"@mulmoclaude/common/ssrf\";\n\n// Inlined — the client needs only this one time constant and must stay free of\n// host-side time-constant modules.\nconst ONE_SECOND_MS = 1_000;\n\n/** Identifies the bot to site operators. */\nexport const FEED_USER_AGENT = \"MulmoClaude-FeedBot/1.0 (+https://github.com/receptron/mulmoclaude)\";\n\n/** Per-request wall-clock cap so a hung server can't wedge a refresh. */\nexport const DEFAULT_FEED_TIMEOUT_MS = 30 * ONE_SECOND_MS;\n\n/** Cap on redirect hops followed (each re-checked for SSRF). */\nconst MAX_REDIRECTS = 5;\n\n/** DNS answers should be IP literals; anything else is blocked outright. */\nfunction isBlockedResolvedIp(address: string): boolean {\n const kind = isIP(address);\n if (kind === 4) return isBlockedIpv4(address);\n if (kind === 6) return isBlockedIpv6(address);\n return true;\n}\n\nasync function resolveAddresses(host: string): Promise<{ address: string }[]> {\n try {\n return await lookup(host, { all: true });\n } catch (err) {\n throw new Error(`could not resolve host '${host}': ${String(err)}`);\n }\n}\n\n/** Reject non-http(s) URLs, internal-by-convention hostnames, and URLs whose\n * host is / resolves to a private/loopback address (SSRF guard). Throws with\n * a clear reason; returns void on pass. */\nasync function assertFetchableUrl(rawUrl: string): Promise<void> {\n if (!/^https?:\\/\\//i.test(rawUrl)) throw new Error(`refusing non-http(s) URL: ${rawUrl}`);\n const url = parseSafeUrlShape(rawUrl);\n if (url === null) throw new Error(`refusing to fetch a private/loopback/internal address: ${rawUrl}`);\n const host = stripIpv6Brackets(url.hostname);\n // A literal address was already vetted by the shape check above.\n if (isIP(host)) return;\n const blocked = (await resolveAddresses(host)).find((entry) => isBlockedResolvedIp(entry.address));\n if (blocked) throw new Error(`refusing to fetch '${host}' — resolves to a private/loopback address (${blocked.address})`);\n}\n\nasync function fetchOnce(url: string, timeoutMs: number): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(new DOMException(`feed fetch timed out after ${timeoutMs}ms`, \"TimeoutError\")), timeoutMs);\n try {\n return await fetch(url, { headers: { \"User-Agent\": FEED_USER_AGENT }, signal: controller.signal, redirect: \"manual\" });\n } finally {\n clearTimeout(timer);\n }\n}\n\n// Follow redirects manually, re-running the SSRF guard on every hop so a\n// public URL cannot bounce to an internal target.\nasync function fetchGuarded(rawUrl: string, timeoutMs: number): Promise<Response> {\n let current = rawUrl;\n for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n await assertFetchableUrl(current);\n const response = await fetchOnce(current, timeoutMs);\n const redirect = response.status >= 300 && response.status < 400 && response.status !== 304 ? response.headers.get(\"location\") : null;\n if (!redirect) return response;\n current = new URL(redirect, current).toString();\n }\n throw new Error(`too many redirects (>${MAX_REDIRECTS}) starting from ${rawUrl}`);\n}\n\n/** Fetch a URL as text, throwing on guard rejection, network error, or non-2xx. */\nexport async function fetchText(url: string, timeoutMs: number = DEFAULT_FEED_TIMEOUT_MS): Promise<string> {\n const response = await fetchGuarded(url, timeoutMs);\n if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);\n return response.text();\n}\n\n/** Fetch a URL as parsed JSON, throwing on guard rejection, network error, or non-2xx. */\nexport async function fetchJson(url: string, timeoutMs: number = DEFAULT_FEED_TIMEOUT_MS): Promise<unknown> {\n const response = await fetchGuarded(url, timeoutMs);\n if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);\n return response.json();\n}\n","// RSS 2.0 + Atom 1.0 + RSS 1.0 (RDF) parser for the Feeds engine.\n//\n// Deliberately NON-normalizing: it locates the feed's items and hands\n// each one back as its RAW parsed XML element. The host hard-codes no\n// per-feed field list — `ingest.map` resolves source paths against the\n// raw item (tags are keys; attributes are prefixed `@_`; namespaced tags\n// keep their prefix), and the caller inspects the feed to decide what to\n// map. Generic value handling (text-node unwrapping, date parsing by the\n// target field's declared type) lives in `projectItem.ts`, not here.\n//\n// Own copy of the XML plumbing — the Feeds tree does not import the\n// legacy `sources` tree. Pure; unit-testable with fixture strings.\n\nimport { XMLParser } from \"fast-xml-parser\";\n\n// Tiny inline type guards (the host's shared `utils/types` is not available in\n// this shared package — these are the only two we need here).\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n\nexport interface ParsedFeedItem {\n /** The raw parsed XML <item>/<entry> object, verbatim. */\n raw: Record<string, unknown>;\n}\n\nexport interface ParsedFeed {\n kind: \"rss\" | \"atom\";\n title: string | null;\n items: ParsedFeedItem[];\n}\n\nconst xml = new XMLParser({\n ignoreAttributes: false,\n attributeNamePrefix: \"@_\",\n cdataPropName: \"#cdata\",\n parseTagValue: false,\n parseAttributeValue: false,\n trimValues: true,\n // Only the item containers are forced to arrays (single vs. many).\n // Everything else stays in its natural parsed shape so `ingest.map`\n // resolves paths the way the caller sees them in the feed.\n isArray: (name) => name === \"item\" || name === \"entry\",\n});\n\n/** Parse an RSS/Atom/RDF feed body. Returns null when the input doesn't\n * look like a feed we understand. */\nexport function parseFeed(body: string): ParsedFeed | null {\n const text = stripBom(body);\n if (!text.trim()) return null;\n let parsed: unknown;\n try {\n parsed = xml.parse(text);\n } catch {\n return null;\n }\n if (!isRecord(parsed)) return null;\n if (isRecord(parsed.rss)) return parseRss(parsed.rss);\n if (isRecord(parsed.feed)) return parseAtom(parsed.feed);\n const rdf = parsed[\"rdf:RDF\"] ?? parsed.RDF;\n if (isRecord(rdf)) return parseRss10(rdf);\n return null;\n}\n\n// Collect the raw item objects, skipping anything that isn't a record or\n// carries no <title> (drops feed-level noise / empty entries).\nfunction collectItems(value: unknown): ParsedFeedItem[] {\n const rawItems = Array.isArray(value) ? value : [];\n const items: ParsedFeedItem[] = [];\n for (const raw of rawItems) {\n if (isRecord(raw) && readString(raw.title) !== null) items.push({ raw });\n }\n return items;\n}\n\nfunction parseRss(rss: Record<string, unknown>): ParsedFeed | null {\n const { channel } = rss;\n if (!isRecord(channel)) return null;\n return { kind: \"rss\", title: readString(channel.title), items: collectItems(channel.item) };\n}\n\nfunction parseRss10(rdf: Record<string, unknown>): ParsedFeed | null {\n const channel = isRecord(rdf.channel) ? rdf.channel : null;\n return { kind: \"rss\", title: channel ? readString(channel.title) : null, items: collectItems(rdf.item) };\n}\n\nfunction parseAtom(feed: Record<string, unknown>): ParsedFeed | null {\n return { kind: \"atom\", title: readString(feed.title), items: collectItems(feed.entry) };\n}\n\n// --- helpers ------------------------------------------------------------\n\n// Extract a string from a plain string, a `{ \"#text\" }` / `{ \"#cdata\" }`\n// node, or an array (first non-empty). Used only to read the feed/item\n// title for the channel name + the empty-item filter.\nfunction readString(value: unknown): string | null {\n if (isNonEmptyString(value)) return value;\n if (typeof value === \"string\") return null;\n if (isRecord(value)) return readStringFromRecord(value);\n if (Array.isArray(value)) return readStringFromArray(value);\n return null;\n}\n\nfunction readStringFromRecord(record: Record<string, unknown>): string | null {\n const text = record[\"#text\"];\n if (isNonEmptyString(text)) return text;\n const cdata = record[\"#cdata\"];\n if (isNonEmptyString(cdata)) return cdata;\n return null;\n}\n\nfunction readStringFromArray(array: readonly unknown[]): string | null {\n for (const entry of array) {\n const resolved = readString(entry);\n if (resolved !== null) return resolved;\n }\n return null;\n}\n\nfunction stripBom(text: string): string {\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n","// Tiny dot/bracket path resolver used by the declarative `ingest.map`\n// and `ingest.itemsAt`. Pure, no I/O, exhaustively unit-testable.\n//\n// Supported syntax:\n// \"title\" → root.title\n// \"data.name\" → root.data.name\n// \"results[0].id\" → root.results[0].id\n// \"hourly[]\" → root.hourly (trailing [] = \"the array\n// here\"; the marker is a no-op for value reads)\n// \"data.results[]\" → root.data.results\n//\n// Any miss (wrong type, out-of-range index, absent key) yields\n// `undefined` rather than throwing — declarative configs fail soft.\n\nimport { isRecord, isUnknownArray } from \"@mulmoclaude/common\";\n\ninterface KeyToken {\n kind: \"key\";\n key: string;\n}\ninterface IndexToken {\n kind: \"index\";\n index: number;\n}\ntype PathToken = KeyToken | IndexToken;\n\nconst BRACKET_RE = /\\[(\\d*)\\]/g;\n\n/** Split one dot-segment (`results[0]`, `hourly[]`, `name`) into tokens.\n * An empty `[]` is an array-identity marker and emits no token — the\n * array value is read by the preceding key. */\nfunction parseSegment(segment: string, tokens: PathToken[]): void {\n const bracketStart = segment.indexOf(\"[\");\n const name = bracketStart === -1 ? segment : segment.slice(0, bracketStart);\n if (name.length > 0) tokens.push({ kind: \"key\", key: name });\n if (bracketStart === -1) return;\n for (const match of segment.slice(bracketStart).matchAll(BRACKET_RE)) {\n const [, inner = \"\"] = match;\n if (inner.length > 0) tokens.push({ kind: \"index\", index: Number(inner) });\n }\n}\n\nfunction tokenize(path: string): PathToken[] {\n const tokens: PathToken[] = [];\n for (const segment of path.split(\".\")) {\n if (segment.length > 0) parseSegment(segment, tokens);\n }\n return tokens;\n}\n\nfunction step(current: unknown, token: PathToken): unknown {\n if (current === null || current === undefined) return undefined;\n if (token.kind === \"key\") {\n if (!isRecord(current)) return undefined;\n // A `__proto__` / `constructor` / `toString` key must miss (→ undefined),\n // not read an inherited Object.prototype member into a `naturalKey`.\n return Object.hasOwn(current, token.key) ? current[token.key] : undefined;\n }\n return isUnknownArray(current) ? current[token.index] : undefined;\n}\n\n/** Resolve a dot/bracket path against `root`, or `undefined` on any miss. */\nexport function getByPath(root: unknown, path: string): unknown {\n let current: unknown = root;\n for (const token of tokenize(path)) {\n current = step(current, token);\n }\n return current;\n}\n\n/** Locate the array of raw items for a fetch. With `itemsAt` set, walk\n * to it; without it, the response itself must be the array. Non-arrays\n * yield `[]` so a malformed response is a no-op, not a crash. */\nexport function getItemsArray(root: unknown, itemsAt: string | undefined): unknown[] {\n if (!itemsAt) return Array.isArray(root) ? root : [];\n const value = getByPath(root, itemsAt);\n return Array.isArray(value) ? value : [];\n}\n","// Project one raw retrieved item (a parsed RSS entry, or a JSON object)\n// into a CollectionItem whose keys match the schema's fields, using the\n// declarative `ingest.map`. Also derives the record's id.\n//\n// Id rule: a collection record's filename IS its primaryKey value, and\n// that value must be a safe slug. Feed-native keys (RSS guids/URLs,\n// ISO datetimes) usually are NOT slug-safe, so we slugify the natural\n// key into a deterministic, stable id — same natural key → same id, so\n// re-fetches upsert in place. The natural key comes from the mapped\n// primaryKey value, then `ingest.idFrom`, then a content hash.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { createHash } from \"node:crypto\";\nimport { getByPath } from \"./pathResolver.js\";\nimport type { CollectionItem, CollectionSchema } from \"../../collection/index.js\";\nimport type { DeclarativeIngestSpec } from \"../ingestTypes.js\";\n\nfunction asKeyString(value: unknown): string | null {\n if (typeof value === \"string\" && value.trim().length > 0) return value.trim();\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return null;\n}\n\n/** Collapse an XML element object that carries body text alongside\n * attributes (`{ \"#text\": \"v\", \"@_x\": \"...\" }`) or CDATA to its text.\n * Generic — the host knows no field names, only this fast-xml-parser\n * shape — so a tag like `<guid isPermaLink=\"false\">id</guid>` maps to\n * its text without the caller needing to write `.#text`. */\nfunction unwrapTextNode(value: unknown): unknown {\n if (isRecord(value)) {\n if (typeof value[\"#text\"] === \"string\") return value[\"#text\"];\n if (typeof value[\"#cdata\"] === \"string\") return value[\"#cdata\"];\n }\n return value;\n}\n\n/** Normalize a mapped value generically — driven by the SCHEMA's declared\n * field type, never by the source field name. Today: unwrap XML text\n * nodes, and coerce anything mapped into a `date` field to a `YYYY-MM-DD`\n * civil date (the collection `date` type + calendar are day-granularity\n * and parse strictly — a full RFC-3339 timestamp would be rejected). */\nfunction normalizeValue(value: unknown, fieldType: string | undefined): unknown {\n const unwrapped = unwrapTextNode(value);\n if (fieldType === \"date\" && typeof unwrapped === \"string\") {\n const millis = Date.parse(unwrapped);\n if (Number.isFinite(millis)) return new Date(millis).toISOString().slice(0, 10);\n }\n return unwrapped;\n}\n\n/** Slugify a natural key into a stable, filename-safe id. Short, safe\n * keys pass through (lowercased); long or unsafe keys collapse to a\n * hash so the filename stays bounded and valid. */\nfunction toSafeId(natural: string): string {\n const collapsed = natural\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\");\n // eslint-disable-next-line sonarjs/super-linear-regex -- anchored hyphen trim, linear, no catastrophic backtracking\n const slug = collapsed.replace(/^-+|-+$/g, \"\");\n if (slug.length > 0 && slug.length <= 80) return slug;\n const hash = createHash(\"sha256\")\n .update(natural || \"item\", \"utf-8\")\n .digest(\"hex\")\n .slice(0, 16);\n return slug.length > 80 ? `${slug.slice(0, 60)}-${hash}` : `feed-${hash}`;\n}\n\nfunction naturalKey(record: CollectionItem, rawItem: unknown, ingest: DeclarativeIngestSpec, schema: CollectionSchema): string {\n const fromMapped = asKeyString(record[schema.primaryKey]);\n if (fromMapped) return fromMapped;\n if (ingest.idFrom) {\n const fromId = asKeyString(unwrapTextNode(getByPath(rawItem, ingest.idFrom)));\n if (fromId) return fromId;\n }\n return JSON.stringify(record);\n}\n\n/** Build a record from a raw fetched item (a parsed RSS/Atom element or a\n * JSON object) using `ingest.map`. Each source path is resolved against\n * the raw item and normalized per the target field's declared type. The\n * returned record's primaryKey is set to the derived safe id (so it\n * doubles as the filename). */\nexport function projectRecord(rawItem: unknown, ingest: DeclarativeIngestSpec, schema: CollectionSchema): CollectionItem {\n const record: CollectionItem = {};\n for (const [targetField, sourcePath] of Object.entries(ingest.map)) {\n const value = normalizeValue(getByPath(rawItem, sourcePath), schema.fields?.[targetField]?.type);\n if (value !== undefined) record[targetField] = value;\n }\n record[schema.primaryKey] = toSafeId(naturalKey(record, rawItem, ingest, schema));\n return record;\n}\n","// RSS / Atom retriever. Fetches the feed, parses it, and projects each\n// item's RAW parsed XML element through `ingest.map`. The map's source\n// paths are the item's own tags/attributes (e.g. `title`, `pubDate`,\n// `enclosure.@_url`, `itunes:duration`) — the host hard-codes no field\n// list; the caller inspects the feed and maps what it carries.\n\nimport { fetchText } from \"../fetch/httpClient.js\";\nimport { parseFeed } from \"../fetch/rssParser.js\";\nimport { projectRecord } from \"../projectItem.js\";\nimport { registerRetriever, type RetrieveFn } from \"./index.js\";\n\nconst retrieveRss: RetrieveFn = async (ingest, schema) => {\n const body = await fetchText(ingest.url);\n const feed = parseFeed(body);\n if (!feed) return { items: [], cursor: {} };\n const items = feed.items.map((item) => projectRecord(item.raw, ingest, schema));\n return { items, cursor: {} };\n};\n\n// Atom shares the same parser + projection path.\nregisterRetriever(\"rss\", retrieveRss);\nregisterRetriever(\"atom\", retrieveRss);\n\nexport { retrieveRss };\n","// Generic JSON-API retriever. Fetches JSON, walks `ingest.itemsAt` to\n// the array of raw items, and projects each through `ingest.map` (whose\n// source paths are dot/bracket paths into each raw item).\n\nimport { fetchJson } from \"../fetch/httpClient.js\";\nimport { getItemsArray } from \"../pathResolver.js\";\nimport { projectRecord } from \"../projectItem.js\";\nimport { registerRetriever, type RetrieveFn } from \"./index.js\";\n\nconst retrieveHttpJson: RetrieveFn = async (ingest, schema) => {\n const json = await fetchJson(ingest.url);\n const rawItems = getItemsArray(json, ingest.itemsAt);\n const items = rawItems.map((raw) => projectRecord(raw, ingest, schema));\n return { items, cursor: {} };\n};\n\nregisterRetriever(\"http-json\", retrieveHttpJson);\n\nexport { retrieveHttpJson };\n","// Per-collection retrieval state — when we last fetched/dispatched, the\n// retriever's cursor (for incremental fetches), and a consecutive-failure\n// counter. NOT committed to git. Location depends on the collection's source:\n// feeds keep it alongside the schema at `<workspace>/feeds/<slug>/_state.json`;\n// skill-backed collections with `ingest.kind: \"agent\"` store it at\n// `<workspace>/data/ingest-state/<slug>.json` (a schema-less `feeds/<slug>/`\n// dir would confuse feed discovery, and `_state.json` must never live in a\n// collection's dataDir where `listItems` would read it as a record).\n// Deliberately minimal: the legacy `sources` tree carries richer backoff\n// state, but the engine starts simple and grows on real need.\n\nimport { isErrorWithCode, isRecord } from \"@mulmoclaude/common\";\nimport { mkdir, readFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport type { CollectionSource } from \"../../collection/index.js\";\nimport { log, requireFeedsHost } from \"./host.js\";\nimport { feedStatePath, ingestStatePath } from \"../paths.js\";\n\n/** Minimal shape needed to locate a collection's state file. `LoadedCollection`\n * satisfies it. */\nexport interface StateTarget {\n slug: string;\n source: CollectionSource;\n}\n\n/** Resolve the on-disk state file for a collection, branching on source. */\nfunction stateFilePath(target: StateTarget, workspaceRoot: string): string {\n return target.source === \"feed\" ? feedStatePath(target.slug, workspaceRoot) : ingestStatePath(target.slug, workspaceRoot);\n}\n\nexport interface FeedState {\n slug: string;\n /** ISO timestamp of the last successful fetch (declarative) or dispatch\n * (agent ingest), or null if never. */\n lastFetchedAt: string | null;\n /** Free-form retriever cursor (e.g. last-seen id / etag). */\n cursor: Record<string, string>;\n /** Consecutive failed fetches/runs; reset to 0 on success. */\n consecutiveFailures: number;\n /** Agent ingest only: the notifier entry id of the active \"refresh failed\"\n * bell, so a later success can clear exactly that entry. Absent when no\n * failure bell is showing. */\n failureBellId?: string;\n}\n\nexport function defaultFeedState(slug: string): FeedState {\n return { slug, lastFetchedAt: null, cursor: {}, consecutiveFailures: 0 };\n}\n\n/** The stored cursor, keeping only the string entries its type promises. A\n * hand-edited state file can hold anything; a non-string entry is unreadable\n * to every retriever, so carrying it forward would only re-persist junk. */\nfunction readCursor(value: unknown): Record<string, string> {\n if (!isRecord(value)) return {};\n return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === \"string\"));\n}\n\nfunction normalizeState(slug: string, parsed: Record<string, unknown>): FeedState {\n const base = defaultFeedState(slug);\n const cursor = parsed.cursor === undefined ? base.cursor : readCursor(parsed.cursor);\n return {\n slug,\n lastFetchedAt: typeof parsed.lastFetchedAt === \"string\" ? parsed.lastFetchedAt : base.lastFetchedAt,\n cursor,\n consecutiveFailures: typeof parsed.consecutiveFailures === \"number\" ? parsed.consecutiveFailures : base.consecutiveFailures,\n ...(typeof parsed.failureBellId === \"string\" ? { failureBellId: parsed.failureBellId } : {}),\n };\n}\n\n/** Read a collection's retrieval state, tolerating a missing file (first run →\n * default). The state path branches on `target.source`. */\nexport async function readFeedState(workspaceRoot: string, target: StateTarget): Promise<FeedState> {\n const { slug } = target;\n try {\n const raw = await readFile(stateFilePath(target, workspaceRoot), \"utf-8\");\n const parsed: unknown = JSON.parse(raw);\n // A non-object state file has no fields to read, so it normalises to the\n // same default `normalizeState` would produce from an empty record.\n return normalizeState(slug, isRecord(parsed) ? parsed : {});\n } catch (err) {\n if (!isErrorWithCode(err) || err.code !== \"ENOENT\") {\n log.warn(\"feeds\", \"failed to read feed state, using default\", { slug, error: String(err) });\n }\n return defaultFeedState(slug);\n }\n}\n\n/** Persist a collection's retrieval state atomically (creating the parent dir\n * if needed). The state path branches on `target.source`. */\nexport async function writeFeedState(workspaceRoot: string, target: StateTarget, state: FeedState): Promise<void> {\n const file = stateFilePath(target, workspaceRoot);\n await mkdir(path.dirname(file), { recursive: true });\n await requireFeedsHost().writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\\n`);\n}\n","// Agent-performed ingest (`ingest.kind: \"agent\"`). On schedule (or a manual\n// Refresh), the host seeds a hidden background worker — origin `system`, so\n// it never appears in the user's session list — in the collection's declared\n// role with its template + a summary of every record, and the worker edits the\n// records itself via the collections io layer. The host stays domain-free:\n// everything stock-specific (or whatever the collection does) is prose in the\n// template.\n//\n// DI seam: the worker is launched through `FeedsHost.spawnWorker`, injected at\n// boot via `configureFeedsHost`, not imported directly — this module ships in a\n// shared package and must not reach into any host's session/routes layer.\n\nimport { listItems, promptPathsFor, readSkillTemplate, buildCollectionActionSeedPrompt, type LoadedCollection } from \"../../collection/server/index.js\";\nimport { publish as publishNotifier, clear as clearNotifier } from \"../../notifier/index.js\";\nimport { log, requireFeedsHost, type AgentWorkerResult, type AgentWorkerRunner } from \"./host.js\";\nimport { readFeedState, writeFeedState, type FeedState } from \"./state.js\";\nimport { AGENT_INGEST_KIND } from \"../ingestTypes.js\";\nimport type { RefreshResult } from \"./refreshResult.js\";\n\nexport type { AgentWorkerResult, AgentWorkerRunner } from \"./host.js\";\n\n/** The injected worker launcher, or null if the host was never configured.\n * Read non-throwingly so the failure-isolated contract holds (an unconfigured\n * host becomes an `errors` entry, not a thrown exception). */\nfunction workerRunnerOrNull(): AgentWorkerRunner | null {\n try {\n return requireFeedsHost().spawnWorker;\n } catch {\n return null;\n }\n}\n\nfunction result(slug: string, patch: Partial<RefreshResult>): RefreshResult {\n return { slug, written: 0, removed: 0, errors: [], ...patch };\n}\n\n/** Dispatch one agent-ingest refresh: build the seed, launch a worker, and (on a\n * successful launch) stamp `lastFetchedAt` with the DISPATCH time — not\n * completion. That's what gates the due-loop, so a slow worker can't cause a\n * double-dispatch. `opts.hidden` (default true) runs an invisible system worker\n * for SCHEDULED refreshes; a MANUAL Refresh passes `hidden:false` for a visible,\n * debuggable session. Failure-isolated: never throws; cap-miss / template-miss /\n * launch error leave state untouched and report via `errors`. */\nexport async function refreshViaAgent(workspaceRoot: string, collection: LoadedCollection, opts?: { hidden?: boolean }): Promise<RefreshResult> {\n const hidden = opts?.hidden ?? true;\n const { slug } = collection;\n const { ingest } = collection.schema;\n if (ingest?.kind !== AGENT_INGEST_KIND) return result(slug, { errors: [\"collection has no agent ingest config\"] });\n const workerRunner = workerRunnerOrNull();\n if (!workerRunner) return result(slug, { errors: [\"agent ingest worker runner not configured\"] });\n\n const template = await readSkillTemplate(collection.skillDir, ingest.template);\n if (template === null) return result(slug, { errors: [`ingest template '${ingest.template}' could not be read`] });\n\n const items = await listItems(collection.dataDir, { workspaceRoot });\n // Thread the canonical, host-normalized paths through the seed so the\n // template can pass `--out-dir data/collections/<slug>/items` (etc.) into\n // any bundled script instead of relying on the script's `argparse` default —\n // which was written for the author's pre-import layout, NOT the R3-\n // normalized location the host actually reads records from. Fixes #1891.\n const message = buildCollectionActionSeedPrompt(items, collection.schema, template, promptPathsFor(collection, workspaceRoot));\n\n // The runner is injected, so guard its promise here to honour the\n // failure-isolated contract (a rejection must become an `errors` entry, not\n // escape into the scheduler loop / route handler).\n let launch: AgentWorkerResult;\n try {\n launch = await workerRunner({\n message,\n roleId: ingest.role,\n hidden,\n // The seed prompt addresses records root-relatively (`promptPathsFor`), so\n // the root travels WITH it: a multi-root host spawns the worker there. A\n // single-workspace host ignores the field — same root either way.\n workspaceRoot,\n // A visible manual run is watched directly — only hidden runs get the\n // completion hook (failure bell + consecutiveFailures); `finalizeRun` only\n // fires it for system-origin sessions anyway.\n onComplete: hidden ? (outcome) => recordOutcome(workspaceRoot, collection, outcome.didError) : undefined,\n });\n } catch (err) {\n log.warn(\"feeds\", \"agent ingest worker launch threw\", { slug, error: String(err) });\n return result(slug, { errors: [String(err)], dispatched: false });\n }\n if (!launch.ok) {\n // Cap-miss or launch error: do NOT touch lastFetchedAt — the next due tick\n // (or manual Refresh) redials. Surface it so a manual refresh reads honest.\n log.info(\"feeds\", \"agent ingest dispatch skipped\", { slug, error: launch.error });\n return result(slug, { errors: [launch.error], dispatched: false });\n }\n\n const state = await readFeedState(workspaceRoot, collection);\n await writeFeedState(workspaceRoot, collection, { ...state, lastFetchedAt: new Date().toISOString() });\n log.info(\"feeds\", \"agent ingest dispatched\", { slug, role: ingest.role, chatId: launch.chatId, hidden, items: items.length });\n // Surface the chatId only for a visible run so the client can open it; a hidden\n // session is excluded from every listing and can't be navigated to anyway.\n return result(slug, { dispatched: true, ...(hidden ? {} : { chatId: launch.chatId }) });\n}\n\n/** Completion hook: reconcile failure tracking when a dispatched worker\n * finishes. On error, bump `consecutiveFailures` and raise a single failure\n * bell (deduped via the persisted `failureBellId`). On success, reset the\n * counter and clear any standing bell. Best-effort + failure-isolated — it\n * runs inside the agent run's teardown, so it must never throw. */\nasync function recordOutcome(workspaceRoot: string, collection: LoadedCollection, didError: boolean): Promise<void> {\n const state = await readFeedState(workspaceRoot, collection);\n const next: FeedState = didError ? await onWorkerError(state, collection) : await onWorkerSuccess(state, collection);\n await writeFeedState(workspaceRoot, collection, next);\n}\n\nasync function onWorkerError(state: FeedState, collection: LoadedCollection): Promise<FeedState> {\n const next: FeedState = { ...state, consecutiveFailures: state.consecutiveFailures + 1 };\n log.warn(\"feeds\", \"agent ingest worker failed\", { slug: collection.slug, consecutiveFailures: next.consecutiveFailures });\n // Raise a single bell on the first failure; a standing one isn't piled onto.\n if (!state.failureBellId) {\n try {\n const { id } = await publishNotifier({\n pluginPkg: \"host\",\n severity: \"nudge\",\n lifecycle: \"fyi\",\n title: \"Collection refresh failed\",\n body: `“${collection.schema.title}” (${collection.slug}) couldn't refresh. Open it to retry.`,\n navigateTarget: `/collections/${collection.slug}`,\n });\n next.failureBellId = id;\n } catch (err) {\n log.warn(\"feeds\", \"failed to publish ingest failure bell\", { slug: collection.slug, error: String(err) });\n }\n }\n return next;\n}\n\nasync function onWorkerSuccess(state: FeedState, collection: LoadedCollection): Promise<FeedState> {\n log.info(\"feeds\", \"agent ingest worker completed\", { slug: collection.slug });\n if (state.failureBellId) {\n await clearNotifier(state.failureBellId).catch((err) =>\n log.warn(\"feeds\", \"failed to clear ingest failure bell\", { slug: collection.slug, error: String(err) }),\n );\n }\n const next: FeedState = { ...state, consecutiveFailures: 0 };\n delete next.failureBellId;\n return next;\n}\n","// Retrieval engine: fetch a feed, upsert its records into the\n// collection's data dir (keyed by primaryKey, so re-fetches replace in\n// place / accumulate by id), and persist per-feed state. Per-feed\n// failures are isolated — `refreshOne` never throws; `refreshDue`\n// processes feeds sequentially to stay gentle on remote hosts (the\n// fetch client does no rate-limiting yet).\n\nimport { discoverCollections, storeFor, type CollectionStore, type LoadedCollection } from \"../../collection/server/index.js\";\nimport type { CollectionItem, CollectionSchema } from \"../../collection/index.js\";\nimport { mergeIntoExisting } from \"../../collection/core/project.js\";\nimport { log, requireFeedsHost } from \"./host.js\";\nimport { getRetriever } from \"./retrievers/index.js\";\nimport \"./retrievers/registerAll.js\";\nimport { readFeedState, writeFeedState, type FeedState } from \"./state.js\";\nimport { DEFAULT_FEED_MAX_ITEMS, AGENT_INGEST_KIND, type FeedSchedule, type IngestSpec } from \"../ingestTypes.js\";\nimport { refreshViaAgent } from \"./agentIngest.js\";\nimport type { RefreshResult } from \"./refreshResult.js\";\n\nexport type { RefreshResult } from \"./refreshResult.js\";\n\n// Refresh cadence anchors (ms). Inlined — the engine needs only these two and\n// must stay free of host-side time-constant modules.\nconst ONE_HOUR_MS = 3_600_000;\nconst ONE_DAY_MS = 86_400_000;\n\n/** A feed's retrieval config, or undefined. `CollectionSchema.ingest` and the\n * engine's `IngestSpec` are both `IngestZ`'s inferred union, so this is a\n * rename, not a narrowing — the engine keeps its own vocabulary at the seam. */\nfunction feedIngest(schema: CollectionSchema): IngestSpec | undefined {\n return schema.ingest;\n}\n\n/** A feed's store. `write`/`delete` are always present (discovery rejects\n * `ingest` on read-only dataSource schemas); null only as defense in depth. */\nfunction writableFeedStore(\n workspaceRoot: string,\n feed: LoadedCollection,\n): (CollectionStore & { write: NonNullable<CollectionStore[\"write\"]>; delete: NonNullable<CollectionStore[\"delete\"]> }) | null {\n const store = storeFor(feed, { workspaceRoot });\n const { write, delete: remove } = store;\n if (!write || !remove) {\n log.warn(\"feeds\", \"feed store is read-only — refresh skipped\", { slug: feed.slug });\n return null;\n }\n return { ...store, write, delete: remove };\n}\n\n/** Fields the feed itself produces: what it actually returned this run, the\n * declared map's targets, and the primary key the retriever derives. Anything\n * else on a record got there some other way — the user.\n *\n * Both sources, because neither alone is enough. The in-tree retrievers project\n * through `map`, so it describes them exactly — but `registerRetriever` lets a\n * host add one that returns whatever it likes, and only the observed keys catch\n * that. Conversely a run that fetched nothing has no keys to observe, and only\n * `map` keeps the cap working through it. */\nexport const ingestedFields = (items: readonly CollectionItem[], mappedTargets: readonly string[], primaryKey: string): ReadonlySet<string> =>\n new Set([primaryKey, ...mappedTargets, ...items.flatMap((item) => Object.keys(item))]);\n\n/** The collection fields a declarative `map` names. Read as `unknown` and\n * narrowed here rather than through the zod-derived union, whose `map` does not\n * survive the `kind` narrowing well enough to index safely. Only the keys are\n * wanted, so the value type never has to be reconstructed. */\nexport function mappedTargetsOf(ingest: IngestSpec | undefined): string[] {\n const map: unknown = ingest === undefined || ingest.kind === AGENT_INGEST_KIND ? undefined : ingest.map;\n return typeof map === \"object\" && map !== null ? Object.keys(map) : [];\n}\n\n/** Whether a record carries content the feed did not put there.\n *\n * Only a NON-EMPTY value counts. A record saved through the UI can carry every\n * declared field as an empty string, and treating those as local content would\n * make every touched record un-prunable — the cap would quietly stop working.\n *\n * An edit to a MAPPED field deliberately does not count: a feed is\n * one-directional, so the next refresh writes over it by design. The local\n * COLUMN is the durable half, and the half worth protecting. */\nexport function hasLocalContent(item: CollectionItem, ingested: ReadonlySet<string>): boolean {\n return Object.entries(item).some(([field, value]) => !ingested.has(field) && value !== undefined && value !== null && value !== \"\");\n}\n\n/** The stored record, or null when it cannot be read.\n *\n * `readItem` answers null for a MISSING record but THROWS on unparsable JSON\n * or an IO error. Letting that escape would fail the whole refresh over one bad\n * file — and write nothing at all, since the loop aborts partway. Before the\n * merge landed, the ingest simply overwrote such a file and the feed healed\n * itself; treating \"unreadable\" as \"absent\" keeps that. (Observed during Claude\n * review; both review bots were rate-limited on this PR.) */\nasync function readableRecord(store: { read: CollectionStore[\"read\"] }, feed: LoadedCollection, itemId: string): Promise<CollectionItem | null> {\n try {\n return await store.read(itemId);\n } catch (error) {\n log.warn(\"feeds\", \"unreadable feed record — the retrieved item will replace it\", { slug: feed.slug, itemId, error: String(error) });\n return null;\n }\n}\n\nasync function upsertItems(workspaceRoot: string, feed: LoadedCollection, items: CollectionItem[]): Promise<number> {\n const store = writableFeedStore(workspaceRoot, feed);\n if (!store) return 0;\n let written = 0;\n for (const item of items) {\n const itemId = item[feed.schema.primaryKey];\n if (typeof itemId !== \"string\" || itemId.length === 0) continue;\n // Laid OVER the record rather than replacing it. `writeItem` writes the\n // whole file, so writing the retrieved item alone deletes every column the\n // ingest does not produce — the note the user put beside the article. The\n // Google Calendar pull learned this in #2620; the feeds ingest never did\n // (#2696).\n const result = await store.write(itemId, mergeIntoExisting(await readableRecord(store, feed, itemId), item));\n if (result.kind === \"ok\") written += 1;\n else log.warn(\"feeds\", \"feed item write skipped\", { slug: feed.slug, itemId, kind: result.kind });\n }\n return written;\n}\n\n/** The schema's first `date` field, used to order records for the\n * maxItems cap. Null when the schema declares none. */\nfunction firstDateField(schema: CollectionSchema): string | null {\n for (const [key, spec] of Object.entries(schema.fields)) {\n if (spec.type === \"date\") return key;\n }\n return null;\n}\n\n// Epoch ms for a record's date value; missing / unparseable sorts oldest.\nfunction recordTime(item: CollectionItem, field: string): number {\n const value = item[field];\n const millis = typeof value === \"string\" ? Date.parse(value) : NaN;\n return Number.isFinite(millis) ? millis : Number.NEGATIVE_INFINITY;\n}\n\n/** Enforce `ingest.maxItems` (default 100): keep the newest N records by\n * the schema's date field, delete the rest. No-op when the cap is 0/absent\n * of a date field, or when under the cap. Returns the number deleted. */\nasync function pruneFeed(workspaceRoot: string, feed: LoadedCollection, fetched: readonly CollectionItem[]): Promise<number> {\n const ingest = feedIngest(feed.schema);\n // maxItems is a declarative-feed concept; agent ingest manages its own record\n // set, so it's never pruned here (and `refreshOne` never calls pruneFeed for it).\n const cap = (ingest && ingest.kind !== AGENT_INGEST_KIND ? ingest.maxItems : undefined) ?? DEFAULT_FEED_MAX_ITEMS;\n if (cap <= 0) return 0;\n const dateField = firstDateField(feed.schema);\n if (!dateField) {\n log.warn(\"feeds\", \"maxItems prune skipped: schema has no date field to order by\", { slug: feed.slug });\n return 0;\n }\n const store = writableFeedStore(workspaceRoot, feed);\n if (!store) return 0;\n const items = await store.list();\n if (items.length <= cap) return 0;\n const ingested = ingestedFields(fetched, mappedTargetsOf(ingest), feed.schema.primaryKey);\n const aged = [...items].sort((left, right) => recordTime(right, dateField) - recordTime(left, dateField)).slice(cap);\n // Deleting a record the user annotated is the same silent loss the merge above\n // prevents, one step later: the note survives every refresh and then goes when\n // the article ages out. Kept records can push a feed past its cap, so say so\n // rather than letting the number drift unexplained (#2696).\n const stale = aged.filter((item) => !hasLocalContent(item, ingested));\n const kept = aged.length - stale.length;\n if (kept > 0) log.info(\"feeds\", \"keeping aged-out records that carry local content\", { slug: feed.slug, kept, cap });\n let removed = 0;\n for (const item of stale) {\n const itemId = item[feed.schema.primaryKey];\n if (typeof itemId !== \"string\" || itemId.length === 0) continue;\n if ((await store.delete(itemId)).kind === \"ok\") removed += 1;\n }\n if (removed > 0) log.info(\"feeds\", \"pruned old feed records\", { slug: feed.slug, removed, cap });\n return removed;\n}\n\n/** Fetch one feed now, upsert its records, then enforce the maxItems cap.\n * Failure-isolated: returns an errors array rather than throwing. */\nexport async function refreshOne(workspaceRoot: string, feed: LoadedCollection, opts?: { hidden?: boolean }): Promise<RefreshResult> {\n const { slug } = feed;\n const ingest = feedIngest(feed.schema);\n if (!ingest) return { slug, written: 0, removed: 0, errors: [\"collection has no ingest config\"] };\n // `agent` ingest dispatches a worker instead of fetching: branch off BEFORE\n // the retriever registry (which only knows declarative kinds). `opts.hidden`\n // (manual Refresh passes false) only affects the agent path; declarative\n // feeds ignore it.\n if (ingest.kind === AGENT_INGEST_KIND) return refreshViaAgent(workspaceRoot, feed, opts);\n const retriever = getRetriever(ingest.kind);\n if (!retriever) return { slug, written: 0, removed: 0, errors: [`no retriever registered for kind '${ingest.kind}'`] };\n const state = await readFeedState(workspaceRoot, feed);\n try {\n const result = await retriever(ingest, feed.schema, state);\n const written = await upsertItems(workspaceRoot, feed, result.items);\n await writeFeedState(workspaceRoot, feed, { ...state, lastFetchedAt: new Date().toISOString(), cursor: result.cursor, consecutiveFailures: 0 });\n const removed = await pruneFeed(workspaceRoot, feed, result.items);\n log.info(\"feeds\", \"feed refreshed\", { slug, written, removed, fetched: result.items.length });\n return { slug, written, removed, errors: [] };\n } catch (error) {\n await writeFeedState(workspaceRoot, feed, { ...state, consecutiveFailures: state.consecutiveFailures + 1 });\n const message = String(error);\n log.warn(\"feeds\", \"feed refresh failed\", { slug, error: message });\n return { slug, written: 0, removed: 0, errors: [message] };\n }\n}\n\nfunction dueIntervalMs(schedule: FeedSchedule): number {\n switch (schedule) {\n case \"daily\":\n return ONE_DAY_MS;\n case \"weekly\":\n return 7 * ONE_DAY_MS;\n default:\n return ONE_HOUR_MS;\n }\n}\n\n/** True iff a `daily` schedule with a UTC `atHour` anchor is due. The system\n * task ticks hourly, so \"due\" means: we're in the anchor hour AND we haven't\n * already run in roughly the last day. The 23 h floor (not 24 h) tolerates the\n * tick landing a few minutes earlier than the previous run, while staying well\n * above 1 h so a second tick in the same anchor hour can't double-fire. */\nfunction isDailyAtHourDue(now: Date, atHour: number, lastFetchedAt: string | null): boolean {\n if (now.getUTCHours() !== atHour) return false;\n if (!lastFetchedAt) return true;\n const elapsed = now.getTime() - Date.parse(lastFetchedAt);\n if (!Number.isFinite(elapsed)) return true;\n return elapsed >= 23 * ONE_HOUR_MS;\n}\n\n/** True iff a collection is due to refresh given its schedule + last run.\n * `on-demand` is never auto-due; a `daily` schedule with `atHour` anchors to\n * that UTC hour, otherwise cadence is elapsed-based. */\nfunction isFeedDue(feed: LoadedCollection, state: FeedState): boolean {\n const ingest = feedIngest(feed.schema);\n const schedule = ingest?.schedule;\n if (!schedule || schedule === \"on-demand\") return false;\n if (schedule === \"daily\" && typeof ingest?.atHour === \"number\") {\n return isDailyAtHourDue(new Date(), ingest.atHour, state.lastFetchedAt);\n }\n if (!state.lastFetchedAt) return true;\n const elapsed = Date.now() - Date.parse(state.lastFetchedAt);\n if (!Number.isFinite(elapsed)) return true;\n return elapsed >= dueIntervalMs(schedule);\n}\n\n/** Refresh every collection whose ingest schedule says it's due — declarative\n * feeds AND skill-backed collections with `ingest.kind: \"agent\"`. Called by the\n * hourly system task. Sequential + failure-isolated. */\nexport async function refreshDue(workspaceRoot: string = requireFeedsHost().workspaceRoot): Promise<RefreshResult[]> {\n const all = await discoverCollections({ workspaceRoot });\n const withIngest = all.filter((collection) => collection.schema.ingest);\n const results: RefreshResult[] = [];\n for (const collection of withIngest) {\n // Isolate per-collection failures: `readFeedState`/`refreshOne` (esp. the\n // agent path) can throw, and one bad collection must not abort the whole\n // due-loop. Capture it as an errors result and move on.\n try {\n const state = await readFeedState(workspaceRoot, collection);\n if (!isFeedDue(collection, state)) continue;\n results.push(await refreshOne(workspaceRoot, collection));\n } catch (error) {\n log.warn(\"feeds\", \"scheduled refresh failed for collection\", { slug: collection.slug, error: String(error) });\n results.push({ slug: collection.slug, written: 0, removed: 0, errors: [String(error)] });\n }\n }\n return results;\n}\n","// Shareable registration for the hourly \"refresh due collections\" task.\n// Both MulmoClaude (via `initScheduler`, with persistence + catch-up) and a\n// standalone MulmoTerminal/MulmoBooks (via a bare task-manager `registerTask`)\n// register the SAME definition through this factory, so the id/schedule/run\n// can't drift between hosts. See plans/done/feat-shareable-feed-refresh-registration.md.\nimport { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from \"@receptron/task-scheduler\";\nimport type { SystemTaskDef } from \"../../scheduler/adapter.js\";\nimport { canonicalRoot } from \"../../files/root.js\";\nimport { refreshDue } from \"./engine.js\";\n\n// Id kept stable so the scheduler-state row isn't orphaned across hosts/renames.\nexport const FEED_REFRESH_TASK_ID = \"system:feed-refresh\";\n\n/** The task id for a given root. Root-less (the configured workspace) keeps the\n * bare, historical id, so a single-workspace host's persisted scheduler state\n * keeps matching. A per-root registration gets its own id — a task id is the\n * scheduler's primary key, so N roots registered under one id would collide\n * and only the last would ever run. */\nexport function feedRefreshTaskId(workspaceRoot?: string): string {\n // Canonical: `/proj` and `/proj/` are one project, so they must resolve to\n // one task id. Registering both spellings otherwise creates two jobs over one\n // tree, each with its own persisted state, refreshing the same feeds twice.\n return workspaceRoot === undefined ? FEED_REFRESH_TASK_ID : `${FEED_REFRESH_TASK_ID}:${canonicalRoot(workspaceRoot)}`;\n}\n// Single source of truth for the default refresh cadence (one hour). Core has no\n// shared time module — `server/utils/time.ts` is host-only — so the named export\n// IS the constant; hosts override via `feedRefreshTaskDef({ intervalMs })`.\nexport const DEFAULT_FEED_REFRESH_INTERVAL_MS = 60 * 60 * 1000;\n\n/**\n * The hourly \"refresh due collections\" task, shared by every host.\n *\n * Drives ALL scheduled ingest: declarative feeds (RSS / JSON) fetch directly,\n * and skill-backed `ingest.kind: \"agent\"` collections dispatch a hidden worker.\n *\n * Returns the rich `SystemTaskDef` rather than registering it — each host keeps\n * its own registration mechanism and applies its own schedule overrides first.\n * A host driving a bare task-manager can still `registerTask(...)` it; the extra\n * `missedRunPolicy` field is simply ignored there.\n *\n * ROOT-PARAMETERISED. Called with no `workspaceRoot` it refreshes the host's\n * configured workspace under the historical task id — unchanged, and what a\n * single-workspace host wants. A multi-root host registers ONE def per root it\n * wants refreshed on a schedule; each gets its own id (see `feedRefreshTaskId`)\n * so the registrations do not overwrite each other. A root that is never\n * registered is never refreshed on a schedule — its feeds update only on an\n * explicit call — so registering per project is the host's decision to make,\n * deliberately, rather than something this module can infer.\n *\n * @param opts.intervalMs per-host interval override (defaults to one hour)\n * @param opts.workspaceRoot the root to refresh; omitted, the configured FeedsHost workspace\n */\nexport function feedRefreshTaskDef(opts?: { workspaceRoot?: string; intervalMs?: number }): SystemTaskDef {\n // Canonicalise ONCE and use that value for the id, the display name and the\n // refresh itself. Deriving only the id from it would leave two registrations\n // for `/proj` and `/proj/` collapsing onto one row whose name and refresh\n // target still named whichever spelling registered last — the id would look\n // deduplicated while the job it ran was a coin flip.\n const root = opts?.workspaceRoot === undefined ? undefined : canonicalRoot(opts.workspaceRoot);\n return {\n id: feedRefreshTaskId(root),\n name: root === undefined ? \"Scheduled collection refresh\" : `Scheduled collection refresh (${root})`,\n description: \"Refresh due collections — fetch declarative feeds + dispatch agent-ingest workers\",\n schedule: { type: SCHEDULE_TYPES.interval, intervalMs: opts?.intervalMs ?? DEFAULT_FEED_REFRESH_INTERVAL_MS },\n missedRunPolicy: MISSED_RUN_POLICIES.runOnce,\n run: () => refreshDue(root).then(() => {}),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["isRecord","isRecord","isRecord","publishNotifier","clearNotifier"],"sources":["../../../src/feeds/server/host.ts","../../../src/feeds/server/registry.ts","../../../src/feeds/server/retrievers/index.ts","../../../../common/dist/ssrf.js","../../../src/feeds/server/fetch/httpClient.ts","../../../src/feeds/server/fetch/rssParser.ts","../../../src/feeds/server/pathResolver.ts","../../../src/feeds/server/projectItem.ts","../../../src/feeds/server/retrievers/rss.ts","../../../src/feeds/server/retrievers/httpJson.ts","../../../src/feeds/server/state.ts","../../../src/feeds/server/agentIngest.ts","../../../src/feeds/server/engine.ts","../../../src/feeds/server/scheduledRefresh.ts"],"sourcesContent":["// Host injection seam for the Feeds engine — mirrors `configureCollectionHost`\n// / `configureScheduler`. The engine is host-agnostic: the workspace root, the\n// logger, an atomic file writer, and the hidden/visible agent-ingest worker\n// launcher are injected once at boot via `configureFeedsHost`. Everything else\n// the engine needs (collection IO, the notifier) is a sibling `@mulmoclaude/core`\n// subpath, imported directly. Both MulmoClaude and MulmoTerminal supply their\n// own host shim.\n\nimport { createForwardingLogger, createHostSlot, type StructuredLogger } from \"../../host/hostSlot.js\";\n\n/** Outcome of launching one hidden/visible agent-ingest worker. `chatId` lets\n * the caller register a completion hook so a failed refresh doesn't die\n * silently. */\nexport type AgentWorkerResult = { ok: true; chatId: string } | { ok: false; error: string };\n\n/** Launches a worker chat. Injected at boot to keep the feeds engine from\n * importing a host's routes/session layer. `hidden` chooses an invisible\n * system worker (scheduled refresh) vs a visible session the user can watch\n * (manual Refresh — debuggable). `onComplete` is a one-shot completion hook\n * (only honoured for hidden workers) so the dispatcher learns success/failure.\n * Returns `ok:false` on the concurrency-cap miss or a launch error — the caller\n * leaves state untouched and retries next tick. */\nexport type AgentWorkerRunner = (args: {\n message: string;\n roleId: string;\n hidden: boolean;\n /** Absolute root this refresh is for — the root `refreshViaAgent` was called\n * with, forwarded because the seed prompt's `dataPath` is RELATIVE to it.\n *\n * A multi-root host must spawn the worker THERE (as its cwd), or the worker\n * resolves `data/collections/<slug>/items` against whatever root it happens\n * to run in and writes another project's records — silently, since both\n * paths exist and neither side errors.\n *\n * Optional so a single-workspace host's runner (MulmoClaude's) can ignore it\n * and behave exactly as before: there is only one root, and it is the one the\n * worker already runs in. */\n workspaceRoot?: string | undefined;\n onComplete?: ((outcome: { didError: boolean }) => void | Promise<void>) | undefined;\n}) => Promise<AgentWorkerResult>;\n\n/** Public alias of the shared `StructuredLogger` — same shape as `CollectionLogger`. */\nexport type FeedsLogger = StructuredLogger;\n\nexport interface FeedsHost {\n /** Absolute workspace root — the default for `refreshDue()` and state paths. */\n workspaceRoot: string;\n /** Host logger. */\n log: FeedsLogger;\n /** Host atomic file writer (state files). */\n writeFileAtomic: (filePath: string, content: string) => Promise<void>;\n /** Launches the agent-ingest worker (was `setAgentWorkerRunner`). */\n spawnWorker: AgentWorkerRunner;\n}\n\nconst hostSlot = createHostSlot<FeedsHost>(\"@mulmoclaude/core/feeds: configureFeedsHost()\");\n\n/** Wire the feeds engine to a host. Call once at startup, before any refresh. */\nexport function configureFeedsHost(host: FeedsHost): void {\n hostSlot.set(host);\n}\n\n/** The configured host, or throw if `configureFeedsHost` was never called. */\nexport function requireFeedsHost(): FeedsHost {\n return hostSlot.get();\n}\n\n/** Test-only: clear the configured host. */\nexport function resetFeedsHostForTesting(): void {\n hostSlot.reset();\n}\n\n/** Forwarding logger so engine modules can `import { log }` without each\n * reaching for `requireFeedsHost().log`. Non-critical: calls before the host\n * is wired are dropped (not thrown), matching the collection/google seams. */\nexport const log: FeedsLogger = createForwardingLogger(() => hostSlot.peek()?.log ?? null);\n","// List the registered data-source feeds. Feeds are CREATED / REMOVED by\n// the agent writing / deleting `feeds/<slug>/schema.json` directly (see\n// config/helps/feeds.md) — the host only discovers + retrieves them.\n// icon / dataPath defaults for agent-authored feed schemas are applied in\n// `collection/server/discovery.ts` (source === \"feed\").\n\nimport { rm } from \"node:fs/promises\";\nimport { discoverCollections, resolveDataDir, safeSlugName, type LoadedCollection } from \"../../collection/server/index.js\";\nimport { log, requireFeedsHost } from \"./host.js\";\nimport { feedDir } from \"../paths.js\";\n\n/** Every registered feed, as a discovered collection (carrying its\n * validated schema, `ingest`, and resolved `dataDir`). */\nexport async function listFeeds(workspaceRoot: string = requireFeedsHost().workspaceRoot): Promise<LoadedCollection[]> {\n const all = await discoverCollections({ workspaceRoot });\n return all.filter((collection) => collection.source === \"feed\");\n}\n\n/** Delete a feed entirely: its records AND its `feeds/<slug>/` directory\n * (schema + state). Idempotent. Host-side only (backs the UI delete\n * button); the agent removes a feed by deleting both directories itself.\n *\n * The records dir is derived from the SLUG (`data/feeds/<slug>`), never\n * from the schema's `dataPath` — feeds are forced into that namespace at\n * discovery, so a malformed/hostile `dataPath` can't redirect this delete\n * at another app's data (e.g. `data/wiki`). `resolveDataDir` also rejects\n * any path that escapes the workspace. */\nexport async function removeFeed(workspaceRoot: string, slug: string): Promise<boolean> {\n const safe = safeSlugName(slug);\n if (safe === null) return false;\n const recordsDir = resolveDataDir(`data/feeds/${safe}`, workspaceRoot);\n try {\n if (recordsDir) await rm(recordsDir, { recursive: true, force: true });\n await rm(feedDir(safe, workspaceRoot), { recursive: true, force: true });\n log.info(\"feeds\", \"feed + records removed\", { slug: safe });\n return true;\n } catch (error) {\n log.warn(\"feeds\", \"feed remove failed\", { slug: safe, error: String(error) });\n return false;\n }\n}\n","// Pluggable retriever registry. Each declarative `ingest.kind` maps to one\n// RetrieveFn that fetches the endpoint and returns projected records.\n// Side-effect registration keeps the engine decoupled from the kinds.\n// The `agent` kind is NOT a retriever (it dispatches a hidden worker before\n// the engine consults this registry); a future `code` kind would register here.\n\nimport type { CollectionItem, CollectionSchema } from \"../../../collection/index.js\";\nimport type { DeclarativeIngestSpec } from \"../../ingestTypes.js\";\nimport type { FeedState } from \"../state.js\";\n\nexport interface RetrieveResult {\n /** Projected records, keyed by primaryKey (the engine upserts them). */\n items: CollectionItem[];\n /** Updated retriever cursor to persist (incremental fetches). */\n cursor: Record<string, string>;\n}\n\n// Declarative-only: the engine branches `agent` ingest off BEFORE looking up a\n// retriever, so a RetrieveFn never sees a non-fetch spec (and rss/http-json can\n// read `ingest.url`/`map` without union narrowing).\nexport type RetrieveFn = (ingest: DeclarativeIngestSpec, schema: CollectionSchema, state: FeedState) => Promise<RetrieveResult>;\n\nconst registry = new Map<string, RetrieveFn>();\n\nexport function registerRetriever(kind: string, retriever: RetrieveFn): void {\n registry.set(kind, retriever);\n}\n\nexport function getRetriever(kind: string): RetrieveFn | undefined {\n return registry.get(kind);\n}\n","// The single SSRF deny-list for the monorepo: address classification and\n// URL-shape checks shared by every guard that fetches model-authored /\n// remote-user-supplied URLs (mastodon bridge urlGuard, core feeds httpClient).\n// Before #2459 the CIDR table was hand-copied per guard and had drifted.\n//\n// Everything here is pure and isomorphic — no `node:` builtins. The parts that\n// need the network (DNS resolution, per-redirect re-checks) stay in each\n// consumer's thin wrapper.\n/* eslint-disable sonarjs/no-hardcoded-ip -- the blocked ranges ARE this module's\n specification; writing them as literals is the point, not an oversight. */\nconst ALLOWED_PROTOCOLS = new Set([\"http:\", \"https:\"]);\nconst BLOCKED_HOSTNAMES = new Set([\"localhost\", \"ip6-localhost\", \"ip6-loopback\"]);\nconst BLOCKED_HOSTNAME_SUFFIXES = [\".localhost\", \".local\", \".internal\"];\n/** IPv4 ranges that must never be fetched, as [firstAddress, prefixLength].\n * The union of the pre-#2459 per-guard tables — removing an entry weakens\n * every guard in the repo at once, so treat edits as security changes. */\nexport const BLOCKED_IPV4_RANGES = [\n [\"0.0.0.0\", 8], // \"this network\"\n [\"10.0.0.0\", 8], // RFC1918\n [\"100.64.0.0\", 10], // CGNAT\n [\"127.0.0.0\", 8], // loopback\n [\"169.254.0.0\", 16], // link-local — includes the 169.254.169.254 metadata endpoint\n [\"172.16.0.0\", 12], // RFC1918\n [\"192.0.0.0\", 24], // IETF protocol assignments\n [\"192.168.0.0\", 16], // RFC1918\n [\"198.18.0.0\", 15], // benchmarking\n [\"224.0.0.0\", 4], // multicast\n [\"240.0.0.0\", 4], // reserved / broadcast\n];\n/** Dotted-decimal IPv4 → unsigned 32-bit value; null for anything else\n * (hex / octal octets, whitespace, wrong part count). */\nexport function ipv4ToInt(address) {\n const parts = address.split(\".\");\n if (parts.length !== 4)\n return null;\n const octets = parts.map((part) => (/^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN));\n if (octets.some((octet) => Number.isNaN(octet) || octet > 255))\n return null;\n return octets.reduce((value, octet) => value * 256 + octet, 0);\n}\nfunction isBlockedIpv4Value(value) {\n return BLOCKED_IPV4_RANGES.some(([base, prefix]) => {\n const baseValue = ipv4ToInt(base);\n if (baseValue === null)\n return false;\n // `>>> 0` keeps the mask unsigned; a /0 shift would be a no-op anyway.\n const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;\n return (value & mask) === (baseValue & mask);\n });\n}\n/** True when this parses as IPv4 AND falls inside a blocked range. Non-IPv4\n * input is false — \"not a blocked literal\", not \"safe\"; a caller that must\n * reject non-IP input entirely does so at its own boundary. */\nexport function isBlockedIpv4(address) {\n const value = ipv4ToInt(address);\n if (value === null)\n return false;\n return isBlockedIpv4Value(value);\n}\n/** `URL.hostname` keeps the brackets on an IPv6 literal (`[::1]`); strip them\n * so the address checks see the bare address. */\nexport function stripIpv6Brackets(hostname) {\n return hostname.replace(/^\\[|\\]$/g, \"\");\n}\n/** The 32-bit value inside an IPv4-mapped IPv6 literal, or null when the\n * address is not v4-mapped. The hex spelling matters: WHATWG URL serializes\n * `[::ffff:127.0.0.1]` as `::ffff:7f00:1`, so matching only the dotted form\n * would wave the mapped loopback through any URL-sourced check. */\nfunction mappedIpv4Value(lower) {\n const dotted = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/.exec(lower)?.[1];\n if (dotted !== undefined)\n return ipv4ToInt(dotted);\n const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);\n const [, high, low] = hex ?? [];\n if (high !== undefined && low !== undefined)\n return Number.parseInt(high, 16) * 0x10000 + Number.parseInt(low, 16);\n return null;\n}\n/** Blocks loopback/unspecified, fc00::/7 unique-local, fe80::/10 link-local,\n * and IPv4-mapped forms of blocked v4 addresses. Mask-based on the leading\n * group — prefix string matching would miss e.g. fe9a:: (inside fe80::/10). */\nexport function isBlockedIpv6(address) {\n const lower = stripIpv6Brackets(address.toLowerCase());\n if (lower === \"::1\" || lower === \"::\")\n return true;\n // IPv4-mapped re-enters the v4 rules.\n const mapped = mappedIpv4Value(lower);\n if (mapped !== null)\n return isBlockedIpv4Value(mapped);\n const [head = \"\"] = lower.split(\":\");\n if (head.length === 0)\n return false;\n const leading = Number.parseInt(head, 16);\n if (Number.isNaN(leading))\n return false;\n if ((leading & 0xfe00) === 0xfc00)\n return true; // fc00::/7 unique-local\n if ((leading & 0xffc0) === 0xfe80)\n return true; // fe80::/10 link-local\n return false;\n}\n/** True when this literal address must never be fetched. Non-IP input is\n * false — see `isBlockedIpv4`. */\nexport function isBlockedIp(address) {\n return address.includes(\":\") ? isBlockedIpv6(address) : isBlockedIpv4(address);\n}\n/** Names that are internal by convention (localhost, *.local, *.internal…),\n * rejected without waiting for DNS to prove it. */\nexport function isBlockedHostname(hostname) {\n const lower = hostname.toLowerCase().replace(/\\.$/, \"\");\n if (BLOCKED_HOSTNAMES.has(lower))\n return true;\n return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => lower.endsWith(suffix));\n}\nfunction parseUrl(raw) {\n try {\n return new URL(raw);\n }\n catch {\n return null;\n }\n}\n/** Parse and apply every check that doesn't need the network: shape, scheme,\n * obviously-internal hostname, and literal addresses. Returns null on reject.\n * Callers must still vet what non-literal hostnames resolve to. */\nexport function parseSafeUrlShape(raw) {\n const url = parseUrl(raw);\n if (url === null)\n return null;\n if (!ALLOWED_PROTOCOLS.has(url.protocol))\n return null;\n const hostname = stripIpv6Brackets(url.hostname);\n if (hostname.length === 0)\n return null;\n if (isBlockedHostname(hostname))\n return null;\n if (isBlockedIp(hostname))\n return null;\n return url;\n}\n","// Minimal HTTP client for feed retrievers. Feed URLs are model-authored /\n// user-supplied, so beyond a User-Agent + timeout this guards against SSRF:\n// every URL (and every redirect hop) is DNS-resolved and rejected if it\n// points at a loopback / private / link-local / cloud-metadata address.\n// The deny-list itself is the repo-wide one in `@mulmoclaude/common/ssrf`\n// (#2459); this wrapper adds the DNS resolution and manual redirect loop.\n// Redirects are followed MANUALLY so a public URL can't 302 to an internal\n// one and bypass the guard. It does NOT do robots.txt / rate limiting (the\n// engine fetches feeds sequentially to stay gentle).\n//\n// Residual risk (same as the mastodon urlGuard): the DNS answer we validate is\n// not necessarily the one the subsequent fetch() uses — it re-resolves — so a\n// rebinding attacker with a very short TTL can still slip through. Pinning the\n// resolved address into the connection would need a custom agent.\n\nimport { lookup } from \"node:dns/promises\";\nimport { isIP } from \"node:net\";\nimport { isBlockedIpv4, isBlockedIpv6, parseSafeUrlShape, stripIpv6Brackets } from \"@mulmoclaude/common/ssrf\";\n\n// Inlined — the client needs only this one time constant and must stay free of\n// host-side time-constant modules.\nconst ONE_SECOND_MS = 1_000;\n\n/** Identifies the bot to site operators. */\nexport const FEED_USER_AGENT = \"MulmoClaude-FeedBot/1.0 (+https://github.com/receptron/mulmoclaude)\";\n\n/** Per-request wall-clock cap so a hung server can't wedge a refresh. */\nexport const DEFAULT_FEED_TIMEOUT_MS = 30 * ONE_SECOND_MS;\n\n/** Cap on redirect hops followed (each re-checked for SSRF). */\nconst MAX_REDIRECTS = 5;\n\n/** DNS answers should be IP literals; anything else is blocked outright. */\nfunction isBlockedResolvedIp(address: string): boolean {\n const kind = isIP(address);\n if (kind === 4) return isBlockedIpv4(address);\n if (kind === 6) return isBlockedIpv6(address);\n return true;\n}\n\nasync function resolveAddresses(host: string): Promise<{ address: string }[]> {\n try {\n return await lookup(host, { all: true });\n } catch (err) {\n throw new Error(`could not resolve host '${host}': ${String(err)}`);\n }\n}\n\n/** Reject non-http(s) URLs, internal-by-convention hostnames, and URLs whose\n * host is / resolves to a private/loopback address (SSRF guard). Throws with\n * a clear reason; returns void on pass. */\nasync function assertFetchableUrl(rawUrl: string): Promise<void> {\n if (!/^https?:\\/\\//i.test(rawUrl)) throw new Error(`refusing non-http(s) URL: ${rawUrl}`);\n const url = parseSafeUrlShape(rawUrl);\n if (url === null) throw new Error(`refusing to fetch a private/loopback/internal address: ${rawUrl}`);\n const host = stripIpv6Brackets(url.hostname);\n // A literal address was already vetted by the shape check above.\n if (isIP(host)) return;\n const blocked = (await resolveAddresses(host)).find((entry) => isBlockedResolvedIp(entry.address));\n if (blocked) throw new Error(`refusing to fetch '${host}' — resolves to a private/loopback address (${blocked.address})`);\n}\n\nasync function fetchOnce(url: string, timeoutMs: number): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(new DOMException(`feed fetch timed out after ${timeoutMs}ms`, \"TimeoutError\")), timeoutMs);\n try {\n return await fetch(url, { headers: { \"User-Agent\": FEED_USER_AGENT }, signal: controller.signal, redirect: \"manual\" });\n } finally {\n clearTimeout(timer);\n }\n}\n\n// Follow redirects manually, re-running the SSRF guard on every hop so a\n// public URL cannot bounce to an internal target.\nasync function fetchGuarded(rawUrl: string, timeoutMs: number): Promise<Response> {\n let current = rawUrl;\n for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n await assertFetchableUrl(current);\n const response = await fetchOnce(current, timeoutMs);\n const redirect = response.status >= 300 && response.status < 400 && response.status !== 304 ? response.headers.get(\"location\") : null;\n if (!redirect) return response;\n current = new URL(redirect, current).toString();\n }\n throw new Error(`too many redirects (>${MAX_REDIRECTS}) starting from ${rawUrl}`);\n}\n\n/** Fetch a URL as text, throwing on guard rejection, network error, or non-2xx. */\nexport async function fetchText(url: string, timeoutMs: number = DEFAULT_FEED_TIMEOUT_MS): Promise<string> {\n const response = await fetchGuarded(url, timeoutMs);\n if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);\n return response.text();\n}\n\n/** Fetch a URL as parsed JSON, throwing on guard rejection, network error, or non-2xx. */\nexport async function fetchJson(url: string, timeoutMs: number = DEFAULT_FEED_TIMEOUT_MS): Promise<unknown> {\n const response = await fetchGuarded(url, timeoutMs);\n if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);\n return response.json();\n}\n","// RSS 2.0 + Atom 1.0 + RSS 1.0 (RDF) parser for the Feeds engine.\n//\n// Deliberately NON-normalizing: it locates the feed's items and hands\n// each one back as its RAW parsed XML element. The host hard-codes no\n// per-feed field list — `ingest.map` resolves source paths against the\n// raw item (tags are keys; attributes are prefixed `@_`; namespaced tags\n// keep their prefix), and the caller inspects the feed to decide what to\n// map. Generic value handling (text-node unwrapping, date parsing by the\n// target field's declared type) lives in `projectItem.ts`, not here.\n//\n// Own copy of the XML plumbing — the Feeds tree does not import the\n// legacy `sources` tree. Pure; unit-testable with fixture strings.\n\nimport { XMLParser } from \"fast-xml-parser\";\n\n// Tiny inline type guards (the host's shared `utils/types` is not available in\n// this shared package — these are the only two we need here).\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n\nexport interface ParsedFeedItem {\n /** The raw parsed XML <item>/<entry> object, verbatim. */\n raw: Record<string, unknown>;\n}\n\nexport interface ParsedFeed {\n kind: \"rss\" | \"atom\";\n title: string | null;\n items: ParsedFeedItem[];\n}\n\nconst xml = new XMLParser({\n ignoreAttributes: false,\n attributeNamePrefix: \"@_\",\n cdataPropName: \"#cdata\",\n parseTagValue: false,\n parseAttributeValue: false,\n trimValues: true,\n // Only the item containers are forced to arrays (single vs. many).\n // Everything else stays in its natural parsed shape so `ingest.map`\n // resolves paths the way the caller sees them in the feed.\n isArray: (name) => name === \"item\" || name === \"entry\",\n});\n\n/** Parse an RSS/Atom/RDF feed body. Returns null when the input doesn't\n * look like a feed we understand. */\nexport function parseFeed(body: string): ParsedFeed | null {\n const text = stripBom(body);\n if (!text.trim()) return null;\n let parsed: unknown;\n try {\n parsed = xml.parse(text);\n } catch {\n return null;\n }\n if (!isRecord(parsed)) return null;\n if (isRecord(parsed.rss)) return parseRss(parsed.rss);\n if (isRecord(parsed.feed)) return parseAtom(parsed.feed);\n const rdf = parsed[\"rdf:RDF\"] ?? parsed.RDF;\n if (isRecord(rdf)) return parseRss10(rdf);\n return null;\n}\n\n// Collect the raw item objects, skipping anything that isn't a record or\n// carries no <title> (drops feed-level noise / empty entries).\nfunction collectItems(value: unknown): ParsedFeedItem[] {\n const rawItems = Array.isArray(value) ? value : [];\n const items: ParsedFeedItem[] = [];\n for (const raw of rawItems) {\n if (isRecord(raw) && readString(raw.title) !== null) items.push({ raw });\n }\n return items;\n}\n\nfunction parseRss(rss: Record<string, unknown>): ParsedFeed | null {\n const { channel } = rss;\n if (!isRecord(channel)) return null;\n return { kind: \"rss\", title: readString(channel.title), items: collectItems(channel.item) };\n}\n\nfunction parseRss10(rdf: Record<string, unknown>): ParsedFeed | null {\n const channel = isRecord(rdf.channel) ? rdf.channel : null;\n return { kind: \"rss\", title: channel ? readString(channel.title) : null, items: collectItems(rdf.item) };\n}\n\nfunction parseAtom(feed: Record<string, unknown>): ParsedFeed | null {\n return { kind: \"atom\", title: readString(feed.title), items: collectItems(feed.entry) };\n}\n\n// --- helpers ------------------------------------------------------------\n\n// Extract a string from a plain string, a `{ \"#text\" }` / `{ \"#cdata\" }`\n// node, or an array (first non-empty). Used only to read the feed/item\n// title for the channel name + the empty-item filter.\nfunction readString(value: unknown): string | null {\n if (isNonEmptyString(value)) return value;\n if (typeof value === \"string\") return null;\n if (isRecord(value)) return readStringFromRecord(value);\n if (Array.isArray(value)) return readStringFromArray(value);\n return null;\n}\n\nfunction readStringFromRecord(record: Record<string, unknown>): string | null {\n const text = record[\"#text\"];\n if (isNonEmptyString(text)) return text;\n const cdata = record[\"#cdata\"];\n if (isNonEmptyString(cdata)) return cdata;\n return null;\n}\n\nfunction readStringFromArray(array: readonly unknown[]): string | null {\n for (const entry of array) {\n const resolved = readString(entry);\n if (resolved !== null) return resolved;\n }\n return null;\n}\n\nfunction stripBom(text: string): string {\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n","// Tiny dot/bracket path resolver used by the declarative `ingest.map`\n// and `ingest.itemsAt`. Pure, no I/O, exhaustively unit-testable.\n//\n// Supported syntax:\n// \"title\" → root.title\n// \"data.name\" → root.data.name\n// \"results[0].id\" → root.results[0].id\n// \"hourly[]\" → root.hourly (trailing [] = \"the array\n// here\"; the marker is a no-op for value reads)\n// \"data.results[]\" → root.data.results\n//\n// Any miss (wrong type, out-of-range index, absent key) yields\n// `undefined` rather than throwing — declarative configs fail soft.\n\nimport { isRecord, isUnknownArray } from \"@mulmoclaude/common\";\n\ninterface KeyToken {\n kind: \"key\";\n key: string;\n}\ninterface IndexToken {\n kind: \"index\";\n index: number;\n}\ntype PathToken = KeyToken | IndexToken;\n\nconst BRACKET_RE = /\\[(\\d*)\\]/g;\n\n/** Split one dot-segment (`results[0]`, `hourly[]`, `name`) into tokens.\n * An empty `[]` is an array-identity marker and emits no token — the\n * array value is read by the preceding key. */\nfunction parseSegment(segment: string, tokens: PathToken[]): void {\n const bracketStart = segment.indexOf(\"[\");\n const name = bracketStart === -1 ? segment : segment.slice(0, bracketStart);\n if (name.length > 0) tokens.push({ kind: \"key\", key: name });\n if (bracketStart === -1) return;\n for (const match of segment.slice(bracketStart).matchAll(BRACKET_RE)) {\n const [, inner = \"\"] = match;\n if (inner.length > 0) tokens.push({ kind: \"index\", index: Number(inner) });\n }\n}\n\nfunction tokenize(path: string): PathToken[] {\n const tokens: PathToken[] = [];\n for (const segment of path.split(\".\")) {\n if (segment.length > 0) parseSegment(segment, tokens);\n }\n return tokens;\n}\n\nfunction step(current: unknown, token: PathToken): unknown {\n if (current === null || current === undefined) return undefined;\n if (token.kind === \"key\") {\n if (!isRecord(current)) return undefined;\n // A `__proto__` / `constructor` / `toString` key must miss (→ undefined),\n // not read an inherited Object.prototype member into a `naturalKey`.\n return Object.hasOwn(current, token.key) ? current[token.key] : undefined;\n }\n return isUnknownArray(current) ? current[token.index] : undefined;\n}\n\n/** Resolve a dot/bracket path against `root`, or `undefined` on any miss. */\nexport function getByPath(root: unknown, path: string): unknown {\n let current: unknown = root;\n for (const token of tokenize(path)) {\n current = step(current, token);\n }\n return current;\n}\n\n/** Locate the array of raw items for a fetch. With `itemsAt` set, walk\n * to it; without it, the response itself must be the array. Non-arrays\n * yield `[]` so a malformed response is a no-op, not a crash. */\nexport function getItemsArray(root: unknown, itemsAt: string | undefined): unknown[] {\n if (!itemsAt) return Array.isArray(root) ? root : [];\n const value = getByPath(root, itemsAt);\n return Array.isArray(value) ? value : [];\n}\n","// Project one raw retrieved item (a parsed RSS entry, or a JSON object)\n// into a CollectionItem whose keys match the schema's fields, using the\n// declarative `ingest.map`. Also derives the record's id.\n//\n// Id rule: a collection record's filename IS its primaryKey value, and\n// that value must be a safe slug. Feed-native keys (RSS guids/URLs,\n// ISO datetimes) usually are NOT slug-safe, so we slugify the natural\n// key into a deterministic, stable id — same natural key → same id, so\n// re-fetches upsert in place. The natural key comes from the mapped\n// primaryKey value, then `ingest.idFrom`, then a content hash.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { createHash } from \"node:crypto\";\nimport { getByPath } from \"./pathResolver.js\";\nimport type { CollectionItem, CollectionSchema } from \"../../collection/index.js\";\nimport type { DeclarativeIngestSpec } from \"../ingestTypes.js\";\n\nfunction asKeyString(value: unknown): string | null {\n if (typeof value === \"string\" && value.trim().length > 0) return value.trim();\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return null;\n}\n\n/** Collapse an XML element object that carries body text alongside\n * attributes (`{ \"#text\": \"v\", \"@_x\": \"...\" }`) or CDATA to its text.\n * Generic — the host knows no field names, only this fast-xml-parser\n * shape — so a tag like `<guid isPermaLink=\"false\">id</guid>` maps to\n * its text without the caller needing to write `.#text`. */\nfunction unwrapTextNode(value: unknown): unknown {\n if (isRecord(value)) {\n if (typeof value[\"#text\"] === \"string\") return value[\"#text\"];\n if (typeof value[\"#cdata\"] === \"string\") return value[\"#cdata\"];\n }\n return value;\n}\n\n/** Normalize a mapped value generically — driven by the SCHEMA's declared\n * field type, never by the source field name. Today: unwrap XML text\n * nodes, and coerce anything mapped into a `date` field to a `YYYY-MM-DD`\n * civil date (the collection `date` type + calendar are day-granularity\n * and parse strictly — a full RFC-3339 timestamp would be rejected). */\nfunction normalizeValue(value: unknown, fieldType: string | undefined): unknown {\n const unwrapped = unwrapTextNode(value);\n if (fieldType === \"date\" && typeof unwrapped === \"string\") {\n const millis = Date.parse(unwrapped);\n if (Number.isFinite(millis)) return new Date(millis).toISOString().slice(0, 10);\n }\n return unwrapped;\n}\n\n/** Slugify a natural key into a stable, filename-safe id. Short, safe\n * keys pass through (lowercased); long or unsafe keys collapse to a\n * hash so the filename stays bounded and valid. */\nfunction toSafeId(natural: string): string {\n const collapsed = natural\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\");\n // eslint-disable-next-line sonarjs/super-linear-regex -- anchored hyphen trim, linear, no catastrophic backtracking\n const slug = collapsed.replace(/^-+|-+$/g, \"\");\n if (slug.length > 0 && slug.length <= 80) return slug;\n const hash = createHash(\"sha256\")\n .update(natural || \"item\", \"utf-8\")\n .digest(\"hex\")\n .slice(0, 16);\n return slug.length > 80 ? `${slug.slice(0, 60)}-${hash}` : `feed-${hash}`;\n}\n\nfunction naturalKey(record: CollectionItem, rawItem: unknown, ingest: DeclarativeIngestSpec, schema: CollectionSchema): string {\n const fromMapped = asKeyString(record[schema.primaryKey]);\n if (fromMapped) return fromMapped;\n if (ingest.idFrom) {\n const fromId = asKeyString(unwrapTextNode(getByPath(rawItem, ingest.idFrom)));\n if (fromId) return fromId;\n }\n return JSON.stringify(record);\n}\n\n/** Build a record from a raw fetched item (a parsed RSS/Atom element or a\n * JSON object) using `ingest.map`. Each source path is resolved against\n * the raw item and normalized per the target field's declared type. The\n * returned record's primaryKey is set to the derived safe id (so it\n * doubles as the filename). */\nexport function projectRecord(rawItem: unknown, ingest: DeclarativeIngestSpec, schema: CollectionSchema): CollectionItem {\n const record: CollectionItem = {};\n for (const [targetField, sourcePath] of Object.entries(ingest.map)) {\n const value = normalizeValue(getByPath(rawItem, sourcePath), schema.fields?.[targetField]?.type);\n if (value !== undefined) record[targetField] = value;\n }\n record[schema.primaryKey] = toSafeId(naturalKey(record, rawItem, ingest, schema));\n return record;\n}\n","// RSS / Atom retriever. Fetches the feed, parses it, and projects each\n// item's RAW parsed XML element through `ingest.map`. The map's source\n// paths are the item's own tags/attributes (e.g. `title`, `pubDate`,\n// `enclosure.@_url`, `itunes:duration`) — the host hard-codes no field\n// list; the caller inspects the feed and maps what it carries.\n\nimport { fetchText } from \"../fetch/httpClient.js\";\nimport { parseFeed } from \"../fetch/rssParser.js\";\nimport { projectRecord } from \"../projectItem.js\";\nimport { registerRetriever, type RetrieveFn } from \"./index.js\";\n\nconst retrieveRss: RetrieveFn = async (ingest, schema) => {\n const body = await fetchText(ingest.url);\n const feed = parseFeed(body);\n if (!feed) return { items: [], cursor: {} };\n const items = feed.items.map((item) => projectRecord(item.raw, ingest, schema));\n return { items, cursor: {} };\n};\n\n// Atom shares the same parser + projection path.\nregisterRetriever(\"rss\", retrieveRss);\nregisterRetriever(\"atom\", retrieveRss);\n\nexport { retrieveRss };\n","// Generic JSON-API retriever. Fetches JSON, walks `ingest.itemsAt` to\n// the array of raw items, and projects each through `ingest.map` (whose\n// source paths are dot/bracket paths into each raw item).\n\nimport { fetchJson } from \"../fetch/httpClient.js\";\nimport { getItemsArray } from \"../pathResolver.js\";\nimport { projectRecord } from \"../projectItem.js\";\nimport { registerRetriever, type RetrieveFn } from \"./index.js\";\n\nconst retrieveHttpJson: RetrieveFn = async (ingest, schema) => {\n const json = await fetchJson(ingest.url);\n const rawItems = getItemsArray(json, ingest.itemsAt);\n const items = rawItems.map((raw) => projectRecord(raw, ingest, schema));\n return { items, cursor: {} };\n};\n\nregisterRetriever(\"http-json\", retrieveHttpJson);\n\nexport { retrieveHttpJson };\n","// Per-collection retrieval state — when we last fetched/dispatched, the\n// retriever's cursor (for incremental fetches), and a consecutive-failure\n// counter. NOT committed to git. Location depends on the collection's source:\n// feeds keep it alongside the schema at `<workspace>/feeds/<slug>/_state.json`;\n// skill-backed collections with `ingest.kind: \"agent\"` store it at\n// `<workspace>/data/ingest-state/<slug>.json` (a schema-less `feeds/<slug>/`\n// dir would confuse feed discovery, and `_state.json` must never live in a\n// collection's dataDir where `listItems` would read it as a record).\n// Deliberately minimal: the legacy `sources` tree carries richer backoff\n// state, but the engine starts simple and grows on real need.\n\nimport { isErrorWithCode, isRecord } from \"@mulmoclaude/common\";\nimport { mkdir, readFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport type { CollectionSource } from \"../../collection/index.js\";\nimport { log, requireFeedsHost } from \"./host.js\";\nimport { feedStatePath, ingestStatePath } from \"../paths.js\";\n\n/** Minimal shape needed to locate a collection's state file. `LoadedCollection`\n * satisfies it. */\nexport interface StateTarget {\n slug: string;\n source: CollectionSource;\n}\n\n/** Resolve the on-disk state file for a collection, branching on source. */\nfunction stateFilePath(target: StateTarget, workspaceRoot: string): string {\n return target.source === \"feed\" ? feedStatePath(target.slug, workspaceRoot) : ingestStatePath(target.slug, workspaceRoot);\n}\n\nexport interface FeedState {\n slug: string;\n /** ISO timestamp of the last successful fetch (declarative) or dispatch\n * (agent ingest), or null if never. */\n lastFetchedAt: string | null;\n /** Free-form retriever cursor (e.g. last-seen id / etag). */\n cursor: Record<string, string>;\n /** Consecutive failed fetches/runs; reset to 0 on success. */\n consecutiveFailures: number;\n /** Agent ingest only: the notifier entry id of the active \"refresh failed\"\n * bell, so a later success can clear exactly that entry. Absent when no\n * failure bell is showing. */\n failureBellId?: string;\n}\n\nexport function defaultFeedState(slug: string): FeedState {\n return { slug, lastFetchedAt: null, cursor: {}, consecutiveFailures: 0 };\n}\n\n/** The stored cursor, keeping only the string entries its type promises. A\n * hand-edited state file can hold anything; a non-string entry is unreadable\n * to every retriever, so carrying it forward would only re-persist junk. */\nfunction readCursor(value: unknown): Record<string, string> {\n if (!isRecord(value)) return {};\n return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === \"string\"));\n}\n\nfunction normalizeState(slug: string, parsed: Record<string, unknown>): FeedState {\n const base = defaultFeedState(slug);\n const cursor = parsed.cursor === undefined ? base.cursor : readCursor(parsed.cursor);\n return {\n slug,\n lastFetchedAt: typeof parsed.lastFetchedAt === \"string\" ? parsed.lastFetchedAt : base.lastFetchedAt,\n cursor,\n consecutiveFailures: typeof parsed.consecutiveFailures === \"number\" ? parsed.consecutiveFailures : base.consecutiveFailures,\n ...(typeof parsed.failureBellId === \"string\" ? { failureBellId: parsed.failureBellId } : {}),\n };\n}\n\n/** Read a collection's retrieval state, tolerating a missing file (first run →\n * default). The state path branches on `target.source`. */\nexport async function readFeedState(workspaceRoot: string, target: StateTarget): Promise<FeedState> {\n const { slug } = target;\n try {\n const raw = await readFile(stateFilePath(target, workspaceRoot), \"utf-8\");\n const parsed: unknown = JSON.parse(raw);\n // A non-object state file has no fields to read, so it normalises to the\n // same default `normalizeState` would produce from an empty record.\n return normalizeState(slug, isRecord(parsed) ? parsed : {});\n } catch (err) {\n if (!isErrorWithCode(err) || err.code !== \"ENOENT\") {\n log.warn(\"feeds\", \"failed to read feed state, using default\", { slug, error: String(err) });\n }\n return defaultFeedState(slug);\n }\n}\n\n/** Persist a collection's retrieval state atomically (creating the parent dir\n * if needed). The state path branches on `target.source`. */\nexport async function writeFeedState(workspaceRoot: string, target: StateTarget, state: FeedState): Promise<void> {\n const file = stateFilePath(target, workspaceRoot);\n await mkdir(path.dirname(file), { recursive: true });\n await requireFeedsHost().writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\\n`);\n}\n","// Agent-performed ingest (`ingest.kind: \"agent\"`). On schedule (or a manual\n// Refresh), the host seeds a hidden background worker — origin `system`, so\n// it never appears in the user's session list — in the collection's declared\n// role with its template + a summary of every record, and the worker edits the\n// records itself via the collections io layer. The host stays domain-free:\n// everything stock-specific (or whatever the collection does) is prose in the\n// template.\n//\n// DI seam: the worker is launched through `FeedsHost.spawnWorker`, injected at\n// boot via `configureFeedsHost`, not imported directly — this module ships in a\n// shared package and must not reach into any host's session/routes layer.\n\nimport { listItems, promptPathsFor, readSkillTemplate, buildCollectionActionSeedPrompt, type LoadedCollection } from \"../../collection/server/index.js\";\nimport { publish as publishNotifier, clear as clearNotifier } from \"../../notifier/index.js\";\nimport { log, requireFeedsHost, type AgentWorkerResult, type AgentWorkerRunner } from \"./host.js\";\nimport { readFeedState, writeFeedState, type FeedState } from \"./state.js\";\nimport { AGENT_INGEST_KIND } from \"../ingestTypes.js\";\nimport type { RefreshResult } from \"./refreshResult.js\";\n\nexport type { AgentWorkerResult, AgentWorkerRunner } from \"./host.js\";\n\n/** The injected worker launcher, or null if the host was never configured.\n * Read non-throwingly so the failure-isolated contract holds (an unconfigured\n * host becomes an `errors` entry, not a thrown exception). */\nfunction workerRunnerOrNull(): AgentWorkerRunner | null {\n try {\n return requireFeedsHost().spawnWorker;\n } catch {\n return null;\n }\n}\n\nfunction result(slug: string, patch: Partial<RefreshResult>): RefreshResult {\n return { slug, written: 0, removed: 0, errors: [], ...patch };\n}\n\n/** Dispatch one agent-ingest refresh: build the seed, launch a worker, and (on a\n * successful launch) stamp `lastFetchedAt` with the DISPATCH time — not\n * completion. That's what gates the due-loop, so a slow worker can't cause a\n * double-dispatch. `opts.hidden` (default true) runs an invisible system worker\n * for SCHEDULED refreshes; a MANUAL Refresh passes `hidden:false` for a visible,\n * debuggable session. Failure-isolated: never throws; cap-miss / template-miss /\n * launch error leave state untouched and report via `errors`. */\nexport async function refreshViaAgent(workspaceRoot: string, collection: LoadedCollection, opts?: { hidden?: boolean }): Promise<RefreshResult> {\n const hidden = opts?.hidden ?? true;\n const { slug } = collection;\n const { ingest } = collection.schema;\n if (ingest?.kind !== AGENT_INGEST_KIND) return result(slug, { errors: [\"collection has no agent ingest config\"] });\n const workerRunner = workerRunnerOrNull();\n if (!workerRunner) return result(slug, { errors: [\"agent ingest worker runner not configured\"] });\n\n const template = await readSkillTemplate(collection.skillDir, ingest.template);\n if (template === null) return result(slug, { errors: [`ingest template '${ingest.template}' could not be read`] });\n\n const items = await listItems(collection.dataDir, { workspaceRoot });\n // Thread the canonical, host-normalized paths through the seed so the\n // template can pass `--out-dir data/collections/<slug>/items` (etc.) into\n // any bundled script instead of relying on the script's `argparse` default —\n // which was written for the author's pre-import layout, NOT the R3-\n // normalized location the host actually reads records from. Fixes #1891.\n const message = buildCollectionActionSeedPrompt(items, collection.schema, template, promptPathsFor(collection, workspaceRoot));\n\n // The runner is injected, so guard its promise here to honour the\n // failure-isolated contract (a rejection must become an `errors` entry, not\n // escape into the scheduler loop / route handler).\n let launch: AgentWorkerResult;\n try {\n launch = await workerRunner({\n message,\n roleId: ingest.role,\n hidden,\n // The seed prompt addresses records root-relatively (`promptPathsFor`), so\n // the root travels WITH it: a multi-root host spawns the worker there. A\n // single-workspace host ignores the field — same root either way.\n workspaceRoot,\n // A visible manual run is watched directly — only hidden runs get the\n // completion hook (failure bell + consecutiveFailures); `finalizeRun` only\n // fires it for system-origin sessions anyway.\n onComplete: hidden ? (outcome) => recordOutcome(workspaceRoot, collection, outcome.didError) : undefined,\n });\n } catch (err) {\n log.warn(\"feeds\", \"agent ingest worker launch threw\", { slug, error: String(err) });\n return result(slug, { errors: [String(err)], dispatched: false });\n }\n if (!launch.ok) {\n // Cap-miss or launch error: do NOT touch lastFetchedAt — the next due tick\n // (or manual Refresh) redials. Surface it so a manual refresh reads honest.\n log.info(\"feeds\", \"agent ingest dispatch skipped\", { slug, error: launch.error });\n return result(slug, { errors: [launch.error], dispatched: false });\n }\n\n const state = await readFeedState(workspaceRoot, collection);\n await writeFeedState(workspaceRoot, collection, { ...state, lastFetchedAt: new Date().toISOString() });\n log.info(\"feeds\", \"agent ingest dispatched\", { slug, role: ingest.role, chatId: launch.chatId, hidden, items: items.length });\n // Surface the chatId only for a visible run so the client can open it; a hidden\n // session is excluded from every listing and can't be navigated to anyway.\n return result(slug, { dispatched: true, ...(hidden ? {} : { chatId: launch.chatId }) });\n}\n\n/** Completion hook: reconcile failure tracking when a dispatched worker\n * finishes. On error, bump `consecutiveFailures` and raise a single failure\n * bell (deduped via the persisted `failureBellId`). On success, reset the\n * counter and clear any standing bell. Best-effort + failure-isolated — it\n * runs inside the agent run's teardown, so it must never throw. */\nasync function recordOutcome(workspaceRoot: string, collection: LoadedCollection, didError: boolean): Promise<void> {\n const state = await readFeedState(workspaceRoot, collection);\n const next: FeedState = didError ? await onWorkerError(state, collection) : await onWorkerSuccess(state, collection);\n await writeFeedState(workspaceRoot, collection, next);\n}\n\nasync function onWorkerError(state: FeedState, collection: LoadedCollection): Promise<FeedState> {\n const next: FeedState = { ...state, consecutiveFailures: state.consecutiveFailures + 1 };\n log.warn(\"feeds\", \"agent ingest worker failed\", { slug: collection.slug, consecutiveFailures: next.consecutiveFailures });\n // Raise a single bell on the first failure; a standing one isn't piled onto.\n if (!state.failureBellId) {\n try {\n const { id } = await publishNotifier({\n pluginPkg: \"host\",\n severity: \"nudge\",\n lifecycle: \"fyi\",\n title: \"Collection refresh failed\",\n body: `“${collection.schema.title}” (${collection.slug}) couldn't refresh. Open it to retry.`,\n navigateTarget: `/collections/${collection.slug}`,\n });\n next.failureBellId = id;\n } catch (err) {\n log.warn(\"feeds\", \"failed to publish ingest failure bell\", { slug: collection.slug, error: String(err) });\n }\n }\n return next;\n}\n\nasync function onWorkerSuccess(state: FeedState, collection: LoadedCollection): Promise<FeedState> {\n log.info(\"feeds\", \"agent ingest worker completed\", { slug: collection.slug });\n if (state.failureBellId) {\n await clearNotifier(state.failureBellId).catch((err) =>\n log.warn(\"feeds\", \"failed to clear ingest failure bell\", { slug: collection.slug, error: String(err) }),\n );\n }\n const next: FeedState = { ...state, consecutiveFailures: 0 };\n delete next.failureBellId;\n return next;\n}\n","// Retrieval engine: fetch a feed, upsert its records into the\n// collection's data dir (keyed by primaryKey, so re-fetches replace in\n// place / accumulate by id), and persist per-feed state. Per-feed\n// failures are isolated — `refreshOne` never throws; `refreshDue`\n// processes feeds sequentially to stay gentle on remote hosts (the\n// fetch client does no rate-limiting yet).\n\nimport { discoverCollections, storeFor, type CollectionStore, type LoadedCollection } from \"../../collection/server/index.js\";\nimport type { CollectionItem, CollectionSchema } from \"../../collection/index.js\";\nimport { mergeIntoExisting } from \"../../collection/core/project.js\";\nimport { log, requireFeedsHost } from \"./host.js\";\nimport { getRetriever } from \"./retrievers/index.js\";\nimport \"./retrievers/registerAll.js\";\nimport { readFeedState, writeFeedState, type FeedState } from \"./state.js\";\nimport { DEFAULT_FEED_MAX_ITEMS, AGENT_INGEST_KIND, type FeedSchedule, type IngestSpec } from \"../ingestTypes.js\";\nimport { refreshViaAgent } from \"./agentIngest.js\";\nimport type { RefreshResult } from \"./refreshResult.js\";\n\nexport type { RefreshResult } from \"./refreshResult.js\";\n\n// Refresh cadence anchors (ms). Inlined — the engine needs only these two and\n// must stay free of host-side time-constant modules.\nconst ONE_HOUR_MS = 3_600_000;\nconst ONE_DAY_MS = 86_400_000;\n\n/** A feed's retrieval config, or undefined. `CollectionSchema.ingest` and the\n * engine's `IngestSpec` are both `IngestZ`'s inferred union, so this is a\n * rename, not a narrowing — the engine keeps its own vocabulary at the seam. */\nfunction feedIngest(schema: CollectionSchema): IngestSpec | undefined {\n return schema.ingest;\n}\n\n/** A feed's store. `write`/`delete` are always present (discovery rejects\n * `ingest` on read-only dataSource schemas); null only as defense in depth. */\nfunction writableFeedStore(\n workspaceRoot: string,\n feed: LoadedCollection,\n): (CollectionStore & { write: NonNullable<CollectionStore[\"write\"]>; delete: NonNullable<CollectionStore[\"delete\"]> }) | null {\n const store = storeFor(feed, { workspaceRoot });\n const { write, delete: remove } = store;\n if (!write || !remove) {\n log.warn(\"feeds\", \"feed store is read-only — refresh skipped\", { slug: feed.slug });\n return null;\n }\n return { ...store, write, delete: remove };\n}\n\n/** Fields the feed itself produces: what it actually returned this run, the\n * declared map's targets, and the primary key the retriever derives. Anything\n * else on a record got there some other way — the user.\n *\n * Both sources, because neither alone is enough. The in-tree retrievers project\n * through `map`, so it describes them exactly — but `registerRetriever` lets a\n * host add one that returns whatever it likes, and only the observed keys catch\n * that. Conversely a run that fetched nothing has no keys to observe, and only\n * `map` keeps the cap working through it. */\nexport const ingestedFields = (items: readonly CollectionItem[], mappedTargets: readonly string[], primaryKey: string): ReadonlySet<string> =>\n new Set([primaryKey, ...mappedTargets, ...items.flatMap((item) => Object.keys(item))]);\n\n/** The collection fields a declarative `map` names. Read as `unknown` and\n * narrowed here rather than through the zod-derived union, whose `map` does not\n * survive the `kind` narrowing well enough to index safely. Only the keys are\n * wanted, so the value type never has to be reconstructed. */\nexport function mappedTargetsOf(ingest: IngestSpec | undefined): string[] {\n const map: unknown = ingest === undefined || ingest.kind === AGENT_INGEST_KIND ? undefined : ingest.map;\n return typeof map === \"object\" && map !== null ? Object.keys(map) : [];\n}\n\n/** Whether a record carries content the feed did not put there.\n *\n * Only a NON-EMPTY value counts. A record saved through the UI can carry every\n * declared field as an empty string, and treating those as local content would\n * make every touched record un-prunable — the cap would quietly stop working.\n *\n * An edit to a MAPPED field deliberately does not count: a feed is\n * one-directional, so the next refresh writes over it by design. The local\n * COLUMN is the durable half, and the half worth protecting. */\nexport function hasLocalContent(item: CollectionItem, ingested: ReadonlySet<string>): boolean {\n return Object.entries(item).some(([field, value]) => !ingested.has(field) && value !== undefined && value !== null && value !== \"\");\n}\n\n/** The stored record, or null when it cannot be read.\n *\n * `readItem` answers null for a MISSING record but THROWS on unparsable JSON\n * or an IO error. Letting that escape would fail the whole refresh over one bad\n * file — and write nothing at all, since the loop aborts partway. Before the\n * merge landed, the ingest simply overwrote such a file and the feed healed\n * itself; treating \"unreadable\" as \"absent\" keeps that. (Observed during Claude\n * review; both review bots were rate-limited on this PR.) */\nasync function readableRecord(store: { read: CollectionStore[\"read\"] }, feed: LoadedCollection, itemId: string): Promise<CollectionItem | null> {\n try {\n return await store.read(itemId);\n } catch (error) {\n log.warn(\"feeds\", \"unreadable feed record — the retrieved item will replace it\", { slug: feed.slug, itemId, error: String(error) });\n return null;\n }\n}\n\nasync function upsertItems(workspaceRoot: string, feed: LoadedCollection, items: CollectionItem[]): Promise<number> {\n const store = writableFeedStore(workspaceRoot, feed);\n if (!store) return 0;\n let written = 0;\n for (const item of items) {\n const itemId = item[feed.schema.primaryKey];\n if (typeof itemId !== \"string\" || itemId.length === 0) continue;\n // Laid OVER the record rather than replacing it. `writeItem` writes the\n // whole file, so writing the retrieved item alone deletes every column the\n // ingest does not produce — the note the user put beside the article. The\n // Google Calendar pull learned this in #2620; the feeds ingest never did\n // (#2696).\n const result = await store.write(itemId, mergeIntoExisting(await readableRecord(store, feed, itemId), item));\n if (result.kind === \"ok\") written += 1;\n else log.warn(\"feeds\", \"feed item write skipped\", { slug: feed.slug, itemId, kind: result.kind });\n }\n return written;\n}\n\n/** The schema's first `date` field, used to order records for the\n * maxItems cap. Null when the schema declares none. */\nfunction firstDateField(schema: CollectionSchema): string | null {\n for (const [key, spec] of Object.entries(schema.fields)) {\n if (spec.type === \"date\") return key;\n }\n return null;\n}\n\n// Epoch ms for a record's date value; missing / unparseable sorts oldest.\nfunction recordTime(item: CollectionItem, field: string): number {\n const value = item[field];\n const millis = typeof value === \"string\" ? Date.parse(value) : NaN;\n return Number.isFinite(millis) ? millis : Number.NEGATIVE_INFINITY;\n}\n\n/** Enforce `ingest.maxItems` (default 100): keep the newest N records by\n * the schema's date field, delete the rest. No-op when the cap is 0/absent\n * of a date field, or when under the cap. Returns the number deleted. */\nasync function pruneFeed(workspaceRoot: string, feed: LoadedCollection, fetched: readonly CollectionItem[]): Promise<number> {\n const ingest = feedIngest(feed.schema);\n // maxItems is a declarative-feed concept; agent ingest manages its own record\n // set, so it's never pruned here (and `refreshOne` never calls pruneFeed for it).\n const cap = (ingest && ingest.kind !== AGENT_INGEST_KIND ? ingest.maxItems : undefined) ?? DEFAULT_FEED_MAX_ITEMS;\n if (cap <= 0) return 0;\n const dateField = firstDateField(feed.schema);\n if (!dateField) {\n log.warn(\"feeds\", \"maxItems prune skipped: schema has no date field to order by\", { slug: feed.slug });\n return 0;\n }\n const store = writableFeedStore(workspaceRoot, feed);\n if (!store) return 0;\n const items = await store.list();\n if (items.length <= cap) return 0;\n const ingested = ingestedFields(fetched, mappedTargetsOf(ingest), feed.schema.primaryKey);\n const aged = [...items].sort((left, right) => recordTime(right, dateField) - recordTime(left, dateField)).slice(cap);\n // Deleting a record the user annotated is the same silent loss the merge above\n // prevents, one step later: the note survives every refresh and then goes when\n // the article ages out. Kept records can push a feed past its cap, so say so\n // rather than letting the number drift unexplained (#2696).\n const stale = aged.filter((item) => !hasLocalContent(item, ingested));\n const kept = aged.length - stale.length;\n if (kept > 0) log.info(\"feeds\", \"keeping aged-out records that carry local content\", { slug: feed.slug, kept, cap });\n let removed = 0;\n for (const item of stale) {\n const itemId = item[feed.schema.primaryKey];\n if (typeof itemId !== \"string\" || itemId.length === 0) continue;\n if ((await store.delete(itemId)).kind === \"ok\") removed += 1;\n }\n if (removed > 0) log.info(\"feeds\", \"pruned old feed records\", { slug: feed.slug, removed, cap });\n return removed;\n}\n\n/** Fetch one feed now, upsert its records, then enforce the maxItems cap.\n * Failure-isolated: returns an errors array rather than throwing. */\nexport async function refreshOne(workspaceRoot: string, feed: LoadedCollection, opts?: { hidden?: boolean }): Promise<RefreshResult> {\n const { slug } = feed;\n const ingest = feedIngest(feed.schema);\n if (!ingest) return { slug, written: 0, removed: 0, errors: [\"collection has no ingest config\"] };\n // `agent` ingest dispatches a worker instead of fetching: branch off BEFORE\n // the retriever registry (which only knows declarative kinds). `opts.hidden`\n // (manual Refresh passes false) only affects the agent path; declarative\n // feeds ignore it.\n if (ingest.kind === AGENT_INGEST_KIND) return refreshViaAgent(workspaceRoot, feed, opts);\n const retriever = getRetriever(ingest.kind);\n if (!retriever) return { slug, written: 0, removed: 0, errors: [`no retriever registered for kind '${ingest.kind}'`] };\n const state = await readFeedState(workspaceRoot, feed);\n try {\n const result = await retriever(ingest, feed.schema, state);\n const written = await upsertItems(workspaceRoot, feed, result.items);\n await writeFeedState(workspaceRoot, feed, { ...state, lastFetchedAt: new Date().toISOString(), cursor: result.cursor, consecutiveFailures: 0 });\n const removed = await pruneFeed(workspaceRoot, feed, result.items);\n log.info(\"feeds\", \"feed refreshed\", { slug, written, removed, fetched: result.items.length });\n return { slug, written, removed, errors: [] };\n } catch (error) {\n await writeFeedState(workspaceRoot, feed, { ...state, consecutiveFailures: state.consecutiveFailures + 1 });\n const message = String(error);\n log.warn(\"feeds\", \"feed refresh failed\", { slug, error: message });\n return { slug, written: 0, removed: 0, errors: [message] };\n }\n}\n\nfunction dueIntervalMs(schedule: FeedSchedule): number {\n switch (schedule) {\n case \"daily\":\n return ONE_DAY_MS;\n case \"weekly\":\n return 7 * ONE_DAY_MS;\n default:\n return ONE_HOUR_MS;\n }\n}\n\n/** True iff a `daily` schedule with a UTC `atHour` anchor is due. The system\n * task ticks hourly, so \"due\" means: we're in the anchor hour AND we haven't\n * already run in roughly the last day. The 23 h floor (not 24 h) tolerates the\n * tick landing a few minutes earlier than the previous run, while staying well\n * above 1 h so a second tick in the same anchor hour can't double-fire. */\nfunction isDailyAtHourDue(now: Date, atHour: number, lastFetchedAt: string | null): boolean {\n if (now.getUTCHours() !== atHour) return false;\n if (!lastFetchedAt) return true;\n const elapsed = now.getTime() - Date.parse(lastFetchedAt);\n if (!Number.isFinite(elapsed)) return true;\n return elapsed >= 23 * ONE_HOUR_MS;\n}\n\n/** True iff a collection is due to refresh given its schedule + last run.\n * `on-demand` is never auto-due; a `daily` schedule with `atHour` anchors to\n * that UTC hour, otherwise cadence is elapsed-based. */\nfunction isFeedDue(feed: LoadedCollection, state: FeedState): boolean {\n const ingest = feedIngest(feed.schema);\n const schedule = ingest?.schedule;\n if (!schedule || schedule === \"on-demand\") return false;\n if (schedule === \"daily\" && typeof ingest?.atHour === \"number\") {\n return isDailyAtHourDue(new Date(), ingest.atHour, state.lastFetchedAt);\n }\n if (!state.lastFetchedAt) return true;\n const elapsed = Date.now() - Date.parse(state.lastFetchedAt);\n if (!Number.isFinite(elapsed)) return true;\n return elapsed >= dueIntervalMs(schedule);\n}\n\n/** Refresh every collection whose ingest schedule says it's due — declarative\n * feeds AND skill-backed collections with `ingest.kind: \"agent\"`. Called by the\n * hourly system task. Sequential + failure-isolated. */\nexport async function refreshDue(workspaceRoot: string = requireFeedsHost().workspaceRoot): Promise<RefreshResult[]> {\n const all = await discoverCollections({ workspaceRoot });\n const withIngest = all.filter((collection) => collection.schema.ingest);\n const results: RefreshResult[] = [];\n for (const collection of withIngest) {\n // Isolate per-collection failures: `readFeedState`/`refreshOne` (esp. the\n // agent path) can throw, and one bad collection must not abort the whole\n // due-loop. Capture it as an errors result and move on.\n try {\n const state = await readFeedState(workspaceRoot, collection);\n if (!isFeedDue(collection, state)) continue;\n results.push(await refreshOne(workspaceRoot, collection));\n } catch (error) {\n log.warn(\"feeds\", \"scheduled refresh failed for collection\", { slug: collection.slug, error: String(error) });\n results.push({ slug: collection.slug, written: 0, removed: 0, errors: [String(error)] });\n }\n }\n return results;\n}\n","// Shareable registration for the hourly \"refresh due collections\" task.\n// Both MulmoClaude (via `initScheduler`, with persistence + catch-up) and a\n// standalone MulmoTerminal/MulmoBooks (via a bare task-manager `registerTask`)\n// register the SAME definition through this factory, so the id/schedule/run\n// can't drift between hosts. See plans/done/feat-shareable-feed-refresh-registration.md.\nimport { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from \"@receptron/task-scheduler\";\nimport type { SystemTaskDef } from \"../../scheduler/adapter.js\";\nimport { canonicalRoot } from \"../../files/root.js\";\nimport { refreshDue } from \"./engine.js\";\n\n// Id kept stable so the scheduler-state row isn't orphaned across hosts/renames.\nexport const FEED_REFRESH_TASK_ID = \"system:feed-refresh\";\n\n/** The task id for a given root. Root-less (the configured workspace) keeps the\n * bare, historical id, so a single-workspace host's persisted scheduler state\n * keeps matching. A per-root registration gets its own id — a task id is the\n * scheduler's primary key, so N roots registered under one id would collide\n * and only the last would ever run. */\nexport function feedRefreshTaskId(workspaceRoot?: string): string {\n // Canonical: `/proj` and `/proj/` are one project, so they must resolve to\n // one task id. Registering both spellings otherwise creates two jobs over one\n // tree, each with its own persisted state, refreshing the same feeds twice.\n return workspaceRoot === undefined ? FEED_REFRESH_TASK_ID : `${FEED_REFRESH_TASK_ID}:${canonicalRoot(workspaceRoot)}`;\n}\n// Single source of truth for the default refresh cadence (one hour). Core has no\n// shared time module — `server/utils/time.ts` is host-only — so the named export\n// IS the constant; hosts override via `feedRefreshTaskDef({ intervalMs })`.\nexport const DEFAULT_FEED_REFRESH_INTERVAL_MS = 60 * 60 * 1000;\n\n/**\n * The hourly \"refresh due collections\" task, shared by every host.\n *\n * Drives ALL scheduled ingest: declarative feeds (RSS / JSON) fetch directly,\n * and skill-backed `ingest.kind: \"agent\"` collections dispatch a hidden worker.\n *\n * Returns the rich `SystemTaskDef` rather than registering it — each host keeps\n * its own registration mechanism and applies its own schedule overrides first.\n * A host driving a bare task-manager can still `registerTask(...)` it; the extra\n * `missedRunPolicy` field is simply ignored there.\n *\n * ROOT-PARAMETERISED. Called with no `workspaceRoot` it refreshes the host's\n * configured workspace under the historical task id — unchanged, and what a\n * single-workspace host wants. A multi-root host registers ONE def per root it\n * wants refreshed on a schedule; each gets its own id (see `feedRefreshTaskId`)\n * so the registrations do not overwrite each other. A root that is never\n * registered is never refreshed on a schedule — its feeds update only on an\n * explicit call — so registering per project is the host's decision to make,\n * deliberately, rather than something this module can infer.\n *\n * @param opts.intervalMs per-host interval override (defaults to one hour)\n * @param opts.workspaceRoot the root to refresh; omitted, the configured FeedsHost workspace\n */\nexport function feedRefreshTaskDef(opts?: { workspaceRoot?: string; intervalMs?: number }): SystemTaskDef {\n // Canonicalise ONCE and use that value for the id, the display name and the\n // refresh itself. Deriving only the id from it would leave two registrations\n // for `/proj` and `/proj/` collapsing onto one row whose name and refresh\n // target still named whichever spelling registered last — the id would look\n // deduplicated while the job it ran was a coin flip.\n const root = opts?.workspaceRoot === undefined ? undefined : canonicalRoot(opts.workspaceRoot);\n return {\n id: feedRefreshTaskId(root),\n name: root === undefined ? \"Scheduled collection refresh\" : `Scheduled collection refresh (${root})`,\n description: \"Refresh due collections — fetch declarative feeds + dispatch agent-ingest workers\",\n schedule: { type: SCHEDULE_TYPES.interval, intervalMs: opts?.intervalMs ?? DEFAULT_FEED_REFRESH_INTERVAL_MS },\n missedRunPolicy: MISSED_RUN_POLICIES.runOnce,\n run: () => refreshDue(root).then(() => {}),\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/file-change/index.ts"],"sourcesContent":["// @mulmoclaude/core/file-change — shared \"this workspace file changed\"\n// broadcaster. A host write route calls `publishFileChange(relPath)` after a\n// successful write; subscribed UI tabs refetch. Extracted so MulmoClaude and\n// MulmoTerminal forward to the SAME plugin-scoped channels (the live-refresh\n// contract the markdown/html Views subscribe to) without duplicating the logic.\n//\n// Everything host-specific is INJECTED via `configureFileChangePublisher` (the\n// pubsub, the workspace root, the path→posix normaliser, the host's own primary\n// channel, the plugin-scope matchers, and any side-effect), so this package owns\n// only the orchestration + the `plugin:<scope>:file:<path>` channel format and\n// touches neither host's pubsub-channel config nor its frontend subscribers.\nimport { stat } from \"node:fs/promises\";\nimport path from \"node:path\";\n\n/** Payload published on every file-change channel. `mtimeMs` is the post-write\n * modified time — monotonic, suitable for cache-busting (`?v=`) and out-of-order\n * drops. */\nexport interface FileChannelPayload {\n path: string;\n mtimeMs: number;\n}\n\n/** A plugin View that wants live-refresh: when `matches(posixPath)` is true, the\n * change is forwarded to `plugin:<scope>:file:<path>` (the channel the View's\n * runtime subscribes to). */\nexport interface FileChangeScope {\n scope: string;\n matches: (posixPath: string) => boolean;\n}\n\nexport interface FileChangePublisherConfig {\n /** Host pubsub publish. */\n publish: (channel: string, payload: FileChannelPayload) => void;\n /** Workspace root — joined with the relative path to stat the post-write mtime. */\n workspaceRoot: string;\n /** Normalise a workspace-relative path to POSIX (drives both `payload.path` and\n * every channel suffix, so they can't drift on mixed separators). */\n toPosix: (relativePath: string) => string;\n /** The host's primary file channel (e.g. a Files explorer's `file:<path>`).\n * Omit when the host has no general file subscriber (e.g. MulmoTerminal). */\n primaryChannel?: (posixPath: string) => string;\n /** Plugin Views to forward to. */\n pluginScopes?: FileChangeScope[];\n /** Optional side-effect after publishing (e.g. a topic-index regen). Receives the\n * posix path + payload; may itself call `publishFileChange` for derived files. */\n onPublished?: (posixPath: string, payload: FileChannelPayload) => void;\n /** Optional warn logger; a publish/stat failure logs but never throws (callers\n * fire-and-forget, so a throw would be an unhandled rejection). */\n warn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nlet config: FileChangePublisherConfig | null = null;\n\n/** Wire the publisher to a host. Call once at startup, before any write route. */\nexport function configureFileChangePublisher(cfg: FileChangePublisherConfig): void {\n config = cfg;\n}\n\n/** Clear the binding — test-only. */\nexport function resetFileChangePublisher(): void {\n config = null;\n}\n\n/** The plugin-scoped live-refresh channel a View subscribes to. */\nexport function pluginFileChannel(scope: string, posixPath: string): string {\n return `plugin:${scope}:file:${posixPath}`;\n}\n\n/** Publish a file-change for a workspace-relative path: the primary channel (if any)\n * + every matching plugin scope, with the post-write mtime. No-op until configured. */\nexport async function publishFileChange(relativePath: string): Promise<void> {\n const cfg = config;\n if (!cfg) return;\n // `relativePath` comes from the host's write routes. Contain it before doing\n // anything: a path that escapes the workspace is dropped entirely — we\n // neither stat an arbitrary file nor broadcast an out-of-workspace path to\n // subscribers or host side-effects. Defence-in-depth, since this is the\n // shared package both hosts use. `root` carries a trailing separator and the\n // guard is a single `startsWith` early-return — the canonical containment\n // shape (so the check is unambiguous for both readers and static analysis).\n const root = path.resolve(cfg.workspaceRoot) + path.sep;\n const absPath = path.join(root, relativePath);\n if (!absPath.startsWith(root)) {\n cfg.warn?.(\"ignoring file-change for path outside workspace\", { path: relativePath });\n return;\n }\n let mtimeMs: number;\n try {\n ({ mtimeMs } = await stat(absPath));\n } catch (err) {\n cfg.warn?.(\"stat failed; falling back to Date.now()\", { path: relativePath, error: errMsg(err) });\n mtimeMs = Date.now();\n }\n const posixPath = cfg.toPosix(relativePath);\n const payload: FileChannelPayload = { path: posixPath, mtimeMs };\n\n if (cfg.primaryChannel) {\n safePublish(cfg, cfg.primaryChannel(posixPath), payload, \"primary publish failed\");\n }\n for (const { scope, matches } of cfg.pluginScopes ?? []) {\n if (!matches(posixPath)) continue;\n safePublish(cfg, pluginFileChannel(scope, posixPath), payload, `${scope} plugin forward failed`);\n }\n cfg.onPublished?.(posixPath, payload);\n}\n\nfunction safePublish(cfg: FileChangePublisherConfig, channel: string, payload: FileChannelPayload, failMsg: string): void {\n try {\n cfg.publish(channel, payload);\n } catch (err) {\n cfg.warn?.(`${failMsg}; subscribers will miss this event`, { channel, error: errMsg(err) });\n }\n}\n\nfunction errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n"],"mappings":";;;;;;AAmDA,IAAI,SAA2C;;AAG/C,SAAgB,6BAA6B,KAAsC;CACjF,SAAS;AACX;;AAGA,SAAgB,2BAAiC;CAC/C,SAAS;AACX;;AAGA,SAAgB,kBAAkB,OAAe,WAA2B;CAC1E,OAAO,UAAU,MAAM,QAAQ;AACjC;;;AAIA,eAAsB,kBAAkB,cAAqC;CAC3E,MAAM,MAAM;CACZ,IAAI,CAAC,KAAK;CAQV,MAAM,
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["path","stat"],"sources":["../../src/file-change/index.ts"],"sourcesContent":["// @mulmoclaude/core/file-change — shared \"this workspace file changed\"\n// broadcaster. A host write route calls `publishFileChange(relPath)` after a\n// successful write; subscribed UI tabs refetch. Extracted so MulmoClaude and\n// MulmoTerminal forward to the SAME plugin-scoped channels (the live-refresh\n// contract the markdown/html Views subscribe to) without duplicating the logic.\n//\n// Everything host-specific is INJECTED via `configureFileChangePublisher` (the\n// pubsub, the workspace root, the path→posix normaliser, the host's own primary\n// channel, the plugin-scope matchers, and any side-effect), so this package owns\n// only the orchestration + the `plugin:<scope>:file:<path>` channel format and\n// touches neither host's pubsub-channel config nor its frontend subscribers.\nimport { stat } from \"node:fs/promises\";\nimport path from \"node:path\";\n\n/** Payload published on every file-change channel. `mtimeMs` is the post-write\n * modified time — monotonic, suitable for cache-busting (`?v=`) and out-of-order\n * drops. */\nexport interface FileChannelPayload {\n path: string;\n mtimeMs: number;\n}\n\n/** A plugin View that wants live-refresh: when `matches(posixPath)` is true, the\n * change is forwarded to `plugin:<scope>:file:<path>` (the channel the View's\n * runtime subscribes to). */\nexport interface FileChangeScope {\n scope: string;\n matches: (posixPath: string) => boolean;\n}\n\nexport interface FileChangePublisherConfig {\n /** Host pubsub publish. */\n publish: (channel: string, payload: FileChannelPayload) => void;\n /** Workspace root — joined with the relative path to stat the post-write mtime. */\n workspaceRoot: string;\n /** Normalise a workspace-relative path to POSIX (drives both `payload.path` and\n * every channel suffix, so they can't drift on mixed separators). */\n toPosix: (relativePath: string) => string;\n /** The host's primary file channel (e.g. a Files explorer's `file:<path>`).\n * Omit when the host has no general file subscriber (e.g. MulmoTerminal). */\n primaryChannel?: (posixPath: string) => string;\n /** Plugin Views to forward to. */\n pluginScopes?: FileChangeScope[];\n /** Optional side-effect after publishing (e.g. a topic-index regen). Receives the\n * posix path + payload; may itself call `publishFileChange` for derived files. */\n onPublished?: (posixPath: string, payload: FileChannelPayload) => void;\n /** Optional warn logger; a publish/stat failure logs but never throws (callers\n * fire-and-forget, so a throw would be an unhandled rejection). */\n warn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nlet config: FileChangePublisherConfig | null = null;\n\n/** Wire the publisher to a host. Call once at startup, before any write route. */\nexport function configureFileChangePublisher(cfg: FileChangePublisherConfig): void {\n config = cfg;\n}\n\n/** Clear the binding — test-only. */\nexport function resetFileChangePublisher(): void {\n config = null;\n}\n\n/** The plugin-scoped live-refresh channel a View subscribes to. */\nexport function pluginFileChannel(scope: string, posixPath: string): string {\n return `plugin:${scope}:file:${posixPath}`;\n}\n\n/** Publish a file-change for a workspace-relative path: the primary channel (if any)\n * + every matching plugin scope, with the post-write mtime. No-op until configured. */\nexport async function publishFileChange(relativePath: string): Promise<void> {\n const cfg = config;\n if (!cfg) return;\n // `relativePath` comes from the host's write routes. Contain it before doing\n // anything: a path that escapes the workspace is dropped entirely — we\n // neither stat an arbitrary file nor broadcast an out-of-workspace path to\n // subscribers or host side-effects. Defence-in-depth, since this is the\n // shared package both hosts use. `root` carries a trailing separator and the\n // guard is a single `startsWith` early-return — the canonical containment\n // shape (so the check is unambiguous for both readers and static analysis).\n const root = path.resolve(cfg.workspaceRoot) + path.sep;\n const absPath = path.join(root, relativePath);\n if (!absPath.startsWith(root)) {\n cfg.warn?.(\"ignoring file-change for path outside workspace\", { path: relativePath });\n return;\n }\n let mtimeMs: number;\n try {\n ({ mtimeMs } = await stat(absPath));\n } catch (err) {\n cfg.warn?.(\"stat failed; falling back to Date.now()\", { path: relativePath, error: errMsg(err) });\n mtimeMs = Date.now();\n }\n const posixPath = cfg.toPosix(relativePath);\n const payload: FileChannelPayload = { path: posixPath, mtimeMs };\n\n if (cfg.primaryChannel) {\n safePublish(cfg, cfg.primaryChannel(posixPath), payload, \"primary publish failed\");\n }\n for (const { scope, matches } of cfg.pluginScopes ?? []) {\n if (!matches(posixPath)) continue;\n safePublish(cfg, pluginFileChannel(scope, posixPath), payload, `${scope} plugin forward failed`);\n }\n cfg.onPublished?.(posixPath, payload);\n}\n\nfunction safePublish(cfg: FileChangePublisherConfig, channel: string, payload: FileChannelPayload, failMsg: string): void {\n try {\n cfg.publish(channel, payload);\n } catch (err) {\n cfg.warn?.(`${failMsg}; subscribers will miss this event`, { channel, error: errMsg(err) });\n }\n}\n\nfunction errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n"],"mappings":";;;;;;AAmDA,IAAI,SAA2C;;AAG/C,SAAgB,6BAA6B,KAAsC;CACjF,SAAS;AACX;;AAGA,SAAgB,2BAAiC;CAC/C,SAAS;AACX;;AAGA,SAAgB,kBAAkB,OAAe,WAA2B;CAC1E,OAAO,UAAU,MAAM,QAAQ;AACjC;;;AAIA,eAAsB,kBAAkB,cAAqC;CAC3E,MAAM,MAAM;CACZ,IAAI,CAAC,KAAK;CAQV,MAAM,OAAOA,UAAAA,QAAK,QAAQ,IAAI,aAAa,IAAIA,UAAAA,QAAK;CACpD,MAAM,UAAUA,UAAAA,QAAK,KAAK,MAAM,YAAY;CAC5C,IAAI,CAAC,QAAQ,WAAW,IAAI,GAAG;EAC7B,IAAI,OAAO,mDAAmD,EAAE,MAAM,aAAa,CAAC;EACpF;CACF;CACA,IAAI;CACJ,IAAI;EACF,CAAC,CAAE,WAAY,OAAA,GAAMC,iBAAAA,KAAAA,CAAK,OAAO;CACnC,SAAS,KAAK;EACZ,IAAI,OAAO,2CAA2C;GAAE,MAAM;GAAc,OAAO,OAAO,GAAG;EAAE,CAAC;EAChG,UAAU,KAAK,IAAI;CACrB;CACA,MAAM,YAAY,IAAI,QAAQ,YAAY;CAC1C,MAAM,UAA8B;EAAE,MAAM;EAAW;CAAQ;CAE/D,IAAI,IAAI,gBACN,YAAY,KAAK,IAAI,eAAe,SAAS,GAAG,SAAS,wBAAwB;CAEnF,KAAK,MAAM,EAAE,OAAO,aAAa,IAAI,gBAAgB,CAAC,GAAG;EACvD,IAAI,CAAC,QAAQ,SAAS,GAAG;EACzB,YAAY,KAAK,kBAAkB,OAAO,SAAS,GAAG,SAAS,GAAG,MAAM,uBAAuB;CACjG;CACA,IAAI,cAAc,WAAW,OAAO;AACtC;AAEA,SAAS,YAAY,KAAgC,SAAiB,SAA6B,SAAuB;CACxH,IAAI;EACF,IAAI,QAAQ,SAAS,OAAO;CAC9B,SAAS,KAAK;EACZ,IAAI,OAAO,GAAG,QAAQ,qCAAqC;GAAE;GAAS,OAAO,OAAO,GAAG;EAAE,CAAC;CAC5F;AACF;AAEA,SAAS,OAAO,KAAsB;CACpC,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD"}
|
package/dist/files/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/files/json.ts","../../src/files/safe.ts","../../src/files/byPath.ts","../../src/files/htmlFileRequest.ts"],"sourcesContent":["import { writeFileAtomic, type WriteAtomicOptions } from \"./atomic.js\";\n\nconst JSON_INDENT = 2;\n\n/** Atomic JSON write (2-space indent) — serialize then write through the atomic\n * tmp-file + rename seam so a reader never sees a half-written document. */\nexport async function writeJsonAtomic(filePath: string, data: unknown, opts: WriteAtomicOptions = {}): Promise<void> {\n await writeFileAtomic(filePath, JSON.stringify(data, null, JSON_INDENT), opts);\n}\n","import { realpathSync } from \"node:fs\";\nimport path from \"node:path\";\n\n/** True for a `not found` filesystem error (ENOENT) — lets callers treat a\n * missing file as an empty/default result instead of a thrown error. */\nexport function isEnoent(err: unknown): boolean {\n return typeof err === \"object\" && err !== null && \"code\" in err && err.code === \"ENOENT\";\n}\n\n/** Realpath-based read-time containment: resolve `relPath` against the root's\n * realpath and require the target's realpath to stay inside it. Returns null\n * on ENOENT or traversal (symlink escapes included). `rootReal` MUST already\n * be a realpath. The one implementation for host and plugins (#2461) — this\n * security-critical primitive must not drift per consumer. */\nexport function resolveWithinRoot(rootReal: string, relPath: string): string | null {\n const normalized = path.normalize(relPath || \"\");\n const resolved = path.resolve(rootReal, normalized);\n const resolvedReal = realpathOrNull(resolved);\n if (resolvedReal === null) return null;\n if (resolvedReal !== rootReal && !resolvedReal.startsWith(rootReal + path.sep)) {\n return null;\n }\n return resolvedReal;\n}\n\nfunction realpathOrNull(absPath: string): string | null {\n try {\n return realpathSync(absPath);\n } catch {\n return null;\n }\n}\n","// Read / overwrite files the TOOL CALL named, rather than files the app minted.\n// `presentDocument(path)` / `presentHtml(path)` open a document that already\n// exists — a repo's `README.md`, `docs/report.html`, an absolute path — and the\n// user's edits in the view overwrite it in place.\n//\n// Shared by both hosts (MulmoClaude's `server/utils/files/by-path.ts` and\n// MulmoTerminal's backend bind their own workspace root to it), because the\n// judgement of what a `path` argument may reach is exactly the thing that must\n// not drift between them: a host that accepts what the other refuses turns one\n// tool call into two different behaviours.\n//\n// The rules:\n// - `classifyFilePath` (also what the plugins' `path` gate calls) decides the\n// shape: right extension, no NUL, no `.` / `..` / empty segment. That\n// lexical guard is what stops a vetted path from being re-pointed later.\n// - relative paths resolve against the injected root; absolute paths are taken\n// as given. There is deliberately NO containment check — opening a file\n// outside the workspace is the documented purpose of the `path` form. The\n// agent can already read and write those files directly; what is new is that\n// the view can too.\n// - a value that is absolute only under ANOTHER platform's rules is refused:\n// `classifyFilePath` recognises `C:\\proj\\x.md` everywhere (the value may\n// come from a remote host), but on POSIX `path.resolve(\"C:/proj/x.md\")`\n// lands under the process cwd — a file nobody named.\n// - reads and writes require a REGULAR FILE, judged through `realpath` so a\n// symlink is assessed by what it points at and a directory named `x.md`\n// cannot masquerade as a document.\n// - `write` overwrites only. Neither tool creates a file at a caller-supplied\n// path, so a write to a path that does not exist means the view is stale or\n// the path was wrong — refusing keeps a typo from scattering files.\n// - `readDir` and `unlink` are refused outright: this capability exists to\n// read and re-save ONE named document, not to browse or delete.\n\nimport { readFile, realpath, stat as fsStat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { classifyFilePath } from \"../artifacts/paths.js\";\nimport { writeFileAtomic } from \"./atomic.js\";\n\nexport const MARKDOWN_EXTENSIONS = [\".md\"] as const;\nexport const HTML_EXTENSIONS = [\".html\", \".htm\"] as const;\n\n/** Minimal structural echo of gui-chat-protocol's `FileOps`. Declared here\n * rather than imported so core keeps no dependency on the protocol package;\n * a host assigns the result straight to a `FileOps`-typed capability. */\nexport interface ByPathFileOps {\n read: (rel: string) => Promise<string>;\n readBytes: (rel: string) => Promise<Uint8Array>;\n write: (rel: string, content: string | Uint8Array) => Promise<void>;\n readDir: (rel: string) => Promise<string[]>;\n stat: (rel: string) => Promise<{ mtimeMs: number; size: number }>;\n exists: (rel: string) => Promise<boolean>;\n unlink: (rel: string) => Promise<void>;\n}\n\n/** Absolute on-disk path for a caller-supplied path with one of `extensions`,\n * or null when the value is not usable on this platform. */\nexport function resolveByPath(root: string, value: string, extensions: readonly string[]): string | null {\n const kind = classifyFilePath(value, extensions);\n if (kind === null) return null;\n if (kind === \"relative\") return path.resolve(root, value);\n return path.isAbsolute(value) ? path.resolve(value) : null;\n}\n\n/** True when the path names an existing regular file. */\nexport async function existsAsFile(root: string, value: string, extensions: readonly string[]): Promise<boolean> {\n const absPath = resolveByPath(root, value, extensions);\n return absPath === null ? false : (await regularFileTarget(absPath)) !== null;\n}\n\nexport interface ByPathOptions {\n /** The root relative values resolve against, read PER CALL — hosts inject the\n * workspace after these ops are already bound into plugin closures. */\n rootFor: () => string;\n extensions: readonly string[];\n}\n\nfunction resolveOrThrow({ rootFor, extensions }: ByPathOptions, value: string): string {\n const absPath = resolveByPath(rootFor(), value, extensions);\n if (absPath === null) throw new Error(`invalid path: ${value}`);\n return absPath;\n}\n\n/** The canonical REGULAR FILE a path names, or null when it is missing, is a\n * directory, or is anything else (a FIFO would block a read forever).\n * Resolved through `realpath` so a symlink is judged — and later written — by\n * what it points at: `writeFileAtomic` renames a temp file into place, which\n * through a link would replace the link itself and leave the real document\n * untouched. */\nasync function regularFileTarget(absPath: string): Promise<string | null> {\n try {\n const target = await realpath(absPath);\n return (await fsStat(target)).isFile() ? target : null;\n } catch {\n return null;\n }\n}\n\n/** The file to read or overwrite. Throws rather than creating: neither tool\n * ever writes to a path that does not already hold a document. */\nasync function existingFileFor(options: ByPathOptions, rel: string): Promise<string> {\n const target = await regularFileTarget(resolveOrThrow(options, rel));\n if (target === null) throw new Error(`no file exists at ${rel}`);\n return target;\n}\n\n/** Browsing and deleting are NOT part of this capability: it exists so a view\n * can read and re-save the ONE document the tool call named, not to enumerate\n * a directory it was never pointed at or remove a file. */\nfunction unsupported(operation: string): () => Promise<never> {\n return () => Promise.reject(new Error(`byPath FileOps does not support ${operation}`));\n}\n\n/** FileOps over caller-supplied paths — what a host injects as `files.byPath`\n * for plugins whose `path` argument may leave their artifact directory. Every\n * method takes the same value the tool call carried, not a scope-relative one. */\nexport function createByPathFileOps(options: ByPathOptions): ByPathFileOps {\n return {\n read: async (rel) => readFile(await existingFileFor(options, rel), \"utf-8\"),\n readBytes: async (rel) => {\n const buf = await readFile(await existingFileFor(options, rel));\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n },\n write: async (rel, content) => writeFileAtomic(await existingFileFor(options, rel), content),\n readDir: unsupported(\"readDir\"),\n stat: async (rel) => {\n const { mtimeMs, size } = await fsStat(resolveOrThrow(options, rel));\n return { mtimeMs, size };\n },\n exists: (rel) => existsAsFile(options.rootFor(), rel, options.extensions),\n unlink: unsupported(\"unlink\"),\n };\n}\n","// Server half of the `/htmlfile` scheme (client half: `src/config/htmlFileUrl.ts`).\n// Turns a request path into the absolute file to serve, or null.\n//\n// Shared by both hosts so the URL the View asks for and the file a host resolves\n// cannot drift apart.\n//\n// This mount is deliberately NOT contained to a root: presentHtml's `path` form\n// may name any HTML page on disk, and serving it is the point. What still holds:\n//\n// - only `ws` (workspace-relative) and `abs` (absolute) scopes exist; anything\n// else 404s, so a bare `/htmlfile/etc/passwd` is not a path at all.\n// - no `.` / `..` / empty segment survives, so a vetted URL cannot be\n// re-pointed by traversal, and no dotfile segment is reachable (same policy\n// as `resolveArtifactRequestPath`, which the artifact mounts share).\n// - extension allowlist, realpath and regular-file checks stay with the host's\n// mount, which is also where the CSP header and rate limit are applied.\n//\n// The trust boundary is whatever the host applies to its `/artifacts/html`\n// mount — in MulmoClaude, the loopback-only listener plus `requireSameOrigin`\n// (bearer auth does not apply outside `/api`, and an iframe `src` request cannot\n// carry an Authorization header anyway).\n\nimport path from \"node:path\";\n// The scope constants are re-declared rather than imported: core must not\n// depend on a plugin (dependencies flow the other way). `html-plugin`'s\n// `htmlFileUrl` builds the URLs these parse, and the round-trip test in the host\n// is what keeps the two halves honest.\nconst HTML_FILE_SCOPE_WORKSPACE = \"ws\";\nconst HTML_FILE_SCOPE_ABSOLUTE = \"abs\";\n\nconst WINDOWS_DRIVE_ONLY_RE = /^[a-zA-Z]:$/;\n\nfunction decodeSegments(reqPath: string): string[] | null {\n let decoded: string[];\n try {\n decoded = reqPath\n .replace(/^\\//, \"\")\n .split(\"/\")\n .map((segment) => decodeURIComponent(segment));\n } catch {\n // Malformed escape (`%ZZ`) — fail closed rather than bubbling a URIError.\n return null;\n }\n // Segmentation happens on the ENCODED path, so a `%2F` (or `%5C`) becomes a\n // separator only after this decode — `a%2F..%2F..%2Ftmp%2Fx.html` would arrive\n // as one segment that the `..` check below never sees, and then split apart\n // inside `path.resolve`. A decoded segment that still contains a separator is\n // never legitimate here, so refuse it.\n return decoded.some((segment) => segment.includes(\"/\") || segment.includes(\"\\\\\")) ? null : decoded;\n}\n\n/** Absolute path for a `/htmlfile/<scope>/<segments…>` request, or null when\n * the URL is malformed, uses an unknown scope, or touches a `.` / `..` /\n * dotfile segment. `workspaceRoot` should already be a realpath. */\nexport function resolveHtmlFileRequestPath(workspaceRoot: string, reqPath: string): string | null {\n const decoded = decodeSegments(reqPath);\n if (decoded === null || decoded.length < 2) return null;\n const [scope, ...rest] = decoded;\n if (scope !== HTML_FILE_SCOPE_WORKSPACE && scope !== HTML_FILE_SCOPE_ABSOLUTE) return null;\n const [firstRest] = rest;\n if (firstRest === undefined) return null;\n if (rest.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\" || segment.startsWith(\".\") || segment.includes(\"\\0\"))) {\n return null;\n }\n if (scope === HTML_FILE_SCOPE_WORKSPACE) return path.resolve(workspaceRoot, ...rest);\n // `abs/C:/proj/page.html` — the drive letter arrives as its own segment, so\n // rejoin it before resolving; everything else is rooted at `/`.\n const candidate = WINDOWS_DRIVE_ONLY_RE.test(firstRest) ? rest.join(\"/\") : `/${rest.join(\"/\")}`;\n // Only THIS platform's `path` can say whether that is really absolute: on\n // POSIX, `path.resolve(\"C:/proj/page.html\")` would land under the process cwd\n // rather than at any drive, silently serving the wrong file. Refuse instead.\n return path.isAbsolute(candidate) ? path.resolve(candidate) : null;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["writeFileAtomic","path","realpathSync","classifyFilePath","path","realpath","fsStat","readFile","writeFileAtomic","path"],"sources":["../../src/files/json.ts","../../src/files/safe.ts","../../src/files/byPath.ts","../../src/files/htmlFileRequest.ts"],"sourcesContent":["import { writeFileAtomic, type WriteAtomicOptions } from \"./atomic.js\";\n\nconst JSON_INDENT = 2;\n\n/** Atomic JSON write (2-space indent) — serialize then write through the atomic\n * tmp-file + rename seam so a reader never sees a half-written document. */\nexport async function writeJsonAtomic(filePath: string, data: unknown, opts: WriteAtomicOptions = {}): Promise<void> {\n await writeFileAtomic(filePath, JSON.stringify(data, null, JSON_INDENT), opts);\n}\n","import { realpathSync } from \"node:fs\";\nimport path from \"node:path\";\n\n/** True for a `not found` filesystem error (ENOENT) — lets callers treat a\n * missing file as an empty/default result instead of a thrown error. */\nexport function isEnoent(err: unknown): boolean {\n return typeof err === \"object\" && err !== null && \"code\" in err && err.code === \"ENOENT\";\n}\n\n/** Realpath-based read-time containment: resolve `relPath` against the root's\n * realpath and require the target's realpath to stay inside it. Returns null\n * on ENOENT or traversal (symlink escapes included). `rootReal` MUST already\n * be a realpath. The one implementation for host and plugins (#2461) — this\n * security-critical primitive must not drift per consumer. */\nexport function resolveWithinRoot(rootReal: string, relPath: string): string | null {\n const normalized = path.normalize(relPath || \"\");\n const resolved = path.resolve(rootReal, normalized);\n const resolvedReal = realpathOrNull(resolved);\n if (resolvedReal === null) return null;\n if (resolvedReal !== rootReal && !resolvedReal.startsWith(rootReal + path.sep)) {\n return null;\n }\n return resolvedReal;\n}\n\nfunction realpathOrNull(absPath: string): string | null {\n try {\n return realpathSync(absPath);\n } catch {\n return null;\n }\n}\n","// Read / overwrite files the TOOL CALL named, rather than files the app minted.\n// `presentDocument(path)` / `presentHtml(path)` open a document that already\n// exists — a repo's `README.md`, `docs/report.html`, an absolute path — and the\n// user's edits in the view overwrite it in place.\n//\n// Shared by both hosts (MulmoClaude's `server/utils/files/by-path.ts` and\n// MulmoTerminal's backend bind their own workspace root to it), because the\n// judgement of what a `path` argument may reach is exactly the thing that must\n// not drift between them: a host that accepts what the other refuses turns one\n// tool call into two different behaviours.\n//\n// The rules:\n// - `classifyFilePath` (also what the plugins' `path` gate calls) decides the\n// shape: right extension, no NUL, no `.` / `..` / empty segment. That\n// lexical guard is what stops a vetted path from being re-pointed later.\n// - relative paths resolve against the injected root; absolute paths are taken\n// as given. There is deliberately NO containment check — opening a file\n// outside the workspace is the documented purpose of the `path` form. The\n// agent can already read and write those files directly; what is new is that\n// the view can too.\n// - a value that is absolute only under ANOTHER platform's rules is refused:\n// `classifyFilePath` recognises `C:\\proj\\x.md` everywhere (the value may\n// come from a remote host), but on POSIX `path.resolve(\"C:/proj/x.md\")`\n// lands under the process cwd — a file nobody named.\n// - reads and writes require a REGULAR FILE, judged through `realpath` so a\n// symlink is assessed by what it points at and a directory named `x.md`\n// cannot masquerade as a document.\n// - `write` overwrites only. Neither tool creates a file at a caller-supplied\n// path, so a write to a path that does not exist means the view is stale or\n// the path was wrong — refusing keeps a typo from scattering files.\n// - `readDir` and `unlink` are refused outright: this capability exists to\n// read and re-save ONE named document, not to browse or delete.\n\nimport { readFile, realpath, stat as fsStat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { classifyFilePath } from \"../artifacts/paths.js\";\nimport { writeFileAtomic } from \"./atomic.js\";\n\nexport const MARKDOWN_EXTENSIONS = [\".md\"] as const;\nexport const HTML_EXTENSIONS = [\".html\", \".htm\"] as const;\n\n/** Minimal structural echo of gui-chat-protocol's `FileOps`. Declared here\n * rather than imported so core keeps no dependency on the protocol package;\n * a host assigns the result straight to a `FileOps`-typed capability. */\nexport interface ByPathFileOps {\n read: (rel: string) => Promise<string>;\n readBytes: (rel: string) => Promise<Uint8Array>;\n write: (rel: string, content: string | Uint8Array) => Promise<void>;\n readDir: (rel: string) => Promise<string[]>;\n stat: (rel: string) => Promise<{ mtimeMs: number; size: number }>;\n exists: (rel: string) => Promise<boolean>;\n unlink: (rel: string) => Promise<void>;\n}\n\n/** Absolute on-disk path for a caller-supplied path with one of `extensions`,\n * or null when the value is not usable on this platform. */\nexport function resolveByPath(root: string, value: string, extensions: readonly string[]): string | null {\n const kind = classifyFilePath(value, extensions);\n if (kind === null) return null;\n if (kind === \"relative\") return path.resolve(root, value);\n return path.isAbsolute(value) ? path.resolve(value) : null;\n}\n\n/** True when the path names an existing regular file. */\nexport async function existsAsFile(root: string, value: string, extensions: readonly string[]): Promise<boolean> {\n const absPath = resolveByPath(root, value, extensions);\n return absPath === null ? false : (await regularFileTarget(absPath)) !== null;\n}\n\nexport interface ByPathOptions {\n /** The root relative values resolve against, read PER CALL — hosts inject the\n * workspace after these ops are already bound into plugin closures. */\n rootFor: () => string;\n extensions: readonly string[];\n}\n\nfunction resolveOrThrow({ rootFor, extensions }: ByPathOptions, value: string): string {\n const absPath = resolveByPath(rootFor(), value, extensions);\n if (absPath === null) throw new Error(`invalid path: ${value}`);\n return absPath;\n}\n\n/** The canonical REGULAR FILE a path names, or null when it is missing, is a\n * directory, or is anything else (a FIFO would block a read forever).\n * Resolved through `realpath` so a symlink is judged — and later written — by\n * what it points at: `writeFileAtomic` renames a temp file into place, which\n * through a link would replace the link itself and leave the real document\n * untouched. */\nasync function regularFileTarget(absPath: string): Promise<string | null> {\n try {\n const target = await realpath(absPath);\n return (await fsStat(target)).isFile() ? target : null;\n } catch {\n return null;\n }\n}\n\n/** The file to read or overwrite. Throws rather than creating: neither tool\n * ever writes to a path that does not already hold a document. */\nasync function existingFileFor(options: ByPathOptions, rel: string): Promise<string> {\n const target = await regularFileTarget(resolveOrThrow(options, rel));\n if (target === null) throw new Error(`no file exists at ${rel}`);\n return target;\n}\n\n/** Browsing and deleting are NOT part of this capability: it exists so a view\n * can read and re-save the ONE document the tool call named, not to enumerate\n * a directory it was never pointed at or remove a file. */\nfunction unsupported(operation: string): () => Promise<never> {\n return () => Promise.reject(new Error(`byPath FileOps does not support ${operation}`));\n}\n\n/** FileOps over caller-supplied paths — what a host injects as `files.byPath`\n * for plugins whose `path` argument may leave their artifact directory. Every\n * method takes the same value the tool call carried, not a scope-relative one. */\nexport function createByPathFileOps(options: ByPathOptions): ByPathFileOps {\n return {\n read: async (rel) => readFile(await existingFileFor(options, rel), \"utf-8\"),\n readBytes: async (rel) => {\n const buf = await readFile(await existingFileFor(options, rel));\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n },\n write: async (rel, content) => writeFileAtomic(await existingFileFor(options, rel), content),\n readDir: unsupported(\"readDir\"),\n stat: async (rel) => {\n const { mtimeMs, size } = await fsStat(resolveOrThrow(options, rel));\n return { mtimeMs, size };\n },\n exists: (rel) => existsAsFile(options.rootFor(), rel, options.extensions),\n unlink: unsupported(\"unlink\"),\n };\n}\n","// Server half of the `/htmlfile` scheme (client half: `src/config/htmlFileUrl.ts`).\n// Turns a request path into the absolute file to serve, or null.\n//\n// Shared by both hosts so the URL the View asks for and the file a host resolves\n// cannot drift apart.\n//\n// This mount is deliberately NOT contained to a root: presentHtml's `path` form\n// may name any HTML page on disk, and serving it is the point. What still holds:\n//\n// - only `ws` (workspace-relative) and `abs` (absolute) scopes exist; anything\n// else 404s, so a bare `/htmlfile/etc/passwd` is not a path at all.\n// - no `.` / `..` / empty segment survives, so a vetted URL cannot be\n// re-pointed by traversal, and no dotfile segment is reachable (same policy\n// as `resolveArtifactRequestPath`, which the artifact mounts share).\n// - extension allowlist, realpath and regular-file checks stay with the host's\n// mount, which is also where the CSP header and rate limit are applied.\n//\n// The trust boundary is whatever the host applies to its `/artifacts/html`\n// mount — in MulmoClaude, the loopback-only listener plus `requireSameOrigin`\n// (bearer auth does not apply outside `/api`, and an iframe `src` request cannot\n// carry an Authorization header anyway).\n\nimport path from \"node:path\";\n// The scope constants are re-declared rather than imported: core must not\n// depend on a plugin (dependencies flow the other way). `html-plugin`'s\n// `htmlFileUrl` builds the URLs these parse, and the round-trip test in the host\n// is what keeps the two halves honest.\nconst HTML_FILE_SCOPE_WORKSPACE = \"ws\";\nconst HTML_FILE_SCOPE_ABSOLUTE = \"abs\";\n\nconst WINDOWS_DRIVE_ONLY_RE = /^[a-zA-Z]:$/;\n\nfunction decodeSegments(reqPath: string): string[] | null {\n let decoded: string[];\n try {\n decoded = reqPath\n .replace(/^\\//, \"\")\n .split(\"/\")\n .map((segment) => decodeURIComponent(segment));\n } catch {\n // Malformed escape (`%ZZ`) — fail closed rather than bubbling a URIError.\n return null;\n }\n // Segmentation happens on the ENCODED path, so a `%2F` (or `%5C`) becomes a\n // separator only after this decode — `a%2F..%2F..%2Ftmp%2Fx.html` would arrive\n // as one segment that the `..` check below never sees, and then split apart\n // inside `path.resolve`. A decoded segment that still contains a separator is\n // never legitimate here, so refuse it.\n return decoded.some((segment) => segment.includes(\"/\") || segment.includes(\"\\\\\")) ? null : decoded;\n}\n\n/** Absolute path for a `/htmlfile/<scope>/<segments…>` request, or null when\n * the URL is malformed, uses an unknown scope, or touches a `.` / `..` /\n * dotfile segment. `workspaceRoot` should already be a realpath. */\nexport function resolveHtmlFileRequestPath(workspaceRoot: string, reqPath: string): string | null {\n const decoded = decodeSegments(reqPath);\n if (decoded === null || decoded.length < 2) return null;\n const [scope, ...rest] = decoded;\n if (scope !== HTML_FILE_SCOPE_WORKSPACE && scope !== HTML_FILE_SCOPE_ABSOLUTE) return null;\n const [firstRest] = rest;\n if (firstRest === undefined) return null;\n if (rest.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\" || segment.startsWith(\".\") || segment.includes(\"\\0\"))) {\n return null;\n }\n if (scope === HTML_FILE_SCOPE_WORKSPACE) return path.resolve(workspaceRoot, ...rest);\n // `abs/C:/proj/page.html` — the drive letter arrives as its own segment, so\n // rejoin it before resolving; everything else is rooted at `/`.\n const candidate = WINDOWS_DRIVE_ONLY_RE.test(firstRest) ? rest.join(\"/\") : `/${rest.join(\"/\")}`;\n // Only THIS platform's `path` can say whether that is really absolute: on\n // POSIX, `path.resolve(\"C:/proj/page.html\")` would land under the process cwd\n // rather than at any drive, silently serving the wrong file. Refuse instead.\n return path.isAbsolute(candidate) ? path.resolve(candidate) : null;\n}\n"],"mappings":"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"}
|