@mulmoclaude/core 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/assets/helps/custom-view.md +32 -11
  2. package/dist/collection/server/index.cjs +1 -1
  3. package/dist/collection/server/index.js +1 -1
  4. package/dist/collection/server/io.d.ts +12 -6
  5. package/dist/collection-watchers/index.cjs +1 -1
  6. package/dist/collection-watchers/index.js +1 -1
  7. package/dist/feeds/index.cjs +17 -0
  8. package/dist/feeds/index.d.ts +2 -0
  9. package/dist/feeds/index.js +4 -0
  10. package/dist/feeds/ingestTypes.d.ts +57 -0
  11. package/dist/feeds/paths.cjs +48 -0
  12. package/dist/feeds/paths.cjs.map +1 -0
  13. package/dist/feeds/paths.d.ts +22 -0
  14. package/dist/feeds/paths.js +38 -0
  15. package/dist/feeds/paths.js.map +1 -0
  16. package/dist/feeds/server/agentIngest.d.ts +13 -0
  17. package/dist/feeds/server/engine.d.ts +12 -0
  18. package/dist/feeds/server/fetch/httpClient.d.ts +8 -0
  19. package/dist/feeds/server/fetch/rssParser.d.ts +12 -0
  20. package/dist/feeds/server/host.d.ts +51 -0
  21. package/dist/feeds/server/index.cjs +804 -0
  22. package/dist/feeds/server/index.cjs.map +1 -0
  23. package/dist/feeds/server/index.d.ts +7 -0
  24. package/dist/feeds/server/index.js +782 -0
  25. package/dist/feeds/server/index.js.map +1 -0
  26. package/dist/feeds/server/pathResolver.d.ts +6 -0
  27. package/dist/feeds/server/projectItem.d.ts +8 -0
  28. package/dist/feeds/server/refreshResult.d.ts +17 -0
  29. package/dist/feeds/server/registry.d.ts +14 -0
  30. package/dist/feeds/server/retrievers/httpJson.d.ts +3 -0
  31. package/dist/feeds/server/retrievers/index.d.ts +12 -0
  32. package/dist/feeds/server/retrievers/registerAll.d.ts +0 -0
  33. package/dist/feeds/server/retrievers/rss.d.ts +3 -0
  34. package/dist/feeds/server/state.d.ts +28 -0
  35. package/dist/ingestTypes-B8DaQOTy.js +15 -0
  36. package/dist/ingestTypes-B8DaQOTy.js.map +1 -0
  37. package/dist/ingestTypes-DDOVhOYC.cjs +32 -0
  38. package/dist/ingestTypes-DDOVhOYC.cjs.map +1 -0
  39. package/dist/{server-BPDCdvOI.js → server-B2YkcCwl.js} +62 -28
  40. package/dist/server-B2YkcCwl.js.map +1 -0
  41. package/dist/{server-Bego8VyU.cjs → server-D27UzXug.cjs} +62 -28
  42. package/dist/server-D27UzXug.cjs.map +1 -0
  43. package/dist/whisper/index.cjs +1 -1
  44. package/dist/whisper/index.js +1 -1
  45. package/dist/workspace-setup/index.js +5 -1
  46. package/dist/workspace-setup/index.js.map +1 -1
  47. package/package.json +20 -1
  48. package/dist/server-BPDCdvOI.js.map +0 -1
  49. package/dist/server-Bego8VyU.cjs.map +0 -1
@@ -0,0 +1,782 @@
1
+ import { a as FEED_SCHEDULES, o as INGEST_KINDS } from "../../deriveAll-vzIhhKBK.js";
2
+ import { A as readSkillTemplate, B as safeSlugName, D as listItems, L as resolveDataDir, M as writeItem, T as deleteItem, g as discoverCollections, w as buildCollectionActionSeedPrompt } from "../../server-B2YkcCwl.js";
3
+ import { n as DEFAULT_FEED_MAX_ITEMS, r as isFeedSchedule, t as AGENT_INGEST_KIND } from "../../ingestTypes-B8DaQOTy.js";
4
+ import { FEEDS_DIR, feedDir, feedStatePath, feedsRoot, ingestStateDir, ingestStatePath } from "../paths.js";
5
+ import { d as publish, n as clear } from "../../notifier-ChpY0XrY.js";
6
+ import path from "node:path";
7
+ import { mkdir, readFile, rm } from "node:fs/promises";
8
+ import { createHash } from "node:crypto";
9
+ import { lookup } from "node:dns/promises";
10
+ import { isIP } from "node:net";
11
+ import { XMLParser } from "fast-xml-parser";
12
+ //#region src/feeds/server/host.ts
13
+ var current = null;
14
+ /** Wire the feeds engine to a host. Call once at startup, before any refresh. */
15
+ function configureFeedsHost(host) {
16
+ if (current && current !== host) throw new Error("@mulmoclaude/core/feeds: configureFeedsHost() was already called with a different host");
17
+ current = host;
18
+ }
19
+ /** The configured host, or throw if `configureFeedsHost` was never called. */
20
+ function requireFeedsHost() {
21
+ if (!current) throw new Error("@mulmoclaude/core/feeds: configureFeedsHost() was not called by the host");
22
+ return current;
23
+ }
24
+ /** Test-only: clear the configured host. */
25
+ function resetFeedsHostForTesting() {
26
+ current = null;
27
+ }
28
+ /** Forwarding logger so engine modules can `import { log }` without each
29
+ * reaching for `requireFeedsHost().log`. */
30
+ var log = {
31
+ error: (prefix, msg, data) => requireFeedsHost().log.error(prefix, msg, data),
32
+ warn: (prefix, msg, data) => requireFeedsHost().log.warn(prefix, msg, data),
33
+ info: (prefix, msg, data) => requireFeedsHost().log.info(prefix, msg, data),
34
+ debug: (prefix, msg, data) => requireFeedsHost().log.debug(prefix, msg, data)
35
+ };
36
+ //#endregion
37
+ //#region src/feeds/server/registry.ts
38
+ /** Every registered feed, as a discovered collection (carrying its
39
+ * validated schema, `ingest`, and resolved `dataDir`). */
40
+ async function listFeeds(workspaceRoot = requireFeedsHost().workspaceRoot) {
41
+ return (await discoverCollections({ workspaceRoot })).filter((collection) => collection.source === "feed");
42
+ }
43
+ /** Delete a feed entirely: its records AND its `feeds/<slug>/` directory
44
+ * (schema + state). Idempotent. Host-side only (backs the UI delete
45
+ * button); the agent removes a feed by deleting both directories itself.
46
+ *
47
+ * The records dir is derived from the SLUG (`data/feeds/<slug>`), never
48
+ * from the schema's `dataPath` — feeds are forced into that namespace at
49
+ * discovery, so a malformed/hostile `dataPath` can't redirect this delete
50
+ * at another app's data (e.g. `data/wiki`). `resolveDataDir` also rejects
51
+ * any path that escapes the workspace. */
52
+ async function removeFeed(workspaceRoot, slug) {
53
+ const safe = safeSlugName(slug);
54
+ if (safe === null) return false;
55
+ const recordsDir = resolveDataDir(`data/feeds/${safe}`, workspaceRoot);
56
+ try {
57
+ if (recordsDir) await rm(recordsDir, {
58
+ recursive: true,
59
+ force: true
60
+ });
61
+ await rm(feedDir(safe, workspaceRoot), {
62
+ recursive: true,
63
+ force: true
64
+ });
65
+ log.info("feeds", "feed + records removed", { slug: safe });
66
+ return true;
67
+ } catch (error) {
68
+ log.warn("feeds", "feed remove failed", {
69
+ slug: safe,
70
+ error: String(error)
71
+ });
72
+ return false;
73
+ }
74
+ }
75
+ //#endregion
76
+ //#region src/feeds/server/retrievers/index.ts
77
+ var registry = /* @__PURE__ */ new Map();
78
+ function registerRetriever(kind, retriever) {
79
+ registry.set(kind, retriever);
80
+ }
81
+ function getRetriever(kind) {
82
+ return registry.get(kind);
83
+ }
84
+ //#endregion
85
+ //#region src/feeds/server/fetch/httpClient.ts
86
+ var ONE_SECOND_MS = 1e3;
87
+ /** Identifies the bot to site operators. */
88
+ var FEED_USER_AGENT = "MulmoClaude-FeedBot/1.0 (+https://github.com/receptron/mulmoclaude)";
89
+ /** Per-request wall-clock cap so a hung server can't wedge a refresh. */
90
+ var DEFAULT_FEED_TIMEOUT_MS = 30 * ONE_SECOND_MS;
91
+ /** Cap on redirect hops followed (each re-checked for SSRF). */
92
+ var MAX_REDIRECTS = 5;
93
+ var BLOCKED_V4_CIDRS = [
94
+ ["0.0.0.0", 8],
95
+ ["10.0.0.0", 8],
96
+ ["100.64.0.0", 10],
97
+ ["127.0.0.0", 8],
98
+ ["169.254.0.0", 16],
99
+ ["172.16.0.0", 12],
100
+ ["192.168.0.0", 16]
101
+ ];
102
+ function ipv4ToInt(address) {
103
+ const octets = address.split(".").map(Number);
104
+ if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
105
+ return (octets[0] << 24 | octets[1] << 16 | octets[2] << 8 | octets[3]) >>> 0;
106
+ }
107
+ function isBlockedIpv4(address) {
108
+ const value = ipv4ToInt(address);
109
+ if (value === null) return true;
110
+ return BLOCKED_V4_CIDRS.some(([base, bits]) => {
111
+ const baseInt = ipv4ToInt(base) ?? 0;
112
+ const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
113
+ return (value & mask) === (baseInt & mask);
114
+ });
115
+ }
116
+ function isBlockedIpv6(address) {
117
+ const lower = address.toLowerCase();
118
+ if (lower === "::1" || lower === "::") return true;
119
+ if (lower.startsWith("fe80")) return true;
120
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
121
+ const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(lower);
122
+ return mapped ? isBlockedIpv4(mapped[1]) : false;
123
+ }
124
+ /** True for any address we must not fetch (also blocks non-IP input). */
125
+ function isBlockedIp(address) {
126
+ const kind = isIP(address);
127
+ if (kind === 4) return isBlockedIpv4(address);
128
+ if (kind === 6) return isBlockedIpv6(address);
129
+ return true;
130
+ }
131
+ /** Reject non-http(s) URLs and URLs that resolve to a private/loopback
132
+ * address (SSRF guard). Throws with a clear reason; returns void on pass. */
133
+ async function assertFetchableUrl(rawUrl) {
134
+ if (!/^https?:\/\//i.test(rawUrl)) throw new Error(`refusing non-http(s) URL: ${rawUrl}`);
135
+ const { hostname } = new URL(rawUrl);
136
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
137
+ if (isIP(host)) {
138
+ if (isBlockedIp(host)) throw new Error(`refusing to fetch a private/loopback address: ${host}`);
139
+ return;
140
+ }
141
+ let addresses;
142
+ try {
143
+ addresses = await lookup(host, { all: true });
144
+ } catch (err) {
145
+ throw new Error(`could not resolve host '${host}': ${String(err)}`);
146
+ }
147
+ const blocked = addresses.find((entry) => isBlockedIp(entry.address));
148
+ if (blocked) throw new Error(`refusing to fetch '${host}' — resolves to a private/loopback address (${blocked.address})`);
149
+ }
150
+ async function fetchOnce(url, timeoutMs) {
151
+ const controller = new AbortController();
152
+ const timer = setTimeout(() => controller.abort(new DOMException(`feed fetch timed out after ${timeoutMs}ms`, "TimeoutError")), timeoutMs);
153
+ try {
154
+ return await fetch(url, {
155
+ headers: { "User-Agent": FEED_USER_AGENT },
156
+ signal: controller.signal,
157
+ redirect: "manual"
158
+ });
159
+ } finally {
160
+ clearTimeout(timer);
161
+ }
162
+ }
163
+ async function fetchGuarded(rawUrl, timeoutMs) {
164
+ let current = rawUrl;
165
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
166
+ await assertFetchableUrl(current);
167
+ const response = await fetchOnce(current, timeoutMs);
168
+ const redirect = response.status >= 300 && response.status < 400 && response.status !== 304 ? response.headers.get("location") : null;
169
+ if (!redirect) return response;
170
+ current = new URL(redirect, current).toString();
171
+ }
172
+ throw new Error(`too many redirects (>${MAX_REDIRECTS}) starting from ${rawUrl}`);
173
+ }
174
+ /** Fetch a URL as text, throwing on guard rejection, network error, or non-2xx. */
175
+ async function fetchText(url, timeoutMs = DEFAULT_FEED_TIMEOUT_MS) {
176
+ const response = await fetchGuarded(url, timeoutMs);
177
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);
178
+ return response.text();
179
+ }
180
+ /** Fetch a URL as parsed JSON, throwing on guard rejection, network error, or non-2xx. */
181
+ async function fetchJson(url, timeoutMs = DEFAULT_FEED_TIMEOUT_MS) {
182
+ const response = await fetchGuarded(url, timeoutMs);
183
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);
184
+ return response.json();
185
+ }
186
+ //#endregion
187
+ //#region src/feeds/server/fetch/rssParser.ts
188
+ function isRecord(value) {
189
+ return typeof value === "object" && value !== null && !Array.isArray(value);
190
+ }
191
+ function isNonEmptyString(value) {
192
+ return typeof value === "string" && value.trim().length > 0;
193
+ }
194
+ var xml = new XMLParser({
195
+ ignoreAttributes: false,
196
+ attributeNamePrefix: "@_",
197
+ cdataPropName: "#cdata",
198
+ parseTagValue: false,
199
+ parseAttributeValue: false,
200
+ trimValues: true,
201
+ isArray: (name) => name === "item" || name === "entry"
202
+ });
203
+ /** Parse an RSS/Atom/RDF feed body. Returns null when the input doesn't
204
+ * look like a feed we understand. */
205
+ function parseFeed(body) {
206
+ const text = stripBom(body);
207
+ if (!text.trim()) return null;
208
+ let parsed;
209
+ try {
210
+ parsed = xml.parse(text);
211
+ } catch {
212
+ return null;
213
+ }
214
+ if (!isRecord(parsed)) return null;
215
+ if (isRecord(parsed.rss)) return parseRss(parsed.rss);
216
+ if (isRecord(parsed.feed)) return parseAtom(parsed.feed);
217
+ const rdf = parsed["rdf:RDF"] ?? parsed.RDF;
218
+ if (isRecord(rdf)) return parseRss10(rdf);
219
+ return null;
220
+ }
221
+ function collectItems(value) {
222
+ const rawItems = Array.isArray(value) ? value : [];
223
+ const items = [];
224
+ for (const raw of rawItems) if (isRecord(raw) && readString(raw.title) !== null) items.push({ raw });
225
+ return items;
226
+ }
227
+ function parseRss(rss) {
228
+ const { channel } = rss;
229
+ if (!isRecord(channel)) return null;
230
+ return {
231
+ kind: "rss",
232
+ title: readString(channel.title),
233
+ items: collectItems(channel.item)
234
+ };
235
+ }
236
+ function parseRss10(rdf) {
237
+ const channel = isRecord(rdf.channel) ? rdf.channel : null;
238
+ return {
239
+ kind: "rss",
240
+ title: channel ? readString(channel.title) : null,
241
+ items: collectItems(rdf.item)
242
+ };
243
+ }
244
+ function parseAtom(feed) {
245
+ return {
246
+ kind: "atom",
247
+ title: readString(feed.title),
248
+ items: collectItems(feed.entry)
249
+ };
250
+ }
251
+ function readString(value) {
252
+ if (isNonEmptyString(value)) return value;
253
+ if (typeof value === "string") return null;
254
+ if (isRecord(value)) return readStringFromRecord(value);
255
+ if (Array.isArray(value)) return readStringFromArray(value);
256
+ return null;
257
+ }
258
+ function readStringFromRecord(record) {
259
+ const text = record["#text"];
260
+ if (isNonEmptyString(text)) return text;
261
+ const cdata = record["#cdata"];
262
+ if (isNonEmptyString(cdata)) return cdata;
263
+ return null;
264
+ }
265
+ function readStringFromArray(array) {
266
+ for (const entry of array) {
267
+ const resolved = readString(entry);
268
+ if (resolved !== null) return resolved;
269
+ }
270
+ return null;
271
+ }
272
+ function stripBom(text) {
273
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
274
+ }
275
+ //#endregion
276
+ //#region src/feeds/server/pathResolver.ts
277
+ var BRACKET_RE = /\[(\d*)\]/g;
278
+ /** Split one dot-segment (`results[0]`, `hourly[]`, `name`) into tokens.
279
+ * An empty `[]` is an array-identity marker and emits no token — the
280
+ * array value is read by the preceding key. */
281
+ function parseSegment(segment, tokens) {
282
+ const bracketStart = segment.indexOf("[");
283
+ const name = bracketStart === -1 ? segment : segment.slice(0, bracketStart);
284
+ if (name.length > 0) tokens.push({
285
+ kind: "key",
286
+ key: name
287
+ });
288
+ if (bracketStart === -1) return;
289
+ for (const match of segment.slice(bracketStart).matchAll(BRACKET_RE)) {
290
+ const [, inner] = match;
291
+ if (inner.length > 0) tokens.push({
292
+ kind: "index",
293
+ index: Number(inner)
294
+ });
295
+ }
296
+ }
297
+ function tokenize(path) {
298
+ const tokens = [];
299
+ for (const segment of path.split(".")) if (segment.length > 0) parseSegment(segment, tokens);
300
+ return tokens;
301
+ }
302
+ function step(current, token) {
303
+ if (current === null || current === void 0) return void 0;
304
+ if (token.kind === "key") {
305
+ if (typeof current !== "object" || Array.isArray(current)) return void 0;
306
+ return current[token.key];
307
+ }
308
+ return Array.isArray(current) ? current[token.index] : void 0;
309
+ }
310
+ /** Resolve a dot/bracket path against `root`, or `undefined` on any miss. */
311
+ function getByPath(root, path) {
312
+ let current = root;
313
+ for (const token of tokenize(path)) current = step(current, token);
314
+ return current;
315
+ }
316
+ /** Locate the array of raw items for a fetch. With `itemsAt` set, walk
317
+ * to it; without it, the response itself must be the array. Non-arrays
318
+ * yield `[]` so a malformed response is a no-op, not a crash. */
319
+ function getItemsArray(root, itemsAt) {
320
+ if (!itemsAt) return Array.isArray(root) ? root : [];
321
+ const value = getByPath(root, itemsAt);
322
+ return Array.isArray(value) ? value : [];
323
+ }
324
+ //#endregion
325
+ //#region src/feeds/server/projectItem.ts
326
+ function asKeyString(value) {
327
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
328
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
329
+ return null;
330
+ }
331
+ /** Collapse an XML element object that carries body text alongside
332
+ * attributes (`{ "#text": "v", "@_x": "..." }`) or CDATA to its text.
333
+ * Generic — the host knows no field names, only this fast-xml-parser
334
+ * shape — so a tag like `<guid isPermaLink="false">id</guid>` maps to
335
+ * its text without the caller needing to write `.#text`. */
336
+ function unwrapTextNode(value) {
337
+ if (value && typeof value === "object" && !Array.isArray(value)) {
338
+ const obj = value;
339
+ if (typeof obj["#text"] === "string") return obj["#text"];
340
+ if (typeof obj["#cdata"] === "string") return obj["#cdata"];
341
+ }
342
+ return value;
343
+ }
344
+ /** Normalize a mapped value generically — driven by the SCHEMA's declared
345
+ * field type, never by the source field name. Today: unwrap XML text
346
+ * nodes, and coerce anything mapped into a `date` field to a `YYYY-MM-DD`
347
+ * civil date (the collection `date` type + calendar are day-granularity
348
+ * and parse strictly — a full RFC-3339 timestamp would be rejected). */
349
+ function normalizeValue(value, fieldType) {
350
+ const unwrapped = unwrapTextNode(value);
351
+ if (fieldType === "date" && typeof unwrapped === "string") {
352
+ const millis = Date.parse(unwrapped);
353
+ if (Number.isFinite(millis)) return new Date(millis).toISOString().slice(0, 10);
354
+ }
355
+ return unwrapped;
356
+ }
357
+ /** Slugify a natural key into a stable, filename-safe id. Short, safe
358
+ * keys pass through (lowercased); long or unsafe keys collapse to a
359
+ * hash so the filename stays bounded and valid. */
360
+ function toSafeId(natural) {
361
+ const slug = natural.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
362
+ if (slug.length > 0 && slug.length <= 80) return slug;
363
+ const hash = createHash("sha256").update(natural || "item", "utf-8").digest("hex").slice(0, 16);
364
+ return slug.length > 80 ? `${slug.slice(0, 60)}-${hash}` : `feed-${hash}`;
365
+ }
366
+ function naturalKey(record, rawItem, ingest, schema) {
367
+ const fromMapped = asKeyString(record[schema.primaryKey]);
368
+ if (fromMapped) return fromMapped;
369
+ if (ingest.idFrom) {
370
+ const fromId = asKeyString(unwrapTextNode(getByPath(rawItem, ingest.idFrom)));
371
+ if (fromId) return fromId;
372
+ }
373
+ return JSON.stringify(record);
374
+ }
375
+ /** Build a record from a raw fetched item (a parsed RSS/Atom element or a
376
+ * JSON object) using `ingest.map`. Each source path is resolved against
377
+ * the raw item and normalized per the target field's declared type. The
378
+ * returned record's primaryKey is set to the derived safe id (so it
379
+ * doubles as the filename). */
380
+ function projectRecord(rawItem, ingest, schema) {
381
+ const record = {};
382
+ for (const [targetField, sourcePath] of Object.entries(ingest.map)) {
383
+ const value = normalizeValue(getByPath(rawItem, sourcePath), schema.fields?.[targetField]?.type);
384
+ if (value !== void 0) record[targetField] = value;
385
+ }
386
+ record[schema.primaryKey] = toSafeId(naturalKey(record, rawItem, ingest, schema));
387
+ return record;
388
+ }
389
+ //#endregion
390
+ //#region src/feeds/server/retrievers/rss.ts
391
+ var retrieveRss = async (ingest, schema) => {
392
+ const feed = parseFeed(await fetchText(ingest.url));
393
+ if (!feed) return {
394
+ items: [],
395
+ cursor: {}
396
+ };
397
+ return {
398
+ items: feed.items.map((item) => projectRecord(item.raw, ingest, schema)),
399
+ cursor: {}
400
+ };
401
+ };
402
+ registerRetriever("rss", retrieveRss);
403
+ registerRetriever("atom", retrieveRss);
404
+ //#endregion
405
+ //#region src/feeds/server/retrievers/httpJson.ts
406
+ var retrieveHttpJson = async (ingest, schema) => {
407
+ return {
408
+ items: getItemsArray(await fetchJson(ingest.url), ingest.itemsAt).map((raw) => projectRecord(raw, ingest, schema)),
409
+ cursor: {}
410
+ };
411
+ };
412
+ registerRetriever("http-json", retrieveHttpJson);
413
+ //#endregion
414
+ //#region src/feeds/server/state.ts
415
+ /** Resolve the on-disk state file for a collection, branching on source. */
416
+ function stateFilePath(target, workspaceRoot) {
417
+ return target.source === "feed" ? feedStatePath(target.slug, workspaceRoot) : ingestStatePath(target.slug, workspaceRoot);
418
+ }
419
+ function defaultFeedState(slug) {
420
+ return {
421
+ slug,
422
+ lastFetchedAt: null,
423
+ cursor: {},
424
+ consecutiveFailures: 0
425
+ };
426
+ }
427
+ function normalizeState(slug, parsed) {
428
+ const base = defaultFeedState(slug);
429
+ const cursor = parsed.cursor && typeof parsed.cursor === "object" ? parsed.cursor : base.cursor;
430
+ return {
431
+ slug,
432
+ lastFetchedAt: typeof parsed.lastFetchedAt === "string" ? parsed.lastFetchedAt : base.lastFetchedAt,
433
+ cursor,
434
+ consecutiveFailures: typeof parsed.consecutiveFailures === "number" ? parsed.consecutiveFailures : base.consecutiveFailures,
435
+ ...typeof parsed.failureBellId === "string" ? { failureBellId: parsed.failureBellId } : {}
436
+ };
437
+ }
438
+ /** Read a collection's retrieval state, tolerating a missing file (first run →
439
+ * default). The state path branches on `target.source`. */
440
+ async function readFeedState(workspaceRoot, target) {
441
+ const { slug } = target;
442
+ try {
443
+ const raw = await readFile(stateFilePath(target, workspaceRoot), "utf-8");
444
+ return normalizeState(slug, JSON.parse(raw));
445
+ } catch (err) {
446
+ if (err.code !== "ENOENT") log.warn("feeds", "failed to read feed state, using default", {
447
+ slug,
448
+ error: String(err)
449
+ });
450
+ return defaultFeedState(slug);
451
+ }
452
+ }
453
+ /** Persist a collection's retrieval state atomically (creating the parent dir
454
+ * if needed). The state path branches on `target.source`. */
455
+ async function writeFeedState(workspaceRoot, target, state) {
456
+ const file = stateFilePath(target, workspaceRoot);
457
+ await mkdir(path.dirname(file), { recursive: true });
458
+ await requireFeedsHost().writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\n`);
459
+ }
460
+ //#endregion
461
+ //#region src/feeds/server/agentIngest.ts
462
+ /** The injected worker launcher, or null if the host was never configured.
463
+ * Read non-throwingly so the failure-isolated contract holds (an unconfigured
464
+ * host becomes an `errors` entry, not a thrown exception). */
465
+ function workerRunnerOrNull() {
466
+ try {
467
+ return requireFeedsHost().spawnWorker;
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+ function result(slug, patch) {
473
+ return {
474
+ slug,
475
+ written: 0,
476
+ removed: 0,
477
+ errors: [],
478
+ ...patch
479
+ };
480
+ }
481
+ /** Dispatch one agent-ingest refresh: build the seed, launch a worker, and (on a
482
+ * successful launch) stamp `lastFetchedAt` with the DISPATCH time — not
483
+ * completion. That's what gates the due-loop, so a slow worker can't cause a
484
+ * double-dispatch. `opts.hidden` (default true) runs an invisible system worker
485
+ * for SCHEDULED refreshes; a MANUAL Refresh passes `hidden:false` for a visible,
486
+ * debuggable session. Failure-isolated: never throws; cap-miss / template-miss /
487
+ * launch error leave state untouched and report via `errors`. */
488
+ async function refreshViaAgent(workspaceRoot, collection, opts) {
489
+ const hidden = opts?.hidden ?? true;
490
+ const { slug } = collection;
491
+ const ingest = collection.schema.ingest;
492
+ if (!ingest || ingest.kind !== "agent") return result(slug, { errors: ["collection has no agent ingest config"] });
493
+ const workerRunner = workerRunnerOrNull();
494
+ if (!workerRunner) return result(slug, { errors: ["agent ingest worker runner not configured"] });
495
+ const template = await readSkillTemplate(collection.skillDir, ingest.template);
496
+ if (template === null) return result(slug, { errors: [`ingest template '${ingest.template}' could not be read`] });
497
+ const items = await listItems(collection.dataDir, { workspaceRoot });
498
+ const message = buildCollectionActionSeedPrompt(items, collection.schema, template);
499
+ let launch;
500
+ try {
501
+ launch = await workerRunner({
502
+ message,
503
+ roleId: ingest.role,
504
+ hidden,
505
+ onComplete: hidden ? (outcome) => recordOutcome(workspaceRoot, collection, outcome.didError) : void 0
506
+ });
507
+ } catch (err) {
508
+ log.warn("feeds", "agent ingest worker launch threw", {
509
+ slug,
510
+ error: String(err)
511
+ });
512
+ return result(slug, {
513
+ errors: [String(err)],
514
+ dispatched: false
515
+ });
516
+ }
517
+ if (!launch.ok) {
518
+ log.info("feeds", "agent ingest dispatch skipped", {
519
+ slug,
520
+ error: launch.error
521
+ });
522
+ return result(slug, {
523
+ errors: [launch.error],
524
+ dispatched: false
525
+ });
526
+ }
527
+ await writeFeedState(workspaceRoot, collection, {
528
+ ...await readFeedState(workspaceRoot, collection),
529
+ lastFetchedAt: (/* @__PURE__ */ new Date()).toISOString()
530
+ });
531
+ log.info("feeds", "agent ingest dispatched", {
532
+ slug,
533
+ role: ingest.role,
534
+ chatId: launch.chatId,
535
+ hidden,
536
+ items: items.length
537
+ });
538
+ return result(slug, {
539
+ dispatched: true,
540
+ ...hidden ? {} : { chatId: launch.chatId }
541
+ });
542
+ }
543
+ /** Completion hook: reconcile failure tracking when a dispatched worker
544
+ * finishes. On error, bump `consecutiveFailures` and raise a single failure
545
+ * bell (deduped via the persisted `failureBellId`). On success, reset the
546
+ * counter and clear any standing bell. Best-effort + failure-isolated — it
547
+ * runs inside the agent run's teardown, so it must never throw. */
548
+ async function recordOutcome(workspaceRoot, collection, didError) {
549
+ const state = await readFeedState(workspaceRoot, collection);
550
+ await writeFeedState(workspaceRoot, collection, didError ? await onWorkerError(state, collection) : await onWorkerSuccess(state, collection));
551
+ }
552
+ async function onWorkerError(state, collection) {
553
+ const next = {
554
+ ...state,
555
+ consecutiveFailures: state.consecutiveFailures + 1
556
+ };
557
+ log.warn("feeds", "agent ingest worker failed", {
558
+ slug: collection.slug,
559
+ consecutiveFailures: next.consecutiveFailures
560
+ });
561
+ if (!state.failureBellId) try {
562
+ const { id } = await publish({
563
+ pluginPkg: "host",
564
+ severity: "nudge",
565
+ lifecycle: "fyi",
566
+ title: "Collection refresh failed",
567
+ body: `“${collection.schema.title}” (${collection.slug}) couldn't refresh. Open it to retry.`,
568
+ navigateTarget: `/collections/${collection.slug}`
569
+ });
570
+ next.failureBellId = id;
571
+ } catch (err) {
572
+ log.warn("feeds", "failed to publish ingest failure bell", {
573
+ slug: collection.slug,
574
+ error: String(err)
575
+ });
576
+ }
577
+ return next;
578
+ }
579
+ async function onWorkerSuccess(state, collection) {
580
+ log.info("feeds", "agent ingest worker completed", { slug: collection.slug });
581
+ if (state.failureBellId) await clear(state.failureBellId).catch((err) => log.warn("feeds", "failed to clear ingest failure bell", {
582
+ slug: collection.slug,
583
+ error: String(err)
584
+ }));
585
+ const next = {
586
+ ...state,
587
+ consecutiveFailures: 0
588
+ };
589
+ delete next.failureBellId;
590
+ return next;
591
+ }
592
+ //#endregion
593
+ //#region src/feeds/server/engine.ts
594
+ var ONE_HOUR_MS = 36e5;
595
+ var ONE_DAY_MS = 864e5;
596
+ /** Feed schemas carry the rich `IngestSpec` (validated at discovery —
597
+ * `source === "feed"` requires `ingest`), but the canonical
598
+ * `CollectionSchema.ingest` only promises the minimal `CollectionIngest`.
599
+ * Narrow here so the engine can read the retrieval fields type-safely. */
600
+ function feedIngest(schema) {
601
+ return schema.ingest;
602
+ }
603
+ async function upsertItems(workspaceRoot, feed, items) {
604
+ let written = 0;
605
+ for (const item of items) {
606
+ const itemId = item[feed.schema.primaryKey];
607
+ if (typeof itemId !== "string" || itemId.length === 0) continue;
608
+ const result = await writeItem(feed.dataDir, itemId, item, {
609
+ refuseOverwrite: false,
610
+ workspaceRoot,
611
+ slug: feed.slug
612
+ });
613
+ if (result.kind === "ok") written += 1;
614
+ else log.warn("feeds", "feed item write skipped", {
615
+ slug: feed.slug,
616
+ itemId,
617
+ kind: result.kind
618
+ });
619
+ }
620
+ return written;
621
+ }
622
+ /** The schema's first `date` field, used to order records for the
623
+ * maxItems cap. Null when the schema declares none. */
624
+ function firstDateField(schema) {
625
+ for (const [key, spec] of Object.entries(schema.fields)) if (spec.type === "date") return key;
626
+ return null;
627
+ }
628
+ function recordTime(item, field) {
629
+ const value = item[field];
630
+ const millis = typeof value === "string" ? Date.parse(value) : NaN;
631
+ return Number.isFinite(millis) ? millis : Number.NEGATIVE_INFINITY;
632
+ }
633
+ /** Enforce `ingest.maxItems` (default 100): keep the newest N records by
634
+ * the schema's date field, delete the rest. No-op when the cap is 0/absent
635
+ * of a date field, or when under the cap. Returns the number deleted. */
636
+ async function pruneFeed(workspaceRoot, feed) {
637
+ const ingest = feedIngest(feed.schema);
638
+ const cap = (ingest && ingest.kind !== "agent" ? ingest.maxItems : void 0) ?? 100;
639
+ if (cap <= 0) return 0;
640
+ const dateField = firstDateField(feed.schema);
641
+ if (!dateField) {
642
+ log.warn("feeds", "maxItems prune skipped: schema has no date field to order by", { slug: feed.slug });
643
+ return 0;
644
+ }
645
+ const items = await listItems(feed.dataDir, { workspaceRoot });
646
+ if (items.length <= cap) return 0;
647
+ const stale = [...items].sort((left, right) => recordTime(right, dateField) - recordTime(left, dateField)).slice(cap);
648
+ let removed = 0;
649
+ for (const item of stale) {
650
+ const itemId = item[feed.schema.primaryKey];
651
+ if (typeof itemId !== "string" || itemId.length === 0) continue;
652
+ if ((await deleteItem(feed.dataDir, itemId, {
653
+ workspaceRoot,
654
+ slug: feed.slug
655
+ })).kind === "ok") removed += 1;
656
+ }
657
+ if (removed > 0) log.info("feeds", "pruned old feed records", {
658
+ slug: feed.slug,
659
+ removed,
660
+ cap
661
+ });
662
+ return removed;
663
+ }
664
+ /** Fetch one feed now, upsert its records, then enforce the maxItems cap.
665
+ * Failure-isolated: returns an errors array rather than throwing. */
666
+ async function refreshOne(workspaceRoot, feed, opts) {
667
+ const { slug } = feed;
668
+ const ingest = feedIngest(feed.schema);
669
+ if (!ingest) return {
670
+ slug,
671
+ written: 0,
672
+ removed: 0,
673
+ errors: ["collection has no ingest config"]
674
+ };
675
+ if (ingest.kind === "agent") return refreshViaAgent(workspaceRoot, feed, opts);
676
+ const retriever = getRetriever(ingest.kind);
677
+ if (!retriever) return {
678
+ slug,
679
+ written: 0,
680
+ removed: 0,
681
+ errors: [`no retriever registered for kind '${ingest.kind}'`]
682
+ };
683
+ const state = await readFeedState(workspaceRoot, feed);
684
+ try {
685
+ const result = await retriever(ingest, feed.schema, state);
686
+ const written = await upsertItems(workspaceRoot, feed, result.items);
687
+ await writeFeedState(workspaceRoot, feed, {
688
+ ...state,
689
+ lastFetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
690
+ cursor: result.cursor,
691
+ consecutiveFailures: 0
692
+ });
693
+ const removed = await pruneFeed(workspaceRoot, feed);
694
+ log.info("feeds", "feed refreshed", {
695
+ slug,
696
+ written,
697
+ removed,
698
+ fetched: result.items.length
699
+ });
700
+ return {
701
+ slug,
702
+ written,
703
+ removed,
704
+ errors: []
705
+ };
706
+ } catch (error) {
707
+ await writeFeedState(workspaceRoot, feed, {
708
+ ...state,
709
+ consecutiveFailures: state.consecutiveFailures + 1
710
+ });
711
+ const message = String(error);
712
+ log.warn("feeds", "feed refresh failed", {
713
+ slug,
714
+ error: message
715
+ });
716
+ return {
717
+ slug,
718
+ written: 0,
719
+ removed: 0,
720
+ errors: [message]
721
+ };
722
+ }
723
+ }
724
+ function dueIntervalMs(schedule) {
725
+ switch (schedule) {
726
+ case "daily": return ONE_DAY_MS;
727
+ case "weekly": return 7 * ONE_DAY_MS;
728
+ default: return ONE_HOUR_MS;
729
+ }
730
+ }
731
+ /** True iff a `daily` schedule with a UTC `atHour` anchor is due. The system
732
+ * task ticks hourly, so "due" means: we're in the anchor hour AND we haven't
733
+ * already run in roughly the last day. The 23 h floor (not 24 h) tolerates the
734
+ * tick landing a few minutes earlier than the previous run, while staying well
735
+ * above 1 h so a second tick in the same anchor hour can't double-fire. */
736
+ function isDailyAtHourDue(now, atHour, lastFetchedAt) {
737
+ if (now.getUTCHours() !== atHour) return false;
738
+ if (!lastFetchedAt) return true;
739
+ const elapsed = now.getTime() - Date.parse(lastFetchedAt);
740
+ if (!Number.isFinite(elapsed)) return true;
741
+ return elapsed >= 23 * ONE_HOUR_MS;
742
+ }
743
+ /** True iff a collection is due to refresh given its schedule + last run.
744
+ * `on-demand` is never auto-due; a `daily` schedule with `atHour` anchors to
745
+ * that UTC hour, otherwise cadence is elapsed-based. */
746
+ function isFeedDue(feed, state) {
747
+ const ingest = feedIngest(feed.schema);
748
+ const schedule = ingest?.schedule;
749
+ if (!schedule || schedule === "on-demand") return false;
750
+ if (schedule === "daily" && typeof ingest?.atHour === "number") return isDailyAtHourDue(/* @__PURE__ */ new Date(), ingest.atHour, state.lastFetchedAt);
751
+ if (!state.lastFetchedAt) return true;
752
+ const elapsed = Date.now() - Date.parse(state.lastFetchedAt);
753
+ if (!Number.isFinite(elapsed)) return true;
754
+ return elapsed >= dueIntervalMs(schedule);
755
+ }
756
+ /** Refresh every collection whose ingest schedule says it's due — declarative
757
+ * feeds AND skill-backed collections with `ingest.kind: "agent"`. Called by the
758
+ * hourly system task. Sequential + failure-isolated. */
759
+ async function refreshDue(workspaceRoot = requireFeedsHost().workspaceRoot) {
760
+ const withIngest = (await discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.ingest);
761
+ const results = [];
762
+ for (const collection of withIngest) try {
763
+ if (!isFeedDue(collection, await readFeedState(workspaceRoot, collection))) continue;
764
+ results.push(await refreshOne(workspaceRoot, collection));
765
+ } catch (error) {
766
+ log.warn("feeds", "scheduled refresh failed for collection", {
767
+ slug: collection.slug,
768
+ error: String(error)
769
+ });
770
+ results.push({
771
+ slug: collection.slug,
772
+ written: 0,
773
+ removed: 0,
774
+ errors: [String(error)]
775
+ });
776
+ }
777
+ return results;
778
+ }
779
+ //#endregion
780
+ export { AGENT_INGEST_KIND, DEFAULT_FEED_MAX_ITEMS, FEEDS_DIR, FEED_SCHEDULES, INGEST_KINDS, configureFeedsHost, feedDir, feedStatePath, feedsRoot, ingestStateDir, ingestStatePath, isFeedSchedule, listFeeds, readFeedState, refreshDue, refreshOne, refreshViaAgent, removeFeed, requireFeedsHost, resetFeedsHostForTesting };
781
+
782
+ //# sourceMappingURL=index.js.map