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