scientify 1.12.1 → 1.12.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.
Files changed (59) hide show
  1. package/README.md +3 -1
  2. package/README.zh.md +3 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -5
  5. package/dist/index.js.map +1 -1
  6. package/dist/src/cli/research.d.ts +1 -1
  7. package/dist/src/cli/research.d.ts.map +1 -1
  8. package/dist/src/cli/research.js +123 -227
  9. package/dist/src/cli/research.js.map +1 -1
  10. package/dist/src/commands/metabolism-status.d.ts +2 -2
  11. package/dist/src/commands/metabolism-status.d.ts.map +1 -1
  12. package/dist/src/commands/metabolism-status.js +75 -72
  13. package/dist/src/commands/metabolism-status.js.map +1 -1
  14. package/dist/src/commands.d.ts.map +1 -1
  15. package/dist/src/commands.js +55 -0
  16. package/dist/src/commands.js.map +1 -1
  17. package/dist/src/hooks/research-mode.d.ts.map +1 -1
  18. package/dist/src/hooks/research-mode.js +54 -37
  19. package/dist/src/hooks/research-mode.js.map +1 -1
  20. package/dist/src/hooks/scientify-signature.d.ts.map +1 -1
  21. package/dist/src/hooks/scientify-signature.js +5 -2
  22. package/dist/src/hooks/scientify-signature.js.map +1 -1
  23. package/dist/src/knowledge-state/render.d.ts +1 -0
  24. package/dist/src/knowledge-state/render.d.ts.map +1 -1
  25. package/dist/src/knowledge-state/render.js +101 -33
  26. package/dist/src/knowledge-state/render.js.map +1 -1
  27. package/dist/src/knowledge-state/store.d.ts.map +1 -1
  28. package/dist/src/knowledge-state/store.js +206 -33
  29. package/dist/src/knowledge-state/store.js.map +1 -1
  30. package/dist/src/knowledge-state/types.d.ts +12 -0
  31. package/dist/src/knowledge-state/types.d.ts.map +1 -1
  32. package/dist/src/literature/subscription-state.d.ts.map +1 -1
  33. package/dist/src/literature/subscription-state.js +579 -7
  34. package/dist/src/literature/subscription-state.js.map +1 -1
  35. package/dist/src/research-subscriptions/constants.d.ts +1 -1
  36. package/dist/src/research-subscriptions/constants.js +1 -1
  37. package/dist/src/research-subscriptions/parse.d.ts.map +1 -1
  38. package/dist/src/research-subscriptions/parse.js +10 -0
  39. package/dist/src/research-subscriptions/parse.js.map +1 -1
  40. package/dist/src/research-subscriptions/prompt.d.ts +1 -1
  41. package/dist/src/research-subscriptions/prompt.d.ts.map +1 -1
  42. package/dist/src/research-subscriptions/prompt.js +142 -221
  43. package/dist/src/research-subscriptions/prompt.js.map +1 -1
  44. package/dist/src/research-subscriptions/types.d.ts +1 -0
  45. package/dist/src/research-subscriptions/types.d.ts.map +1 -1
  46. package/dist/src/templates/bootstrap.d.ts.map +1 -1
  47. package/dist/src/templates/bootstrap.js +19 -32
  48. package/dist/src/templates/bootstrap.js.map +1 -1
  49. package/dist/src/tools/scientify-cron.d.ts +4 -2
  50. package/dist/src/tools/scientify-cron.d.ts.map +1 -1
  51. package/dist/src/tools/scientify-cron.js +369 -17
  52. package/dist/src/tools/scientify-cron.js.map +1 -1
  53. package/dist/src/tools/scientify-literature-state.d.ts +8 -0
  54. package/dist/src/tools/scientify-literature-state.d.ts.map +1 -1
  55. package/dist/src/tools/scientify-literature-state.js +140 -71
  56. package/dist/src/tools/scientify-literature-state.js.map +1 -1
  57. package/openclaw.plugin.json +2 -4
  58. package/package.json +1 -1
  59. package/skills/research-subscription/SKILL.md +7 -0
@@ -13,36 +13,25 @@ export function renderBootstrapMd(projectName) {
13
13
  2. 根据用户回答,提取:
14
14
  - 核心域关键词(3-5 个)
15
15
  - 建议的 arXiv 分类(如 cs.LG, cs.AI)
16
- - 建议的监测带相邻领域分类
16
+ - 建议的跨域探索方向(反射带)
17
17
  3. 向用户确认以上配置,接受调整
18
18
  4. 确认后执行以下写入操作:
19
19
  - 更新 SOUL.md:填写研究方向、核心域、监测带各字段
20
- - 生成 metabolism/config.json(参考下方模板)
21
- 5. 询问用户是否立即执行 Day 0(构建初始知识状态 K(T0))
22
- - 如果是,使用 arxiv_search 按配置的关键词检索论文,执行 skills/metabolism/SKILL.md 中的四步循环
20
+ - 生成 task.json(记录 topic / mode / created)
21
+ 5. 询问用户是否立即执行首轮研究(持续研究引擎)
22
+ - 如果是,执行 prepare -> collect/filter -> reflect -> record -> status
23
+ - 研究状态写入 knowledge_state/
23
24
  6. 删除本文件(BOOTSTRAP.md)
24
25
 
25
- ## config.json 模板
26
+ ## task.json 模板
26
27
 
27
28
  \`\`\`json
28
29
  {
29
30
  "projectId": "${projectName}",
30
- "coreQuery": {
31
- "keywords": ["关键词1", "关键词2"],
32
- "arxivCategories": ["cs.LG"],
33
- "dateMode": "daily-new"
34
- },
35
- "monitorZone": {
36
- "categories": ["相邻领域分类"],
37
- "enabled": true
38
- },
39
- "heartbeat": {
40
- "cronExpression": "0 6 * * *",
41
- "timezone": "Asia/Shanghai",
42
- "enabled": true
43
- },
44
- "agentId": "research-${projectName}",
45
- "currentDay": 0,
31
+ "topic": "由用户确认后的研究主题",
32
+ "coreKeywords": ["关键词1", "关键词2"],
33
+ "monitorKeywords": ["跨域探索关键词"],
34
+ "mode": "continuous-research-engine",
46
35
  "createdAt": "${new Date().toISOString()}"
47
36
  }
48
37
  \`\`\`
@@ -75,14 +64,13 @@ export function renderAgentsMd() {
75
64
  $W/
76
65
  ├── SOUL.md # 身份 + 研究方向
77
66
  ├── AGENTS.md # 本文档
78
- ├── metabolism/ # 知识新陈代谢
79
- │ ├── config.json # 项目配置(关键词、分类、heartbeat)
80
- │ ├── knowledge/ # K(t) 持久知识状态
81
- ├── _index.md # 全景索引
82
- │ └── topic-*.md # 主题文件(上限 50)
83
- │ ├── diffs/ # 每日 diff 报告
84
- ├── hypotheses/ # 生成的假设
85
- │ └── log/ # 运行日志
67
+ ├── knowledge_state/ # 持续研究状态真源(唯一)
68
+ │ ├── knowledge/
69
+ │ ├── daily_changes/
70
+ │ ├── hypotheses/
71
+ ├── logs/
72
+ │ ├── state.json
73
+ └── events.jsonl
86
74
  ├── survey/ # /research-collect outputs
87
75
  │ ├── search_terms.json
88
76
  │ └── report.md
@@ -102,14 +90,13 @@ $W/
102
90
  ├── iterations/ # /research-review: 审查迭代
103
91
  │ └── judge_v*.md
104
92
  ├── experiment_res.md # /research-experiment: 实验报告
105
- └── skills/ # workspace skills (metabolism 等)
106
93
  \`\`\`
107
94
 
108
95
  ## Session Context
109
96
 
110
97
  你可能在不同类型的 session 中被唤醒:
111
98
  - **Main session**:与人类直接对话,可触发 research-pipeline 等编排 skill
112
- - **Cron session**:定时触发,执行周期性任务(如每日 metabolism)
99
+ - **Cron session**:定时触发,执行周期性研究心跳
113
100
  - **Spawn session**:被 main session 调度(sessions_spawn),执行一次性重任务
114
101
 
115
102
  任务指令会在 session 启动时注入,按指令执行即可。
@@ -123,7 +110,7 @@ $W/
123
110
  产出文件一旦写入不修改,除非用户明确要求。例外:\`project/\` 在 implement-review 迭代中可变。
124
111
 
125
112
  ### Knowledge File Rules
126
- - knowledge/ 下的文件是持久知识状态,修改需谨慎
113
+ - knowledge_state/ 下的文件是持久知识状态,修改需谨慎
127
114
  - 每次修改必须先读取当前内容再更新
128
115
  - _index.md 是全景索引,必须与 topic 文件保持同步
129
116
  - topic 文件数上限 50,低活跃主题应合并归档
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../../../src/templates/bootstrap.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,OAAO;;YAEG,WAAW;;;;kBAIL,WAAW;;;;;;;;;;;;;;;;;kBAiBX,WAAW;;;;;;;;;;;;;;;yBAeJ,WAAW;;kBAElB,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;;;CAGzC,CAAC;AACF,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,WAAmB;IAC9C,OAAO,qBAAqB,WAAW;;OAElC,WAAW;;;;;;;;;;;CAWjB,CAAC;AACF,CAAC;AAED,MAAM,UAAU,cAAc;IAC5B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmFR,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../../../src/templates/bootstrap.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,OAAO;;YAEG,WAAW;;;;kBAIL,WAAW;;;;;;;;;;;;;;;;;;kBAkBX,WAAW;;;;;kBAKX,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;;;CAGzC,CAAC;AACF,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,WAAmB;IAC9C,OAAO,qBAAqB,WAAW;;OAElC,WAAW;;;;;;;;;;;CAWjB,CAAC;AACF,CAAC;AAED,MAAM,UAAU,cAAc;IAC5B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiFR,CAAC;AACF,CAAC"}
@@ -1,6 +1,6 @@
1
1
  import type { PluginLogger, PluginRuntime } from "openclaw";
2
2
  export declare const ScientifyCronToolSchema: import("@sinclair/typebox").TObject<{
3
- action: import("@sinclair/typebox").TString;
3
+ action: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
4
4
  scope: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
5
5
  schedule: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
6
6
  topic: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -19,6 +19,7 @@ export declare const ScientifyCronToolSchema: import("@sinclair/typebox").TObjec
19
19
  channel: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
20
20
  to: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
21
21
  no_deliver: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
22
+ metadata_only: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
22
23
  run_now: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
23
24
  job_id: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
24
25
  }>;
@@ -31,7 +32,7 @@ export declare function createScientifyCronTool(deps: CronToolDeps): {
31
32
  name: string;
32
33
  description: string;
33
34
  parameters: import("@sinclair/typebox").TObject<{
34
- action: import("@sinclair/typebox").TString;
35
+ action: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
35
36
  scope: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
36
37
  schedule: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
37
38
  topic: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -50,6 +51,7 @@ export declare function createScientifyCronTool(deps: CronToolDeps): {
50
51
  channel: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
51
52
  to: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
52
53
  no_deliver: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
54
+ metadata_only: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
53
55
  run_now: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
54
56
  job_id: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
55
57
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"scientify-cron.d.ts","sourceRoot":"","sources":["../../../src/tools/scientify-cron.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAA6C,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AASvG,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;EAyFlC,CAAC;AAEH,KAAK,YAAY,GAAG;IAClB,OAAO,EAAE,aAAa,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB,CAAC;AAwLF,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAWzB,MAAM,WAAW,OAAO;;;;;;;;EAuFxD"}
1
+ {"version":3,"file":"scientify-cron.d.ts","sourceRoot":"","sources":["../../../src/tools/scientify-cron.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAA6C,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAevG,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;EAiGlC,CAAC;AAEH,KAAK,YAAY,GAAG;IAClB,OAAO,EAAE,aAAa,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB,CAAC;AAobF,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAWzB,MAAM,WAAW,OAAO;;;;;;;;EAuNxD"}
@@ -1,11 +1,13 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { normalizeDeliveryChannelOverride } from "../research-subscriptions/delivery.js";
2
+ import { buildStateScopeKey, normalizeDeliveryChannelOverride, resolveDeliveryTarget, } from "../research-subscriptions/delivery.js";
3
+ import { parseSubscribeOptions } from "../research-subscriptions/parse.js";
3
4
  import { createResearchSubscribeHandler, createResearchSubscriptionsHandler, createResearchUnsubscribeHandler, } from "../research-subscriptions.js";
5
+ import { getIncrementalStateStatus, recordIncrementalPush } from "../literature/subscription-state.js";
4
6
  import { Result } from "./result.js";
5
7
  export const ScientifyCronToolSchema = Type.Object({
6
- action: Type.String({
7
- description: 'Action: "upsert" | "list" | "remove".',
8
- }),
8
+ action: Type.Optional(Type.String({
9
+ description: 'Action: "upsert" | "list" | "remove". When omitted, tool infers action from parameters.',
10
+ })),
9
11
  scope: Type.Optional(Type.String({
10
12
  description: "Scope key for grouping jobs (e.g. user ID or thread ID). Default: global.",
11
13
  })),
@@ -48,8 +50,11 @@ export const ScientifyCronToolSchema = Type.Object({
48
50
  no_deliver: Type.Optional(Type.Boolean({
49
51
  description: "If true, run in background without push delivery.",
50
52
  })),
53
+ metadata_only: Type.Optional(Type.Boolean({
54
+ description: "If true, allow metadata-only reading (skip full-text-first strict default). Use only when user explicitly requests it.",
55
+ })),
51
56
  run_now: Type.Optional(Type.Boolean({
52
- description: "If true (upsert only), trigger one immediate run after job creation/update and return the run result handle.",
57
+ description: "If true (upsert only), trigger one immediate run after job creation/update; for research tasks, also return a status_json snapshot.",
53
58
  })),
54
59
  job_id: Type.Optional(Type.String({
55
60
  description: "Specific job id to remove (only used when action=remove).",
@@ -64,6 +69,78 @@ function readStringParam(params, key) {
64
69
  const str = String(value).trim();
65
70
  return str.length > 0 ? str : undefined;
66
71
  }
72
+ function sanitizeProjectId(raw) {
73
+ const trimmed = raw.trim();
74
+ if (/^[A-Za-z0-9_-]+$/.test(trimmed))
75
+ return trimmed;
76
+ const slug = trimmed
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9_-]+/g, "-")
79
+ .replace(/-+/g, "-")
80
+ .replace(/^-|-$/g, "");
81
+ return slug || "project";
82
+ }
83
+ function normalizeScheduleInput(raw, runNow) {
84
+ if (!raw)
85
+ return undefined;
86
+ const trimmed = raw.trim();
87
+ if (!trimmed)
88
+ return undefined;
89
+ const lower = trimmed.toLowerCase();
90
+ if (["now", "immediate", "immediately", "right now", "asap", "立即", "马上", "立刻"].includes(lower)) {
91
+ // run_now already triggers immediate execution; keep a valid persistent schedule.
92
+ return runNow ? "daily 09:00 Asia/Shanghai" : "at 2m";
93
+ }
94
+ if (/^\d+[smhdw]$/i.test(trimmed)) {
95
+ return `at ${trimmed}`;
96
+ }
97
+ if (/^every\s*hour$/i.test(trimmed) || /^每小时$/u.test(trimmed)) {
98
+ return "every 1h";
99
+ }
100
+ // Guard against `at <past-time>` generated by model/tool callers.
101
+ if (lower.startsWith("at ")) {
102
+ const when = trimmed.slice(3).trim();
103
+ if (when) {
104
+ const atMs = Date.parse(when);
105
+ if (!Number.isNaN(atMs) && atMs <= Date.now()) {
106
+ return runNow ? "daily 09:00 Asia/Shanghai" : "at 2m";
107
+ }
108
+ }
109
+ }
110
+ return trimmed;
111
+ }
112
+ function inferAction(params) {
113
+ const raw = readStringParam(params, "action")?.toLowerCase();
114
+ if (raw) {
115
+ if (["upsert", "create", "add", "set", "update", "start", "schedule", "new", "insert"].includes(raw)) {
116
+ return "upsert";
117
+ }
118
+ if (["list", "show", "ls", "status"].includes(raw)) {
119
+ return "list";
120
+ }
121
+ if (["remove", "delete", "cancel", "rm", "unsubscribe"].includes(raw)) {
122
+ return "remove";
123
+ }
124
+ }
125
+ const hasJobId = Boolean(readStringParam(params, "job_id"));
126
+ const hasUpsertSignals = Boolean(readStringParam(params, "schedule")) ||
127
+ Boolean(readStringParam(params, "topic")) ||
128
+ Boolean(readStringParam(params, "message")) ||
129
+ Boolean(readStringParam(params, "project")) ||
130
+ readBooleanParam(params, "run_now") ||
131
+ readBooleanParam(params, "no_deliver") ||
132
+ readBooleanParam(params, "metadata_only") ||
133
+ readNumberParam(params, "max_papers") !== undefined ||
134
+ readNumberParam(params, "recency_days") !== undefined ||
135
+ readNumberParam(params, "candidate_pool") !== undefined ||
136
+ Boolean(readStringParam(params, "channel")) ||
137
+ Boolean(readStringParam(params, "to"));
138
+ if (hasUpsertSignals)
139
+ return "upsert";
140
+ if (hasJobId)
141
+ return "remove";
142
+ return "list";
143
+ }
67
144
  function readBooleanParam(params, key) {
68
145
  return params[key] === true;
69
146
  }
@@ -155,22 +232,179 @@ function deriveTopicFromResearchMessage(message) {
155
232
  const normalized = text.trim();
156
233
  return normalized.length > 0 ? normalized : message.trim();
157
234
  }
158
- function buildSubscribeArgs(params) {
159
- const parts = [];
160
- const schedule = readStringParam(params, "schedule") ?? "daily 09:00 Asia/Shanghai";
161
- parts.push(schedule);
235
+ function resolveTopicAndMessage(params) {
162
236
  let topic = readStringParam(params, "topic");
163
237
  let message = readStringParam(params, "message");
164
238
  if (!topic && message && shouldPromoteMessageToTopic(message)) {
165
239
  topic = deriveTopicFromResearchMessage(message);
166
240
  message = undefined;
167
241
  }
242
+ return { topic, message };
243
+ }
244
+ function parseIncrementalScopeFromResultText(text) {
245
+ const fenced = text.match(/Incremental Scope:\s*`([^`]+)`/i);
246
+ if (fenced?.[1])
247
+ return fenced[1].trim();
248
+ const plain = text.match(/Incremental Scope:\s*([^\n]+)/i);
249
+ if (plain?.[1])
250
+ return plain[1].trim();
251
+ return undefined;
252
+ }
253
+ function latestRunId(status) {
254
+ return status?.recentChangeStats[0]?.runId;
255
+ }
256
+ function lastRunAtMs(status) {
257
+ return status?.knowledgeStateSummary?.lastRunAtMs ?? 0;
258
+ }
259
+ function lastPushedAtMs(status) {
260
+ return status?.lastPushedAtMs ?? 0;
261
+ }
262
+ function hasFreshRun(before, after) {
263
+ const beforeRunId = latestRunId(before);
264
+ const afterRunId = latestRunId(after);
265
+ if (!before) {
266
+ return after.totalRuns > 0 || Boolean(afterRunId) || lastRunAtMs(after) > 0 || lastPushedAtMs(after) > 0;
267
+ }
268
+ if (after.totalRuns > before.totalRuns)
269
+ return true;
270
+ if (afterRunId && beforeRunId && afterRunId !== beforeRunId)
271
+ return true;
272
+ if (!beforeRunId && afterRunId)
273
+ return true;
274
+ if (lastRunAtMs(after) > lastRunAtMs(before))
275
+ return true;
276
+ if (lastPushedAtMs(after) > lastPushedAtMs(before))
277
+ return true;
278
+ return false;
279
+ }
280
+ function buildFallbackRunId(jobId) {
281
+ const ts = new Date().toISOString().replace(/[-:.]/g, "").replace("T", "t").replace("Z", "z");
282
+ return `cron-${jobId}-${ts}-autofallback`;
283
+ }
284
+ function sleep(ms) {
285
+ return new Promise((resolve) => setTimeout(resolve, ms));
286
+ }
287
+ function parseCronRunMarker(raw) {
288
+ const text = (raw ?? "").trim();
289
+ if (!text)
290
+ return undefined;
291
+ try {
292
+ const parsed = JSON.parse(text);
293
+ return {
294
+ ok: typeof parsed.ok === "boolean" ? parsed.ok : undefined,
295
+ ran: typeof parsed.ran === "boolean" ? parsed.ran : undefined,
296
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
297
+ };
298
+ }
299
+ catch {
300
+ return undefined;
301
+ }
302
+ }
303
+ function serializeRunStatusSnapshot(status) {
304
+ const projectRecentPapers = status.knowledgeStateSummary?.recentPapers ?? [];
305
+ const globalById = new Map(status.recentPapers.map((paper) => [paper.id, paper]));
306
+ return {
307
+ scope: status.scope,
308
+ topic: status.topic,
309
+ topic_key: status.topicKey,
310
+ known_paper_count: status.knownPaperCount,
311
+ total_runs: status.totalRuns,
312
+ last_status: status.lastStatus ?? null,
313
+ last_pushed_at_ms: status.lastPushedAtMs ?? null,
314
+ latest_run_id: status.recentChangeStats[0]?.runId ?? null,
315
+ knowledge_state_summary: status.knowledgeStateSummary
316
+ ? {
317
+ project_id: status.knowledgeStateSummary.projectId,
318
+ stream_key: status.knowledgeStateSummary.streamKey,
319
+ run_profile: status.knowledgeStateSummary.runProfile,
320
+ total_runs: status.knowledgeStateSummary.totalRuns,
321
+ total_hypotheses: status.knowledgeStateSummary.totalHypotheses,
322
+ knowledge_topics_count: status.knowledgeStateSummary.knowledgeTopicsCount,
323
+ paper_notes_count: status.knowledgeStateSummary.paperNotesCount,
324
+ trigger_state: {
325
+ consecutive_new_revise_days: status.knowledgeStateSummary.triggerState.consecutiveNewReviseDays,
326
+ bridge_count_7d: status.knowledgeStateSummary.triggerState.bridgeCount7d,
327
+ unread_core_backlog: status.knowledgeStateSummary.triggerState.unreadCoreBacklog,
328
+ last_updated_at_ms: status.knowledgeStateSummary.triggerState.lastUpdatedAtMs,
329
+ },
330
+ quality_gate: {
331
+ passed: status.knowledgeStateSummary.qualityGate.passed,
332
+ full_text_coverage_pct: status.knowledgeStateSummary.qualityGate.fullTextCoveragePct,
333
+ evidence_binding_rate_pct: status.knowledgeStateSummary.qualityGate.evidenceBindingRatePct,
334
+ citation_error_rate_pct: status.knowledgeStateSummary.qualityGate.citationErrorRatePct,
335
+ reasons: status.knowledgeStateSummary.qualityGate.reasons,
336
+ },
337
+ hypothesis_gate: {
338
+ accepted: status.knowledgeStateSummary.hypothesisGate.accepted,
339
+ rejected: status.knowledgeStateSummary.hypothesisGate.rejected,
340
+ rejection_reasons: status.knowledgeStateSummary.hypothesisGate.rejectionReasons,
341
+ },
342
+ last_reflection_tasks: status.knowledgeStateSummary.lastReflectionTasks,
343
+ }
344
+ : null,
345
+ recent_change_stats: status.recentChangeStats.map((item) => ({
346
+ day: item.day,
347
+ run_id: item.runId,
348
+ new_count: item.newCount,
349
+ confirm_count: item.confirmCount,
350
+ revise_count: item.reviseCount,
351
+ bridge_count: item.bridgeCount,
352
+ })),
353
+ recent_papers: (projectRecentPapers.length > 0 ? projectRecentPapers : status.recentPapers).map((paper) => {
354
+ const paperId = typeof paper.id === "string" ? paper.id : "";
355
+ const fromGlobal = paperId ? globalById.get(paperId) : undefined;
356
+ return {
357
+ id: paperId || null,
358
+ title: paper.title ?? null,
359
+ url: paper.url ?? null,
360
+ last_score: "lastScore" in paper && typeof paper.lastScore === "number"
361
+ ? paper.lastScore
362
+ : "score" in paper && typeof paper.score === "number"
363
+ ? paper.score
364
+ : fromGlobal?.lastScore ?? null,
365
+ last_reason: "lastReason" in paper && typeof paper.lastReason === "string"
366
+ ? paper.lastReason
367
+ : "reason" in paper && typeof paper.reason === "string"
368
+ ? paper.reason
369
+ : fromGlobal?.lastReason ?? null,
370
+ first_pushed_at_ms: "firstPushedAtMs" in paper && typeof paper.firstPushedAtMs === "number"
371
+ ? paper.firstPushedAtMs
372
+ : fromGlobal?.firstPushedAtMs ?? null,
373
+ last_pushed_at_ms: "lastPushedAtMs" in paper && typeof paper.lastPushedAtMs === "number"
374
+ ? paper.lastPushedAtMs
375
+ : fromGlobal?.lastPushedAtMs ?? null,
376
+ push_count: "pushCount" in paper && typeof paper.pushCount === "number"
377
+ ? paper.pushCount
378
+ : fromGlobal?.pushCount ?? null,
379
+ };
380
+ }),
381
+ global_recent_papers: status.recentPapers.map((paper) => ({
382
+ id: paper.id,
383
+ title: paper.title ?? null,
384
+ url: paper.url ?? null,
385
+ last_score: paper.lastScore ?? null,
386
+ last_reason: paper.lastReason ?? null,
387
+ first_pushed_at_ms: paper.firstPushedAtMs,
388
+ last_pushed_at_ms: paper.lastPushedAtMs,
389
+ push_count: paper.pushCount,
390
+ })),
391
+ knowledge_state_missing_reason: status.knowledgeStateMissingReason ?? null,
392
+ };
393
+ }
394
+ function buildSubscribeArgs(params) {
395
+ const parts = [];
396
+ const schedule = normalizeScheduleInput(readStringParam(params, "schedule"), readBooleanParam(params, "run_now")) ??
397
+ "daily 09:00 Asia/Shanghai";
398
+ parts.push(schedule);
399
+ const resolved = resolveTopicAndMessage(params);
400
+ const topic = resolved.topic;
401
+ const message = resolved.message;
168
402
  if (topic) {
169
403
  parts.push("--topic", quoteArg(topic));
170
404
  }
171
405
  const project = readStringParam(params, "project");
172
406
  if (project) {
173
- parts.push("--project", quoteArg(project));
407
+ parts.push("--project", quoteArg(sanitizeProjectId(project)));
174
408
  }
175
409
  if (message) {
176
410
  parts.push("--message", quoteArg(message));
@@ -212,6 +446,9 @@ function buildSubscribeArgs(params) {
212
446
  if (readBooleanParam(params, "no_deliver")) {
213
447
  parts.push("--no-deliver");
214
448
  }
449
+ if (readBooleanParam(params, "metadata_only")) {
450
+ parts.push("--metadata-only");
451
+ }
215
452
  return parts.join(" ");
216
453
  }
217
454
  export function createScientifyCronTool(deps) {
@@ -225,12 +462,32 @@ export function createScientifyCronTool(deps) {
225
462
  parameters: ScientifyCronToolSchema,
226
463
  execute: async (_toolCallId, rawArgs) => {
227
464
  const params = rawArgs;
228
- const action = (readStringParam(params, "action") ?? "").toLowerCase();
465
+ const action = inferAction(params);
466
+ if (!action) {
467
+ return Result.err("invalid_params", 'Unable to infer action. Use one of: action="upsert" | "list" | "remove".');
468
+ }
229
469
  const scope = normalizeScope(readStringParam(params, "scope"));
230
470
  try {
231
471
  if (action === "upsert") {
232
- const args = buildSubscribeArgs(params);
472
+ // In tool context, delivery target may be unavailable unless caller explicitly sets channel/to.
473
+ // Default to no-deliver when delivery is unspecified to avoid hard failure on creation.
474
+ const hasDeliveryHints = Boolean(readStringParam(params, "channel")) || Boolean(readStringParam(params, "to"));
475
+ const upsertParams = readBooleanParam(params, "no_deliver") || hasDeliveryHints ? params : { ...params, no_deliver: true };
476
+ const args = buildSubscribeArgs(upsertParams);
233
477
  const ctx = buildToolContext(scope, args, `/research-subscribe ${args}`);
478
+ let expectedStateScopeKey;
479
+ try {
480
+ const parsed = parseSubscribeOptions(args);
481
+ if (!("error" in parsed)) {
482
+ const delivery = resolveDeliveryTarget(ctx, parsed);
483
+ if (!("error" in delivery)) {
484
+ expectedStateScopeKey = buildStateScopeKey(ctx, delivery);
485
+ }
486
+ }
487
+ }
488
+ catch {
489
+ // keep best-effort behavior; fallback to parsed text scope or caller scope
490
+ }
234
491
  const res = await subscribe(ctx);
235
492
  const err = getResultError(res);
236
493
  if (err) {
@@ -238,15 +495,109 @@ export function createScientifyCronTool(deps) {
238
495
  }
239
496
  const text = getResultText(res);
240
497
  const jobId = parseJobIdFromResultText(text);
241
- const runNow = readBooleanParam(params, "run_now");
498
+ const resolved = resolveTopicAndMessage(upsertParams);
499
+ const incrementalScope = parseIncrementalScopeFromResultText(text);
500
+ const project = readStringParam(upsertParams, "project");
501
+ const runNow = readBooleanParam(upsertParams, "run_now");
242
502
  if (runNow && jobId) {
243
- let runRes = await deps.runtime.system.runCommandWithTimeout(["openclaw", "cron", "run", jobId, "--json"], { timeoutMs: 120_000 });
503
+ const statusScope = expectedStateScopeKey ?? incrementalScope ?? scope;
504
+ const beforeStatus = resolved.topic
505
+ ? await getIncrementalStateStatus({
506
+ scope: statusScope,
507
+ topic: resolved.topic,
508
+ ...(project ? { projectId: project } : {}),
509
+ }).catch(() => undefined)
510
+ : undefined;
511
+ const runArgsPrimary = [
512
+ "openclaw",
513
+ "cron",
514
+ "run",
515
+ jobId,
516
+ "--expect-final",
517
+ "--timeout",
518
+ "900000",
519
+ ];
520
+ let runRes = await deps.runtime.system.runCommandWithTimeout(runArgsPrimary, {
521
+ timeoutMs: 920_000,
522
+ });
244
523
  if (runRes.code !== 0 &&
245
- /unknown option '--json'|unknown option \"--json\"|unknown option\s+--json/i.test(runRes.stderr || "")) {
246
- runRes = await deps.runtime.system.runCommandWithTimeout(["openclaw", "cron", "run", jobId], { timeoutMs: 120_000 });
524
+ /unknown option '--expect-final'|unknown option \"--expect-final\"|unknown option\s+--expect-final/i.test(runRes.stderr || "")) {
525
+ // Backward compatibility for older OpenClaw versions.
526
+ runRes = await deps.runtime.system.runCommandWithTimeout(["openclaw", "cron", "run", jobId], { timeoutMs: 600_000 });
247
527
  }
528
+ let runAlreadyInProgress = false;
248
529
  if (runRes.code !== 0) {
249
- return Result.err("operation_failed", runRes.stderr || `cron run failed for job ${jobId}`);
530
+ const marker = parseCronRunMarker(runRes.stdout) ?? parseCronRunMarker(runRes.stderr);
531
+ if (marker?.ok === true && marker?.ran === false && marker?.reason === "already-running") {
532
+ runAlreadyInProgress = true;
533
+ }
534
+ else {
535
+ return Result.err("operation_failed", runRes.stderr || runRes.stdout || `cron run failed for job ${jobId}`);
536
+ }
537
+ }
538
+ let statusSnapshot;
539
+ if (resolved.topic) {
540
+ try {
541
+ let status;
542
+ const deadline = Date.now() + (runAlreadyInProgress ? 300_000 : 120_000);
543
+ while (Date.now() <= deadline) {
544
+ const fetched = await getIncrementalStateStatus({
545
+ scope: statusScope,
546
+ topic: resolved.topic,
547
+ ...(project ? { projectId: project } : {}),
548
+ }).catch(() => undefined);
549
+ if (fetched && hasFreshRun(beforeStatus, fetched)) {
550
+ status = fetched;
551
+ break;
552
+ }
553
+ await sleep(1_000);
554
+ }
555
+ if (!status) {
556
+ const fallbackError = "run_now completed but no new persisted research run was detected. Auto-persisted fallback error run.";
557
+ try {
558
+ const persisted = await recordIncrementalPush({
559
+ scope: statusScope,
560
+ topic: resolved.topic,
561
+ ...(project ? { projectId: project } : {}),
562
+ status: "degraded_quality",
563
+ runId: buildFallbackRunId(jobId),
564
+ note: fallbackError,
565
+ papers: [],
566
+ knowledgeState: {
567
+ corePapers: [],
568
+ explorationPapers: [],
569
+ explorationTrace: [],
570
+ knowledgeChanges: [],
571
+ knowledgeUpdates: [],
572
+ hypotheses: [],
573
+ runLog: {
574
+ runProfile: readBooleanParam(upsertParams, "metadata_only") ? "fast" : "strict",
575
+ error: "run_now completed but the agent turn did not persist via scientify_literature_state.record",
576
+ notes: "Fallback persisted by scientify_cron_job guard to avoid stale status response.",
577
+ tempCleanupStatus: "not_needed",
578
+ },
579
+ },
580
+ });
581
+ status = await getIncrementalStateStatus({
582
+ scope: statusScope,
583
+ topic: resolved.topic,
584
+ ...(project ? { projectId: project } : {}),
585
+ }).catch(() => undefined);
586
+ if (!status || !hasFreshRun(beforeStatus, status)) {
587
+ return Result.err("operation_failed", `${fallbackError} fallback_run_id=${persisted.runId}, but fresh status still unavailable.`);
588
+ }
589
+ }
590
+ catch (persistError) {
591
+ return Result.err("operation_failed", `run_now completed but no new persisted research run was detected. Refusing stale status response. fallback_persist_error=${persistError instanceof Error ? persistError.message : String(persistError)}`);
592
+ }
593
+ }
594
+ statusSnapshot = serializeRunStatusSnapshot(status);
595
+ }
596
+ catch (statusError) {
597
+ statusSnapshot = {
598
+ error: statusError instanceof Error ? statusError.message : String(statusError),
599
+ };
600
+ }
250
601
  }
251
602
  return Result.ok({
252
603
  action,
@@ -254,6 +605,7 @@ export function createScientifyCronTool(deps) {
254
605
  job_id: jobId,
255
606
  run_now: true,
256
607
  run_result: runRes.stdout.trim(),
608
+ ...(statusSnapshot ? { status_json: statusSnapshot } : {}),
257
609
  result: text,
258
610
  });
259
611
  }