scientify 1.10.0 → 1.10.2
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/README.md +10 -2
- package/README.zh.md +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/src/hooks/research-mode.d.ts.map +1 -1
- package/dist/src/hooks/research-mode.js +3 -0
- package/dist/src/hooks/research-mode.js.map +1 -1
- package/dist/src/hooks/scientify-signature.d.ts.map +1 -1
- package/dist/src/hooks/scientify-signature.js +1 -0
- package/dist/src/hooks/scientify-signature.js.map +1 -1
- package/dist/src/literature/subscription-state.d.ts +90 -0
- package/dist/src/literature/subscription-state.d.ts.map +1 -0
- package/dist/src/literature/subscription-state.js +521 -0
- package/dist/src/literature/subscription-state.js.map +1 -0
- package/dist/src/research-subscriptions/constants.d.ts +16 -0
- package/dist/src/research-subscriptions/constants.d.ts.map +1 -0
- package/dist/src/research-subscriptions/constants.js +59 -0
- package/dist/src/research-subscriptions/constants.js.map +1 -0
- package/dist/src/research-subscriptions/cron-client.d.ts +8 -0
- package/dist/src/research-subscriptions/cron-client.d.ts.map +1 -0
- package/dist/src/research-subscriptions/cron-client.js +81 -0
- package/dist/src/research-subscriptions/cron-client.js.map +1 -0
- package/dist/src/research-subscriptions/delivery.d.ts +10 -0
- package/dist/src/research-subscriptions/delivery.d.ts.map +1 -0
- package/dist/src/research-subscriptions/delivery.js +82 -0
- package/dist/src/research-subscriptions/delivery.js.map +1 -0
- package/dist/src/research-subscriptions/handlers.d.ts +6 -0
- package/dist/src/research-subscriptions/handlers.d.ts.map +1 -0
- package/dist/src/research-subscriptions/handlers.js +203 -0
- package/dist/src/research-subscriptions/handlers.js.map +1 -0
- package/dist/src/research-subscriptions/parse.d.ts +11 -0
- package/dist/src/research-subscriptions/parse.d.ts.map +1 -0
- package/dist/src/research-subscriptions/parse.js +478 -0
- package/dist/src/research-subscriptions/parse.js.map +1 -0
- package/dist/src/research-subscriptions/prompt.d.ts +5 -0
- package/dist/src/research-subscriptions/prompt.d.ts.map +1 -0
- package/dist/src/research-subscriptions/prompt.js +189 -0
- package/dist/src/research-subscriptions/prompt.js.map +1 -0
- package/dist/src/research-subscriptions/types.d.ts +65 -0
- package/dist/src/research-subscriptions/types.d.ts.map +1 -0
- package/dist/src/research-subscriptions/types.js +2 -0
- package/dist/src/research-subscriptions/types.js.map +1 -0
- package/dist/src/research-subscriptions.d.ts +1 -9
- package/dist/src/research-subscriptions.d.ts.map +1 -1
- package/dist/src/research-subscriptions.js +1 -761
- package/dist/src/research-subscriptions.js.map +1 -1
- package/dist/src/tools/scientify-cron.d.ts +20 -0
- package/dist/src/tools/scientify-cron.d.ts.map +1 -1
- package/dist/src/tools/scientify-cron.js +104 -4
- package/dist/src/tools/scientify-cron.js.map +1 -1
- package/dist/src/tools/scientify-literature-state.d.ts +70 -0
- package/dist/src/tools/scientify-literature-state.d.ts.map +1 -0
- package/dist/src/tools/scientify-literature-state.js +312 -0
- package/dist/src/tools/scientify-literature-state.js.map +1 -0
- package/package.json +1 -1
- package/skills/_shared/workspace-spec.md +4 -1
- package/skills/research-subscription/SKILL.md +14 -1
|
@@ -1,762 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
const DEFAULT_TZ = "Asia/Shanghai";
|
|
3
|
-
const DEFAULT_CRON_PROMPT = "/research-pipeline Run an incremental literature check for the active project. Only report if there are newly relevant papers: provide 3 concise highlights with sources. If no increment is found, reply with 'No new literature found.'";
|
|
4
|
-
const SCIENTIFY_SIGNATURE_FOOTER = "---\n🐍Scientify";
|
|
5
|
-
const REMINDER_HINT_RE = /\b(remind(?:er)?|remember|alarm|wake|break|sleep|coffee|water|drink)\b|提醒|记得|闹钟|休息|喝水|喝咖啡|睡觉/u;
|
|
6
|
-
const RESEARCH_HINT_RE = /\b(research|paper|papers|survey|literature|arxiv|openalex|citation|benchmark|dataset|model)\b|论文|文献|研究|综述|检索|引用|实验/u;
|
|
7
|
-
const ALLOWED_DELIVERY_CHANNELS = new Set([
|
|
8
|
-
"telegram",
|
|
9
|
-
"whatsapp",
|
|
10
|
-
"discord",
|
|
11
|
-
"irc",
|
|
12
|
-
"googlechat",
|
|
13
|
-
"slack",
|
|
14
|
-
"signal",
|
|
15
|
-
"imessage",
|
|
16
|
-
"feishu",
|
|
17
|
-
"nostr",
|
|
18
|
-
"msteams",
|
|
19
|
-
"mattermost",
|
|
20
|
-
"nextcloud-talk",
|
|
21
|
-
"matrix",
|
|
22
|
-
"bluebubbles",
|
|
23
|
-
"line",
|
|
24
|
-
"zalo",
|
|
25
|
-
"zalouser",
|
|
26
|
-
"synology-chat",
|
|
27
|
-
"tlon",
|
|
28
|
-
]);
|
|
29
|
-
const WEEKDAY_MAP = {
|
|
30
|
-
sun: 0,
|
|
31
|
-
sunday: 0,
|
|
32
|
-
mon: 1,
|
|
33
|
-
monday: 1,
|
|
34
|
-
tue: 2,
|
|
35
|
-
tues: 2,
|
|
36
|
-
tuesday: 2,
|
|
37
|
-
wed: 3,
|
|
38
|
-
wednesday: 3,
|
|
39
|
-
thu: 4,
|
|
40
|
-
thur: 4,
|
|
41
|
-
thurs: 4,
|
|
42
|
-
thursday: 4,
|
|
43
|
-
fri: 5,
|
|
44
|
-
friday: 5,
|
|
45
|
-
sat: 6,
|
|
46
|
-
saturday: 6,
|
|
47
|
-
};
|
|
48
|
-
function tokenizeArgs(raw) {
|
|
49
|
-
const tokens = [];
|
|
50
|
-
let current = "";
|
|
51
|
-
let quote = null;
|
|
52
|
-
let escaped = false;
|
|
53
|
-
for (let i = 0; i < raw.length; i++) {
|
|
54
|
-
const ch = raw[i];
|
|
55
|
-
if (escaped) {
|
|
56
|
-
current += ch;
|
|
57
|
-
escaped = false;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
if (quote) {
|
|
61
|
-
if (ch === "\\") {
|
|
62
|
-
const next = raw[i + 1];
|
|
63
|
-
if (next === quote || next === "\\") {
|
|
64
|
-
escaped = true;
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (ch === quote) {
|
|
69
|
-
quote = null;
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
current += ch;
|
|
73
|
-
}
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
if (ch === '"' || ch === "'") {
|
|
77
|
-
quote = ch;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (/\s/.test(ch)) {
|
|
81
|
-
if (current.length > 0) {
|
|
82
|
-
tokens.push(current);
|
|
83
|
-
current = "";
|
|
84
|
-
}
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
current += ch;
|
|
88
|
-
}
|
|
89
|
-
if (current.length > 0) {
|
|
90
|
-
tokens.push(current);
|
|
91
|
-
}
|
|
92
|
-
return tokens;
|
|
93
|
-
}
|
|
94
|
-
function parseTime(raw) {
|
|
95
|
-
const m = raw.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
|
|
96
|
-
if (!m)
|
|
97
|
-
return null;
|
|
98
|
-
return { hour: Number(m[1]), minute: Number(m[2]) };
|
|
99
|
-
}
|
|
100
|
-
function isValidTimezone(raw) {
|
|
101
|
-
try {
|
|
102
|
-
new Intl.DateTimeFormat("en-US", { timeZone: raw });
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function validateEveryDuration(raw) {
|
|
110
|
-
return /^\d+[smhdw]$/i.test(raw);
|
|
111
|
-
}
|
|
112
|
-
function isDurationLike(raw) {
|
|
113
|
-
return /^\+?\d+[smhdw]$/i.test(raw);
|
|
114
|
-
}
|
|
115
|
-
function validateCronExpr(raw) {
|
|
116
|
-
const fields = raw.trim().split(/\s+/);
|
|
117
|
-
return fields.length === 5 || fields.length === 6;
|
|
118
|
-
}
|
|
119
|
-
function parseTimeAndTz(tokens, startIndex) {
|
|
120
|
-
let time = DEFAULT_TIME;
|
|
121
|
-
let tz = DEFAULT_TZ;
|
|
122
|
-
const first = tokens[startIndex];
|
|
123
|
-
const second = tokens[startIndex + 1];
|
|
124
|
-
if (!first) {
|
|
125
|
-
return { time, tz };
|
|
126
|
-
}
|
|
127
|
-
if (parseTime(first)) {
|
|
128
|
-
time = first;
|
|
129
|
-
if (second) {
|
|
130
|
-
if (!isValidTimezone(second)) {
|
|
131
|
-
return {
|
|
132
|
-
error: `Error: invalid timezone \`${second}\`. Use an IANA timezone like \`Asia/Shanghai\`.`,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
tz = second;
|
|
136
|
-
}
|
|
137
|
-
return { time, tz };
|
|
138
|
-
}
|
|
139
|
-
if (!isValidTimezone(first)) {
|
|
140
|
-
return {
|
|
141
|
-
error: `Error: invalid time format \`${first}\`. Use \`HH:MM\` (24-hour) or a timezone value.`,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
tz = first;
|
|
145
|
-
return { time, tz };
|
|
146
|
-
}
|
|
147
|
-
function parseScheduleArgs(tokens) {
|
|
148
|
-
if (tokens.length === 0) {
|
|
149
|
-
const parsed = parseTime(DEFAULT_TIME);
|
|
150
|
-
const expr = `${parsed.minute} ${parsed.hour} * * *`;
|
|
151
|
-
return { kind: "cron", expr, tz: DEFAULT_TZ, display: `daily ${DEFAULT_TIME} (${DEFAULT_TZ})` };
|
|
152
|
-
}
|
|
153
|
-
const mode = tokens[0]?.toLowerCase();
|
|
154
|
-
if (mode === "daily" || mode === "day") {
|
|
155
|
-
const parsed = parseTimeAndTz(tokens, 1);
|
|
156
|
-
if ("error" in parsed)
|
|
157
|
-
return parsed;
|
|
158
|
-
const time = parseTime(parsed.time);
|
|
159
|
-
return {
|
|
160
|
-
kind: "cron",
|
|
161
|
-
expr: `${time.minute} ${time.hour} * * *`,
|
|
162
|
-
tz: parsed.tz,
|
|
163
|
-
display: `daily ${parsed.time} (${parsed.tz})`,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
if (mode === "weekly" || mode === "week") {
|
|
167
|
-
const dayToken = (tokens[1] ?? "").toLowerCase();
|
|
168
|
-
const dow = WEEKDAY_MAP[dayToken];
|
|
169
|
-
if (dow === undefined) {
|
|
170
|
-
return {
|
|
171
|
-
error: "Error: weekly mode requires a weekday, for example \`mon\`, \`tue\`, or \`sun\`.",
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
const parsed = parseTimeAndTz(tokens, 2);
|
|
175
|
-
if ("error" in parsed)
|
|
176
|
-
return parsed;
|
|
177
|
-
const time = parseTime(parsed.time);
|
|
178
|
-
return {
|
|
179
|
-
kind: "cron",
|
|
180
|
-
expr: `${time.minute} ${time.hour} * * ${dow}`,
|
|
181
|
-
tz: parsed.tz,
|
|
182
|
-
display: `weekly ${tokens[1]} ${parsed.time} (${parsed.tz})`,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
if (mode === "every") {
|
|
186
|
-
const duration = tokens[1];
|
|
187
|
-
if (!duration || !validateEveryDuration(duration)) {
|
|
188
|
-
return {
|
|
189
|
-
error: "Error: every mode needs an interval, for example \`every 6h\` or \`every 30m\`.",
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
return {
|
|
193
|
-
kind: "every",
|
|
194
|
-
duration,
|
|
195
|
-
display: `every ${duration}`,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
if (mode === "at" || mode === "once") {
|
|
199
|
-
const when = tokens[1];
|
|
200
|
-
if (!when) {
|
|
201
|
-
return {
|
|
202
|
-
error: "Error: at mode needs a time value, for example \`at 2m\` or \`at 2026-03-04T08:00:00+08:00\`.",
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
if (isDurationLike(when)) {
|
|
206
|
-
return {
|
|
207
|
-
kind: "at",
|
|
208
|
-
when: when.startsWith("+") ? when.slice(1) : when,
|
|
209
|
-
display: `at ${when}`,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
const date = new Date(when);
|
|
213
|
-
if (Number.isNaN(date.getTime())) {
|
|
214
|
-
return {
|
|
215
|
-
error: "Error: invalid at time format. Use a duration like \`2m\` or an ISO datetime like \`2026-03-04T08:00:00+08:00\`.",
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
return {
|
|
219
|
-
kind: "at",
|
|
220
|
-
when,
|
|
221
|
-
display: `at ${when}`,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
if (mode === "cron") {
|
|
225
|
-
let expr = "";
|
|
226
|
-
let tz = DEFAULT_TZ;
|
|
227
|
-
if (tokens[1]?.includes(" ")) {
|
|
228
|
-
expr = tokens[1];
|
|
229
|
-
tz = tokens[2] ?? DEFAULT_TZ;
|
|
230
|
-
if (tokens.length > 3) {
|
|
231
|
-
return {
|
|
232
|
-
error: "Error: invalid cron mode format. Use either `cron \"<expr>\" [TZ]` or split fields (`cron 0 9 * * * [TZ]`).",
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
const valueCount = tokens.length - 1;
|
|
238
|
-
if (valueCount < 5 || valueCount > 7) {
|
|
239
|
-
return {
|
|
240
|
-
error: "Error: invalid cron mode format. Example: `cron \"0 9 * * *\" Asia/Shanghai`, `cron 0 9 * * * Asia/Shanghai`, or `cron 0 0 9 * * * Asia/Shanghai`.",
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
if (valueCount === 7) {
|
|
244
|
-
// 6-field cron + timezone
|
|
245
|
-
expr = tokens.slice(1, 7).join(" ").trim();
|
|
246
|
-
tz = tokens[7] ?? DEFAULT_TZ;
|
|
247
|
-
}
|
|
248
|
-
else if (valueCount === 6) {
|
|
249
|
-
// Ambiguous case:
|
|
250
|
-
// - 5-field cron + timezone
|
|
251
|
-
// - 6-field cron with no timezone
|
|
252
|
-
const maybeTz = tokens[6];
|
|
253
|
-
if (maybeTz && isValidTimezone(maybeTz)) {
|
|
254
|
-
expr = tokens.slice(1, 6).join(" ").trim();
|
|
255
|
-
tz = maybeTz;
|
|
256
|
-
}
|
|
257
|
-
else {
|
|
258
|
-
expr = tokens.slice(1, 7).join(" ").trim();
|
|
259
|
-
tz = DEFAULT_TZ;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
// 5-field cron, no timezone
|
|
264
|
-
expr = tokens.slice(1, 6).join(" ").trim();
|
|
265
|
-
tz = DEFAULT_TZ;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (!validateCronExpr(expr)) {
|
|
269
|
-
return {
|
|
270
|
-
error: "Error: invalid cron mode format. Example: \`cron \"0 9 * * *\" Asia/Shanghai\` or \`cron 0 9 * * * Asia/Shanghai\`.",
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
if (!isValidTimezone(tz)) {
|
|
274
|
-
return {
|
|
275
|
-
error: `Error: invalid timezone \`${tz}\`. Use an IANA timezone like \`Asia/Shanghai\`.`,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
return {
|
|
279
|
-
kind: "cron",
|
|
280
|
-
expr,
|
|
281
|
-
tz,
|
|
282
|
-
display: `cron ${expr} (${tz})`,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
return {
|
|
286
|
-
error: "Error: usage is \`/research-subscribe daily [HH:MM] [TZ]\`, \`/research-subscribe weekly <day> [HH:MM] [TZ]\`, \`/research-subscribe every <duration>\`, \`/research-subscribe at <2m|ISO>\`, or \`/research-subscribe cron \"<expr>\" [TZ]\`.",
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
function parseSubscribeOptions(rawArgs) {
|
|
290
|
-
const tokens = tokenizeArgs((rawArgs ?? "").trim());
|
|
291
|
-
const scheduleTokens = [];
|
|
292
|
-
let channelOverride;
|
|
293
|
-
let toOverride;
|
|
294
|
-
let topic;
|
|
295
|
-
let message;
|
|
296
|
-
let noDeliver = false;
|
|
297
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
298
|
-
const token = tokens[i];
|
|
299
|
-
if (token === "--channel") {
|
|
300
|
-
const value = tokens[i + 1];
|
|
301
|
-
if (!value) {
|
|
302
|
-
return { error: "Error: \`--channel\` expects a value, e.g. \`--channel feishu\`." };
|
|
303
|
-
}
|
|
304
|
-
channelOverride = value.toLowerCase();
|
|
305
|
-
i++;
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (token === "--to") {
|
|
309
|
-
const value = tokens[i + 1];
|
|
310
|
-
if (!value) {
|
|
311
|
-
return { error: "Error: \`--to\` expects a value, e.g. \`--to user_or_chat_id\`." };
|
|
312
|
-
}
|
|
313
|
-
toOverride = value;
|
|
314
|
-
i++;
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
if (token === "--no-deliver") {
|
|
318
|
-
noDeliver = true;
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
if (token === "--topic") {
|
|
322
|
-
const value = tokens[i + 1];
|
|
323
|
-
if (!value) {
|
|
324
|
-
return { error: "Error: \`--topic\` expects a value, e.g. \`--topic \"multimodal LLM safety\"\`." };
|
|
325
|
-
}
|
|
326
|
-
topic = value;
|
|
327
|
-
i++;
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
if (token === "--message") {
|
|
331
|
-
const value = tokens[i + 1];
|
|
332
|
-
if (!value) {
|
|
333
|
-
return { error: "Error: `--message` expects a value, e.g. `--message \"Time to drink water.\"`." };
|
|
334
|
-
}
|
|
335
|
-
message = value;
|
|
336
|
-
i++;
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
if (token.startsWith("--")) {
|
|
340
|
-
return { error: `Error: unknown argument \`${token}\`.` };
|
|
341
|
-
}
|
|
342
|
-
scheduleTokens.push(token);
|
|
343
|
-
}
|
|
344
|
-
return {
|
|
345
|
-
scheduleTokens,
|
|
346
|
-
channelOverride,
|
|
347
|
-
toOverride,
|
|
348
|
-
noDeliver,
|
|
349
|
-
topic,
|
|
350
|
-
message,
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
function sanitizeIdPart(value) {
|
|
354
|
-
const cleaned = (value ?? "unknown")
|
|
355
|
-
.toLowerCase()
|
|
356
|
-
.replace(/[^a-z0-9_-]+/g, "-")
|
|
357
|
-
.replace(/-+/g, "-")
|
|
358
|
-
.replace(/^-|-$/g, "");
|
|
359
|
-
return cleaned.slice(0, 48) || "unknown";
|
|
360
|
-
}
|
|
361
|
-
function buildScopedJobName(ctx) {
|
|
362
|
-
const channel = sanitizeIdPart(ctx.channel);
|
|
363
|
-
const sender = sanitizeIdPart(ctx.senderId);
|
|
364
|
-
return `scientify-report-${channel}-${sender}`;
|
|
365
|
-
}
|
|
366
|
-
function resolveDeliveryTarget(ctx, opts) {
|
|
367
|
-
if (opts.noDeliver) {
|
|
368
|
-
return {
|
|
369
|
-
mode: "none",
|
|
370
|
-
channel: "last",
|
|
371
|
-
display: "none",
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
if (opts.channelOverride) {
|
|
375
|
-
const channel = opts.channelOverride;
|
|
376
|
-
if (channel !== "last" && !ALLOWED_DELIVERY_CHANNELS.has(channel)) {
|
|
377
|
-
return {
|
|
378
|
-
error: "Error: unsupported channel override. Try one of: feishu, telegram, slack, discord, last.",
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
if (channel !== "last" && !opts.toOverride) {
|
|
382
|
-
return {
|
|
383
|
-
error: "Error: `--to` is required when `--channel` is set to a concrete channel (for example: `--channel feishu --to ou_xxx`).",
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
return {
|
|
387
|
-
mode: "announce",
|
|
388
|
-
channel,
|
|
389
|
-
to: opts.toOverride,
|
|
390
|
-
display: `${channel}${opts.toOverride ? `:${opts.toOverride}` : ""}`,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
const channel = (ctx.channel ?? "").trim().toLowerCase();
|
|
394
|
-
const senderId = ctx.senderId?.trim();
|
|
395
|
-
if (senderId && ALLOWED_DELIVERY_CHANNELS.has(channel)) {
|
|
396
|
-
return {
|
|
397
|
-
mode: "announce",
|
|
398
|
-
channel,
|
|
399
|
-
to: opts.toOverride ?? senderId,
|
|
400
|
-
display: `${channel}:${opts.toOverride ?? senderId}`,
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
if (opts.toOverride) {
|
|
404
|
-
return {
|
|
405
|
-
error: "Error: cannot infer channel from current source. Please set \`--channel\` together with \`--to\`.",
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
// Tool-created subscriptions must not silently fall back to "last",
|
|
409
|
-
// otherwise jobs may be created without a concrete delivery target.
|
|
410
|
-
if ((ctx.channel ?? "").trim().toLowerCase() === "tool") {
|
|
411
|
-
return {
|
|
412
|
-
error: "Error: cannot infer delivery target in tool context. Provide both \`--channel\` and \`--to\` (or set \`--no-deliver\`).",
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
return {
|
|
416
|
-
mode: "announce",
|
|
417
|
-
channel: "last",
|
|
418
|
-
display: "last",
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
async function runCommand(deps, argv, timeoutMs = 30_000) {
|
|
422
|
-
deps.logger.debug?.(`[scientify-cron] run: ${argv.join(" ")}`);
|
|
423
|
-
return deps.runtime.system.runCommandWithTimeout(argv, { timeoutMs });
|
|
424
|
-
}
|
|
425
|
-
function parseJsonFromOutput(stdout) {
|
|
426
|
-
const trimmed = stdout.trim();
|
|
427
|
-
if (!trimmed)
|
|
428
|
-
return null;
|
|
429
|
-
try {
|
|
430
|
-
return JSON.parse(trimmed);
|
|
431
|
-
}
|
|
432
|
-
catch {
|
|
433
|
-
// Continue to best-effort extraction.
|
|
434
|
-
}
|
|
435
|
-
const start = trimmed.indexOf("{");
|
|
436
|
-
const end = trimmed.lastIndexOf("}");
|
|
437
|
-
if (start >= 0 && end > start) {
|
|
438
|
-
const maybeJson = trimmed.slice(start, end + 1);
|
|
439
|
-
try {
|
|
440
|
-
return JSON.parse(maybeJson);
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
return null;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
async function listAllJobs(deps) {
|
|
449
|
-
const res = await runCommand(deps, ["openclaw", "cron", "list", "--all", "--json"]);
|
|
450
|
-
if (res.code !== 0) {
|
|
451
|
-
throw new Error(res.stderr || "cron list failed");
|
|
452
|
-
}
|
|
453
|
-
const payload = parseJsonFromOutput(res.stdout);
|
|
454
|
-
return payload?.jobs ?? [];
|
|
455
|
-
}
|
|
456
|
-
async function getJobById(deps, jobId) {
|
|
457
|
-
const jobs = await listAllJobs(deps);
|
|
458
|
-
return jobs.find((job) => job.id === jobId);
|
|
459
|
-
}
|
|
460
|
-
async function ensureJobEnabled(deps, jobId) {
|
|
461
|
-
const current = await getJobById(deps, jobId);
|
|
462
|
-
if (!current || current.enabled !== false) {
|
|
463
|
-
return current;
|
|
464
|
-
}
|
|
465
|
-
const enable = await runCommand(deps, ["openclaw", "cron", "enable", jobId]);
|
|
466
|
-
if (enable.code !== 0) {
|
|
467
|
-
throw new Error(enable.stderr || `failed to enable cron job ${jobId}`);
|
|
468
|
-
}
|
|
469
|
-
const after = await getJobById(deps, jobId);
|
|
470
|
-
if (after?.enabled === false) {
|
|
471
|
-
throw new Error(`cron job ${jobId} is disabled after enable attempt`);
|
|
472
|
-
}
|
|
473
|
-
return after;
|
|
474
|
-
}
|
|
475
|
-
function scheduleText(job) {
|
|
476
|
-
const schedule = job.schedule;
|
|
477
|
-
if (!schedule)
|
|
478
|
-
return "(unknown)";
|
|
479
|
-
if (schedule.kind === "cron") {
|
|
480
|
-
const expr = schedule.expr ?? "(missing expr)";
|
|
481
|
-
const tz = schedule.tz ? ` (${schedule.tz})` : "";
|
|
482
|
-
return `${expr}${tz}`;
|
|
483
|
-
}
|
|
484
|
-
if (schedule.kind === "every") {
|
|
485
|
-
if (typeof schedule.everyMs === "number" && schedule.everyMs > 0) {
|
|
486
|
-
const totalSeconds = Math.floor(schedule.everyMs / 1000);
|
|
487
|
-
if (totalSeconds % 3600 === 0)
|
|
488
|
-
return `every ${totalSeconds / 3600}h`;
|
|
489
|
-
if (totalSeconds % 60 === 0)
|
|
490
|
-
return `every ${totalSeconds / 60}m`;
|
|
491
|
-
return `every ${totalSeconds}s`;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
if (schedule.kind === "at") {
|
|
495
|
-
if (typeof schedule.at === "string" && schedule.at.trim().length > 0) {
|
|
496
|
-
return `at ${schedule.at}`;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
return JSON.stringify(schedule);
|
|
500
|
-
}
|
|
501
|
-
function normalizeReminderText(raw) {
|
|
502
|
-
let text = raw.trim();
|
|
503
|
-
text = text.replace(/^(please\s+)?remind\s+(me|us|you)\s+(to\s+)?/i, "");
|
|
504
|
-
text = text.replace(/^remember\s+to\s+/i, "");
|
|
505
|
-
text = text.replace(/^(请)?(提醒我|提醒你|提醒|记得)(一下|一声)?[::,\s]*/u, "");
|
|
506
|
-
const normalized = text.trim();
|
|
507
|
-
return normalized.length > 0 ? normalized : raw.trim();
|
|
508
|
-
}
|
|
509
|
-
function inferReminderMessageFromTopic(topic) {
|
|
510
|
-
const trimmed = topic?.trim();
|
|
511
|
-
if (!trimmed)
|
|
512
|
-
return undefined;
|
|
513
|
-
if (!REMINDER_HINT_RE.test(trimmed))
|
|
514
|
-
return undefined;
|
|
515
|
-
if (RESEARCH_HINT_RE.test(trimmed))
|
|
516
|
-
return undefined;
|
|
517
|
-
return normalizeReminderText(trimmed);
|
|
518
|
-
}
|
|
519
|
-
function buildScheduledTaskMessage(options, scheduleKind) {
|
|
520
|
-
const customMessage = options.message?.trim();
|
|
521
|
-
if (customMessage) {
|
|
522
|
-
return [
|
|
523
|
-
"Scheduled reminder task.",
|
|
524
|
-
`Please send this reminder now: "${customMessage}"`,
|
|
525
|
-
"Keep the reminder concise and do not run a research workflow unless explicitly requested.",
|
|
526
|
-
].join("\n");
|
|
527
|
-
}
|
|
528
|
-
const reminderFromTopic = inferReminderMessageFromTopic(options.topic);
|
|
529
|
-
if (reminderFromTopic) {
|
|
530
|
-
return [
|
|
531
|
-
"Scheduled reminder task.",
|
|
532
|
-
`Please send this reminder now: "${reminderFromTopic}"`,
|
|
533
|
-
"Keep the reminder concise and do not run a research workflow unless explicitly requested.",
|
|
534
|
-
].join("\n");
|
|
535
|
-
}
|
|
536
|
-
const trimmedTopic = options.topic?.trim();
|
|
537
|
-
if (!trimmedTopic) {
|
|
538
|
-
return DEFAULT_CRON_PROMPT;
|
|
539
|
-
}
|
|
540
|
-
if (scheduleKind === "at") {
|
|
541
|
-
return `/research-pipeline Run a focused literature study on \"${trimmedTopic}\" and return exactly 3 high-value representative papers (not limited to newly published or previously pushed items). For each paper, include source link, the specific core pain point it addresses, and why that pain point is still important now. Then provide a one-paragraph synthesis answering the user's core question.`;
|
|
542
|
-
}
|
|
543
|
-
return `/research-pipeline Run an incremental literature check focused on \"${trimmedTopic}\". Return only the 3 highest-value papers that have not been pushed before, each with a source link and one-line value summary. If there is no new high-value paper, reply with 'No new literature found.'`;
|
|
544
|
-
}
|
|
545
|
-
function formatUsage() {
|
|
546
|
-
return [
|
|
547
|
-
"## Scientify Scheduled Subscription",
|
|
548
|
-
"",
|
|
549
|
-
"Examples:",
|
|
550
|
-
"- `/research-subscribe`",
|
|
551
|
-
"- `/research-subscribe daily 09:00 Asia/Shanghai`",
|
|
552
|
-
"- `/research-subscribe weekly mon 09:30 Asia/Shanghai`",
|
|
553
|
-
"- `/research-subscribe every 6h`",
|
|
554
|
-
"- `/research-subscribe at 2m`",
|
|
555
|
-
"- `/research-subscribe at 2026-03-04T08:00:00+08:00`",
|
|
556
|
-
"- `/research-subscribe cron \"0 9 * * 1\" Asia/Shanghai`",
|
|
557
|
-
"- `/research-subscribe daily 09:00 --channel feishu --to ou_xxx`",
|
|
558
|
-
"- `/research-subscribe every 2h --channel telegram --to 12345678`",
|
|
559
|
-
"- `/research-subscribe daily 08:00 --topic \"LLM alignment\"`",
|
|
560
|
-
"- `/research-subscribe at 1m --message \"Time to drink coffee.\"`",
|
|
561
|
-
"- `/research-subscribe daily 09:00 --no-deliver`",
|
|
562
|
-
].join("\n");
|
|
563
|
-
}
|
|
564
|
-
function withSignature(text) {
|
|
565
|
-
return `${text}\n${SCIENTIFY_SIGNATURE_FOOTER}`;
|
|
566
|
-
}
|
|
567
|
-
export function createResearchSubscribeHandler(deps) {
|
|
568
|
-
return async (ctx) => {
|
|
569
|
-
const options = parseSubscribeOptions(ctx.args);
|
|
570
|
-
if ("error" in options) {
|
|
571
|
-
return {
|
|
572
|
-
error: options.error,
|
|
573
|
-
text: `${options.error}\n\n${formatUsage()}`,
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
const parsed = parseScheduleArgs(options.scheduleTokens);
|
|
577
|
-
if ("error" in parsed) {
|
|
578
|
-
return {
|
|
579
|
-
error: parsed.error,
|
|
580
|
-
text: `${parsed.error}\n\n${formatUsage()}`,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
if (parsed.kind === "at" && !isDurationLike(parsed.when)) {
|
|
584
|
-
const atMs = Date.parse(parsed.when);
|
|
585
|
-
if (!Number.isNaN(atMs) && atMs <= Date.now()) {
|
|
586
|
-
const error = "Error: `at` time is in the past. Use a future ISO datetime (for example `2026-03-04T08:00:00+08:00`) or a relative duration like `at 5m`.";
|
|
587
|
-
return {
|
|
588
|
-
error,
|
|
589
|
-
text: `${error}\n\n${formatUsage()}`,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
const jobName = buildScopedJobName(ctx);
|
|
594
|
-
const delivery = resolveDeliveryTarget(ctx, options);
|
|
595
|
-
if ("error" in delivery) {
|
|
596
|
-
return {
|
|
597
|
-
error: delivery.error,
|
|
598
|
-
text: `${delivery.error}\n\n${formatUsage()}`,
|
|
599
|
-
};
|
|
600
|
-
}
|
|
601
|
-
try {
|
|
602
|
-
const jobs = await listAllJobs(deps);
|
|
603
|
-
const existing = jobs.filter((job) => job.name === jobName);
|
|
604
|
-
const addArgs = [
|
|
605
|
-
"openclaw",
|
|
606
|
-
"cron",
|
|
607
|
-
"add",
|
|
608
|
-
"--name",
|
|
609
|
-
jobName,
|
|
610
|
-
"--description",
|
|
611
|
-
"Scientify scheduled job",
|
|
612
|
-
"--session",
|
|
613
|
-
"isolated",
|
|
614
|
-
"--message",
|
|
615
|
-
buildScheduledTaskMessage(options, parsed.kind),
|
|
616
|
-
"--timeout-seconds",
|
|
617
|
-
"1800",
|
|
618
|
-
];
|
|
619
|
-
if (delivery.mode === "none") {
|
|
620
|
-
addArgs.push("--no-deliver");
|
|
621
|
-
}
|
|
622
|
-
else {
|
|
623
|
-
addArgs.push("--announce", "--best-effort-deliver", "--channel", delivery.channel);
|
|
624
|
-
if (delivery.to) {
|
|
625
|
-
addArgs.push("--to", delivery.to);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
if (parsed.kind === "cron") {
|
|
629
|
-
addArgs.push("--cron", parsed.expr, "--tz", parsed.tz);
|
|
630
|
-
}
|
|
631
|
-
else if (parsed.kind === "every") {
|
|
632
|
-
addArgs.push("--every", parsed.duration);
|
|
633
|
-
}
|
|
634
|
-
else {
|
|
635
|
-
addArgs.push("--at", parsed.when, "--delete-after-run");
|
|
636
|
-
}
|
|
637
|
-
addArgs.push("--json");
|
|
638
|
-
const addRes = await runCommand(deps, addArgs, 60_000);
|
|
639
|
-
if (addRes.code !== 0) {
|
|
640
|
-
throw new Error(addRes.stderr || "cron add failed");
|
|
641
|
-
}
|
|
642
|
-
const created = parseJsonFromOutput(addRes.stdout);
|
|
643
|
-
if (!created?.id) {
|
|
644
|
-
throw new Error("cron add did not return job id");
|
|
645
|
-
}
|
|
646
|
-
const createdId = created.id;
|
|
647
|
-
// Defensive check: if a job is persisted disabled, enable it immediately.
|
|
648
|
-
const persisted = await ensureJobEnabled(deps, createdId);
|
|
649
|
-
// Hard safety: for concrete announce channels, `delivery.to` must exist.
|
|
650
|
-
if (delivery.mode === "announce" && delivery.channel !== "last" && !created.delivery?.to) {
|
|
651
|
-
await runCommand(deps, ["openclaw", "cron", "rm", createdId, "--json"]).catch(() => undefined);
|
|
652
|
-
throw new Error(`cron add created a job without delivery.to for channel "${delivery.channel}". Refusing to keep this job.`);
|
|
653
|
-
}
|
|
654
|
-
const cleanupErrors = [];
|
|
655
|
-
for (const job of existing) {
|
|
656
|
-
const rm = await runCommand(deps, ["openclaw", "cron", "rm", job.id, "--json"]);
|
|
657
|
-
if (rm.code !== 0) {
|
|
658
|
-
cleanupErrors.push(rm.stderr || `failed to remove previous job ${job.id}`);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
const lines = [
|
|
662
|
-
"Created scheduled job successfully.",
|
|
663
|
-
"",
|
|
664
|
-
`- Job ID: \`${createdId}\``,
|
|
665
|
-
`- Name: \`${jobName}\``,
|
|
666
|
-
`- Enabled: \`${persisted?.enabled === false ? "no" : "yes"}\``,
|
|
667
|
-
`- Schedule: \`${parsed.display}\``,
|
|
668
|
-
`- Delivery: \`${delivery.display}\``,
|
|
669
|
-
"",
|
|
670
|
-
"Useful commands:",
|
|
671
|
-
`- Run now: \`openclaw cron run ${createdId}\``,
|
|
672
|
-
`- Show runs: \`openclaw cron runs --id ${createdId} --limit 20\``,
|
|
673
|
-
"- Cancel: `/research-unsubscribe`",
|
|
674
|
-
];
|
|
675
|
-
if (cleanupErrors.length > 0) {
|
|
676
|
-
lines.push(`- Warning: previous job cleanup had ${cleanupErrors.length} error(s).`);
|
|
677
|
-
}
|
|
678
|
-
const message = lines.join("\n");
|
|
679
|
-
return { text: ctx.channel === "tool" ? message : withSignature(message) };
|
|
680
|
-
}
|
|
681
|
-
catch (error) {
|
|
682
|
-
const message = `Error: failed to create scheduled job: ${error instanceof Error ? error.message : String(error)}`;
|
|
683
|
-
deps.logger.warn(`[scientify-cron] subscribe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
684
|
-
return {
|
|
685
|
-
error: message,
|
|
686
|
-
text: message,
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
export function createResearchUnsubscribeHandler(deps) {
|
|
692
|
-
return async (ctx) => {
|
|
693
|
-
const maybeId = (ctx.args ?? "").trim();
|
|
694
|
-
const jobName = buildScopedJobName(ctx);
|
|
695
|
-
try {
|
|
696
|
-
if (maybeId) {
|
|
697
|
-
const rm = await runCommand(deps, ["openclaw", "cron", "rm", maybeId, "--json"]);
|
|
698
|
-
if (rm.code !== 0) {
|
|
699
|
-
throw new Error(rm.stderr || `failed to remove ${maybeId}`);
|
|
700
|
-
}
|
|
701
|
-
return { text: `Removed scheduled job: \`${maybeId}\`` };
|
|
702
|
-
}
|
|
703
|
-
const jobs = await listAllJobs(deps);
|
|
704
|
-
const mine = jobs.filter((job) => job.name === jobName);
|
|
705
|
-
if (mine.length === 0) {
|
|
706
|
-
return {
|
|
707
|
-
text: "No Scientify scheduled jobs found for this scope.",
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
for (const job of mine) {
|
|
711
|
-
const rm = await runCommand(deps, ["openclaw", "cron", "rm", job.id, "--json"]);
|
|
712
|
-
if (rm.code !== 0) {
|
|
713
|
-
throw new Error(rm.stderr || `failed to remove ${job.id}`);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
return {
|
|
717
|
-
text: `Canceled Scientify subscription. Removed ${mine.length} job(s).`,
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
catch (error) {
|
|
721
|
-
deps.logger.warn(`[scientify-cron] unsubscribe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
722
|
-
const message = `Error: failed to cancel subscription: ${error instanceof Error ? error.message : String(error)}`;
|
|
723
|
-
return {
|
|
724
|
-
error: message,
|
|
725
|
-
text: message,
|
|
726
|
-
};
|
|
727
|
-
}
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
export function createResearchSubscriptionsHandler(deps) {
|
|
731
|
-
return async (ctx) => {
|
|
732
|
-
const jobName = buildScopedJobName(ctx);
|
|
733
|
-
try {
|
|
734
|
-
const jobs = await listAllJobs(deps);
|
|
735
|
-
const mine = jobs.filter((job) => job.name === jobName);
|
|
736
|
-
if (mine.length === 0) {
|
|
737
|
-
return {
|
|
738
|
-
text: "No Scientify scheduled jobs found. Use `/research-subscribe daily 09:00 Asia/Shanghai` to create one.",
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
const lines = ["Your Scientify scheduled jobs:", ""];
|
|
742
|
-
for (const job of mine) {
|
|
743
|
-
lines.push(`- ID: \`${job.id}\``);
|
|
744
|
-
lines.push(` enabled: ${job.enabled ? "yes" : "no"}`);
|
|
745
|
-
lines.push(` schedule: \`${scheduleText(job)}\``);
|
|
746
|
-
lines.push(` delivery: \`${job.delivery?.channel ?? "unknown"}${job.delivery?.to ? `:${job.delivery.to}` : ""}\``);
|
|
747
|
-
}
|
|
748
|
-
lines.push("");
|
|
749
|
-
lines.push("Cancel all: `/research-unsubscribe`");
|
|
750
|
-
return { text: lines.join("\n") };
|
|
751
|
-
}
|
|
752
|
-
catch (error) {
|
|
753
|
-
deps.logger.warn(`[scientify-cron] list subscriptions failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
754
|
-
const message = `Error: failed to list scheduled jobs: ${error instanceof Error ? error.message : String(error)}`;
|
|
755
|
-
return {
|
|
756
|
-
error: message,
|
|
757
|
-
text: message,
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
};
|
|
761
|
-
}
|
|
1
|
+
export { createResearchSubscribeHandler, createResearchSubscriptionsHandler, createResearchUnsubscribeHandler, } from "./research-subscriptions/handlers.js";
|
|
762
2
|
//# sourceMappingURL=research-subscriptions.js.map
|