@researai/deepscientist 1.5.12 → 1.5.14

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 (99) hide show
  1. package/bin/ds.js +20 -3
  2. package/docs/en/00_QUICK_START.md +24 -5
  3. package/docs/en/01_SETTINGS_REFERENCE.md +4 -0
  4. package/docs/en/05_TUI_GUIDE.md +466 -96
  5. package/docs/en/09_DOCTOR.md +24 -5
  6. package/docs/en/15_CODEX_PROVIDER_SETUP.md +113 -15
  7. package/docs/en/README.md +2 -0
  8. package/docs/zh/00_QUICK_START.md +24 -5
  9. package/docs/zh/01_SETTINGS_REFERENCE.md +4 -0
  10. package/docs/zh/05_TUI_GUIDE.md +465 -82
  11. package/docs/zh/09_DOCTOR.md +24 -5
  12. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +113 -15
  13. package/docs/zh/README.md +2 -0
  14. package/package.json +2 -1
  15. package/pyproject.toml +1 -1
  16. package/src/deepscientist/__init__.py +1 -1
  17. package/src/deepscientist/artifact/service.py +125 -2
  18. package/src/deepscientist/cli.py +3 -0
  19. package/src/deepscientist/codex_cli_compat.py +117 -0
  20. package/src/deepscientist/config/service.py +53 -6
  21. package/src/deepscientist/connector/lingzhu_support.py +23 -4
  22. package/src/deepscientist/daemon/app.py +111 -30
  23. package/src/deepscientist/mcp/server.py +161 -19
  24. package/src/deepscientist/prompts/builder.py +13 -54
  25. package/src/deepscientist/quest/service.py +99 -0
  26. package/src/deepscientist/quest/stage_views.py +134 -29
  27. package/src/deepscientist/runners/codex.py +11 -2
  28. package/src/deepscientist/runners/runtime_overrides.py +3 -0
  29. package/src/deepscientist/shared.py +6 -1
  30. package/src/prompts/system.md +220 -2065
  31. package/src/skills/baseline/SKILL.md +265 -994
  32. package/src/skills/baseline/references/artifact-payload-examples.md +39 -0
  33. package/src/skills/baseline/references/baseline-checklist-template.md +21 -32
  34. package/src/skills/baseline/references/baseline-plan-template.md +41 -57
  35. package/src/tui/dist/app/AppContainer.js +1442 -52
  36. package/src/tui/dist/components/Composer.js +1 -1
  37. package/src/tui/dist/components/ConfigScreen.js +190 -36
  38. package/src/tui/dist/components/GradientStatusText.js +1 -20
  39. package/src/tui/dist/components/InputPrompt.js +41 -32
  40. package/src/tui/dist/components/LoadingIndicator.js +1 -1
  41. package/src/tui/dist/components/Logo.js +61 -38
  42. package/src/tui/dist/components/MainContent.js +10 -3
  43. package/src/tui/dist/components/WelcomePanel.js +4 -12
  44. package/src/tui/dist/components/messages/AssistantMessage.js +1 -1
  45. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -3
  46. package/src/tui/dist/components/messages/OperationMessage.js +1 -1
  47. package/src/tui/dist/index.js +28 -1
  48. package/src/tui/dist/layouts/DefaultAppLayout.js +3 -3
  49. package/src/tui/dist/lib/api.js +17 -0
  50. package/src/tui/dist/lib/connectorConfig.js +90 -0
  51. package/src/tui/dist/lib/connectors.js +261 -0
  52. package/src/tui/dist/lib/qr.js +21 -0
  53. package/src/tui/dist/semantic-colors.js +29 -19
  54. package/src/tui/package.json +2 -1
  55. package/src/ui/dist/assets/{AiManusChatView-CnJcXynW.js → AiManusChatView-DaF9Nge_.js} +12 -12
  56. package/src/ui/dist/assets/{AnalysisPlugin-DeyzPEhV.js → AnalysisPlugin-BSVx6dXE.js} +1 -1
  57. package/src/ui/dist/assets/{CliPlugin-CB1YODQn.js → CliPlugin-C9gzJX41.js} +9 -9
  58. package/src/ui/dist/assets/{CodeEditorPlugin-B-xicq1e.js → CodeEditorPlugin-DU9G0Tox.js} +8 -8
  59. package/src/ui/dist/assets/{CodeViewerPlugin-DT54ysXa.js → CodeViewerPlugin-DoX_fI9l.js} +5 -5
  60. package/src/ui/dist/assets/{DocViewerPlugin-DQtKT-VD.js → DocViewerPlugin-C4FWIXuU.js} +3 -3
  61. package/src/ui/dist/assets/{GitDiffViewerPlugin-hqHbCfnv.js → GitDiffViewerPlugin-BgfFMgtf.js} +20 -20
  62. package/src/ui/dist/assets/{ImageViewerPlugin-OcVo33jV.js → ImageViewerPlugin-tcPkfY_x.js} +5 -5
  63. package/src/ui/dist/assets/{LabCopilotPanel-DdGwhEUV.js → LabCopilotPanel-_dKV60Bf.js} +11 -11
  64. package/src/ui/dist/assets/{LabPlugin-Ciz1gDaX.js → LabPlugin-Bje0ayoC.js} +2 -2
  65. package/src/ui/dist/assets/{LatexPlugin-BhmjNQRC.js → LatexPlugin-CVsBzAln.js} +7 -7
  66. package/src/ui/dist/assets/{MarkdownViewerPlugin-BzdVH9Bx.js → MarkdownViewerPlugin-xjmrqv_8.js} +4 -4
  67. package/src/ui/dist/assets/{MarketplacePlugin-DmyHspXt.js → MarketplacePlugin-mMM2A8wP.js} +3 -3
  68. package/src/ui/dist/assets/{NotebookEditor-BTVYRGkm.js → NotebookEditor-3kVDSOBo.js} +11 -11
  69. package/src/ui/dist/assets/{NotebookEditor-BMXKrDRk.js → NotebookEditor-SoJ8X-MO.js} +1 -1
  70. package/src/ui/dist/assets/{PdfLoader-CvcjJHXv.js → PdfLoader-DElVuHl9.js} +1 -1
  71. package/src/ui/dist/assets/{PdfMarkdownPlugin-DW2ej8Vk.js → PdfMarkdownPlugin-Bq88XT4G.js} +2 -2
  72. package/src/ui/dist/assets/{PdfViewerPlugin-CmlDxbhU.js → PdfViewerPlugin-CsCXMo9S.js} +10 -10
  73. package/src/ui/dist/assets/{SearchPlugin-DAjQZPSv.js → SearchPlugin-oUPvy19k.js} +1 -1
  74. package/src/ui/dist/assets/{TextViewerPlugin-C-nVAZb_.js → TextViewerPlugin-CRkT9yNy.js} +5 -5
  75. package/src/ui/dist/assets/{VNCViewer-D7-dIYon.js → VNCViewer-BgbuvWhR.js} +10 -10
  76. package/src/ui/dist/assets/{bot-C_G4WtNI.js → bot-v_RASACv.js} +1 -1
  77. package/src/ui/dist/assets/{code-Cd7WfiWq.js → code-5hC9d0VH.js} +1 -1
  78. package/src/ui/dist/assets/{file-content-B57zsL9y.js → file-content-D1PxfOrp.js} +1 -1
  79. package/src/ui/dist/assets/{file-diff-panel-DVoheLFq.js → file-diff-panel-DG1oT_Hj.js} +1 -1
  80. package/src/ui/dist/assets/{file-socket-B5kXFxZP.js → file-socket-BmdFYQlk.js} +1 -1
  81. package/src/ui/dist/assets/{image-LLOjkMHF.js → image-Dqe2X2tW.js} +1 -1
  82. package/src/ui/dist/assets/{index-Dxa2eYMY.js → index-DVsMKK_y.js} +1 -1
  83. package/src/ui/dist/assets/{index-C3r2iGrp.js → index-Duvz8Ip0.js} +12 -12
  84. package/src/ui/dist/assets/{index-CLQauncb.js → index-Nt9hS4ck.js} +470 -165
  85. package/src/ui/dist/assets/{index-hOUOWbW2.js → index-RDlNXXx1.js} +2 -2
  86. package/src/ui/dist/assets/{monaco-BGGAEii3.js → monaco-DIXge1CP.js} +1 -1
  87. package/src/ui/dist/assets/{pdf-effect-queue-DlEr1_y5.js → pdf-effect-queue-BBTTQaO-.js} +1 -1
  88. package/src/ui/dist/assets/{popover-CWJbJuYY.js → popover-BWlolyxo.js} +1 -1
  89. package/src/ui/dist/assets/{project-sync-CRJiucYO.js → project-sync-BM5PkFH4.js} +1 -1
  90. package/src/ui/dist/assets/{select-CoHB7pvH.js → select-D4dAtrA8.js} +2 -2
  91. package/src/ui/dist/assets/{sigma-D5aJWR8J.js → sigma-CKbE5jJT.js} +1 -1
  92. package/src/ui/dist/assets/{square-check-big-DUK_mnkS.js → square-check-big-CZNGMgiB.js} +1 -1
  93. package/src/ui/dist/assets/{trash-ChU3SEE3.js → trash-DaB37xAz.js} +1 -1
  94. package/src/ui/dist/assets/{useCliAccess-BrJBV3tY.js → useCliAccess-C2OmAcWe.js} +1 -1
  95. package/src/ui/dist/assets/{useFileDiffOverlay-C2OQaVWc.js → useFileDiffOverlay-Dowd1Ij4.js} +1 -1
  96. package/src/ui/dist/assets/{wrap-text-C7Qqh-om.js → wrap-text-BGjAhAUq.js} +1 -1
  97. package/src/ui/dist/assets/{zoom-out-rtX0FKya.js → zoom-out-dMZQMXzc.js} +1 -1
  98. package/src/ui/dist/index.html +1 -1
  99. package/uv.lock +1 -1
@@ -15,6 +15,9 @@ DEFAULT_LINGZHU_AGENT_ID = "main"
15
15
  DEFAULT_LINGZHU_SESSION_NAMESPACE = "lingzhu"
16
16
  DEFAULT_LINGZHU_TASK_PREFIX = "我现在的任务是"
17
17
  DEFAULT_LINGZHU_PASSIVE_CHAT_TYPE = "passive"
18
+ _LINGZHU_COMMAND_PUNCTUATION = "::,,。.;;!!??、~~`'\"“”‘’()()【】[]《》<>"
19
+ _LINGZHU_COMMAND_PUNCT_TRANSLATION = str.maketrans({char: " " for char in _LINGZHU_COMMAND_PUNCTUATION})
20
+ _LINGZHU_PREFIX_SEPARATORS_CLASS = re.escape(_LINGZHU_COMMAND_PUNCTUATION) + r"\s"
18
21
 
19
22
  _AUTH_AK_SEGMENTS = (8, 4, 4, 4, 12)
20
23
  _AUTH_AK_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
@@ -237,12 +240,28 @@ def lingzhu_extract_user_text(messages: Any) -> str:
237
240
  return "\n".join(parts).strip()
238
241
 
239
242
 
240
- def lingzhu_extract_task_text(text: Any) -> str | None:
243
+ def lingzhu_normalize_command_text(text: Any) -> str:
241
244
  normalized = str(text or "").strip()
242
- if not normalized.startswith(DEFAULT_LINGZHU_TASK_PREFIX):
245
+ if not normalized:
246
+ return ""
247
+ normalized = normalized.translate(_LINGZHU_COMMAND_PUNCT_TRANSLATION)
248
+ normalized = re.sub(r"\s+", " ", normalized)
249
+ return normalized.strip()
250
+
251
+
252
+ def lingzhu_extract_task_text(text: Any) -> str | None:
253
+ raw_text = str(text or "").strip()
254
+ if not raw_text:
255
+ return None
256
+ prefix_pattern = r"^[{separators}]*{prefix}[{separators}]*".format(
257
+ separators=_LINGZHU_PREFIX_SEPARATORS_CLASS,
258
+ prefix="".join(f"{re.escape(char)}[{_LINGZHU_PREFIX_SEPARATORS_CLASS}]*" for char in DEFAULT_LINGZHU_TASK_PREFIX),
259
+ )
260
+ matched = re.match(prefix_pattern, raw_text)
261
+ if matched is None:
243
262
  return None
244
- remainder = normalized[len(DEFAULT_LINGZHU_TASK_PREFIX) :].strip()
245
- remainder = remainder.lstrip("::,,。.;;!!?? ")
263
+ remainder = raw_text[matched.end() :].strip()
264
+ remainder = remainder.lstrip(_LINGZHU_COMMAND_PUNCTUATION + " \t\r\n")
246
265
  return remainder or None
247
266
 
248
267
 
@@ -54,6 +54,7 @@ from ..network import urlopen_with_proxy as urlopen
54
54
  from ..latex_runtime import QuestLatexService
55
55
  from ..connector.lingzhu_support import (
56
56
  lingzhu_detect_tool_call_from_text,
57
+ lingzhu_normalize_command_text,
57
58
  lingzhu_extract_task_text,
58
59
  lingzhu_extract_user_text,
59
60
  lingzhu_health_payload,
@@ -102,6 +103,25 @@ LEGACY_CODEX_RETRY_BACKOFF_MULTIPLIER = 2.0
102
103
  LEGACY_CODEX_RETRY_MAX_BACKOFF_SEC = 8.0
103
104
  _CRASH_AUTO_RESUME_COOLDOWN = timedelta(minutes=10)
104
105
  _CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS = 2
106
+ _CHINESE_DIGIT_MAP = {
107
+ "零": 0,
108
+ "〇": 0,
109
+ "一": 1,
110
+ "二": 2,
111
+ "两": 2,
112
+ "三": 3,
113
+ "四": 4,
114
+ "五": 5,
115
+ "六": 6,
116
+ "七": 7,
117
+ "八": 8,
118
+ "九": 9,
119
+ }
120
+ _CHINESE_UNIT_MAP = {
121
+ "十": 10,
122
+ "百": 100,
123
+ "千": 1000,
124
+ }
105
125
  _LINGZHU_SHORT_COMMAND_DIRECT_MAP = {
106
126
  "帮助": "help",
107
127
  "列表": "list",
@@ -117,7 +137,7 @@ _LINGZHU_SHORT_COMMAND_PREFIX_MAP = {
117
137
  "暂停": "stop",
118
138
  "恢复": "resume",
119
139
  }
120
- _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新"}
140
+ _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新", "最新的"}
121
141
  _LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
122
142
 
123
143
 
@@ -3570,10 +3590,8 @@ class DaemonApp:
3570
3590
  ),
3571
3591
  }
3572
3592
  )
3573
- target_quest = args[0]
3574
- if target_quest in {"latest", "newest"}:
3575
- quests = self.quest_service.list_quests()
3576
- target_quest = str(quests[0]["quest_id"]) if quests else ""
3593
+ requested_quest_ref = str(args[0] or "").strip()
3594
+ target_quest = self._resolve_quest_reference(requested_quest_ref) or requested_quest_ref
3577
3595
  if not (self.home / "quests" / target_quest / "quest.yaml").exists():
3578
3596
  available = ", ".join(item["quest_id"] for item in self.quest_service.list_quests()[:6]) or "none"
3579
3597
  return channel.send(
@@ -3581,8 +3599,8 @@ class DaemonApp:
3581
3599
  "conversation_id": conversation_id,
3582
3600
  "kind": "ack",
3583
3601
  "message": self._polite_copy(
3584
- zh=f"老师,目前没有找到 quest `{target_quest}`。可用 quest 包括:{available}。",
3585
- en=f"I could not find quest `{target_quest}`. Available quests: {available}.",
3602
+ zh=f"老师,目前没有找到 quest `{requested_quest_ref}`。可用 quest 包括:{available}。",
3603
+ en=f"I could not find quest `{requested_quest_ref}`. Available quests: {available}.",
3586
3604
  ),
3587
3605
  }
3588
3606
  )
@@ -3626,10 +3644,8 @@ class DaemonApp:
3626
3644
  ),
3627
3645
  }
3628
3646
  )
3629
- target_quest = str(args[0] or "").strip()
3630
- if target_quest in {"latest", "newest"}:
3631
- quests = self.quest_service.list_quests()
3632
- target_quest = str(quests[0]["quest_id"]) if quests else ""
3647
+ requested_quest_ref = str(args[0] or "").strip()
3648
+ target_quest = self._resolve_quest_reference(requested_quest_ref) or requested_quest_ref
3633
3649
  confirmed = any(str(item).strip().lower() in {"--yes", "--force", "-y"} for item in args[1:])
3634
3650
  if not confirmed:
3635
3651
  return channel.send(
@@ -3660,8 +3676,8 @@ class DaemonApp:
3660
3676
  "conversation_id": conversation_id,
3661
3677
  "kind": "ack",
3662
3678
  "message": self._polite_copy(
3663
- zh=f"老师,目前没有找到 quest `{target_quest}`。可用 quest 包括:{available}。",
3664
- en=f"I could not find quest `{target_quest}`. Available quests: {available}.",
3679
+ zh=f"老师,目前没有找到 quest `{requested_quest_ref}`。可用 quest 包括:{available}。",
3680
+ en=f"I could not find quest `{requested_quest_ref}`. Available quests: {available}.",
3665
3681
  ),
3666
3682
  }
3667
3683
  )
@@ -4345,6 +4361,53 @@ class DaemonApp:
4345
4361
  quest_id = str(quests[0].get("quest_id") or "").strip()
4346
4362
  return quest_id or None
4347
4363
 
4364
+ @staticmethod
4365
+ def _strip_quest_reference_noise(value: str | None) -> str:
4366
+ normalized = lingzhu_normalize_command_text(value)
4367
+ if not normalized:
4368
+ return ""
4369
+ normalized = re.sub(r"^(?:第|quest|Quest|任务|项目)\s*", "", normalized).strip()
4370
+ normalized = re.sub(r"\s*(?:号|个|个任务|任务)\s*$", "", normalized).strip()
4371
+ return normalized
4372
+
4373
+ @staticmethod
4374
+ def _parse_chinese_numeric_reference(value: str) -> str | None:
4375
+ normalized = str(value or "").strip()
4376
+ if not normalized:
4377
+ return None
4378
+ if all(char in _CHINESE_DIGIT_MAP for char in normalized):
4379
+ return "".join(str(_CHINESE_DIGIT_MAP[char]) for char in normalized)
4380
+ if not all(char in _CHINESE_DIGIT_MAP or char in _CHINESE_UNIT_MAP for char in normalized):
4381
+ return None
4382
+ total = 0
4383
+ current = 0
4384
+ for char in normalized:
4385
+ if char in _CHINESE_DIGIT_MAP:
4386
+ current = _CHINESE_DIGIT_MAP[char]
4387
+ continue
4388
+ unit = _CHINESE_UNIT_MAP.get(char)
4389
+ if unit is None:
4390
+ return None
4391
+ total += (current or 1) * unit
4392
+ current = 0
4393
+ total += current
4394
+ return str(total) if total > 0 else None
4395
+
4396
+ def _resolve_numeric_quest_id(self, value: str | None) -> str | None:
4397
+ normalized = str(value or "").strip()
4398
+ if not normalized or not normalized.isdigit():
4399
+ return None
4400
+ if self._quest_exists(normalized):
4401
+ return normalized
4402
+ target_numeric = int(normalized)
4403
+ for item in self.quest_service.list_quests():
4404
+ quest_id = str(item.get("quest_id") or "").strip()
4405
+ if not quest_id.isdigit():
4406
+ continue
4407
+ if int(quest_id) == target_numeric:
4408
+ return quest_id
4409
+ return None
4410
+
4348
4411
  def _quest_exists(self, quest_id: str | None) -> bool:
4349
4412
  normalized = str(quest_id or "").strip()
4350
4413
  if not normalized:
@@ -4352,11 +4415,22 @@ class DaemonApp:
4352
4415
  return (self.home / "quests" / normalized / "quest.yaml").exists()
4353
4416
 
4354
4417
  def _resolve_quest_reference(self, value: str | None) -> str | None:
4355
- normalized = str(value or "").strip()
4418
+ normalized = self._strip_quest_reference_noise(value)
4356
4419
  if not normalized:
4357
4420
  return None
4358
4421
  if normalized in {"latest", "newest"}:
4359
4422
  return self._latest_quest_id()
4423
+ literal_match = normalized if self._quest_exists(normalized) else None
4424
+ if literal_match:
4425
+ return literal_match
4426
+ numeric_match = self._resolve_numeric_quest_id(normalized)
4427
+ if numeric_match:
4428
+ return numeric_match
4429
+ chinese_numeric = self._parse_chinese_numeric_reference(normalized)
4430
+ if chinese_numeric:
4431
+ numeric_match = self._resolve_numeric_quest_id(chinese_numeric)
4432
+ if numeric_match:
4433
+ return numeric_match
4360
4434
  return normalized
4361
4435
 
4362
4436
  def _connector_control_reply(
@@ -4795,7 +4869,7 @@ class DaemonApp:
4795
4869
 
4796
4870
  @staticmethod
4797
4871
  def _parse_lingzhu_short_command(text: str) -> tuple[str, list[str]] | None:
4798
- normalized = re.sub(r"\s+", " ", str(text or "").strip())
4872
+ normalized = lingzhu_normalize_command_text(text)
4799
4873
  if not normalized or normalized.startswith("/"):
4800
4874
  return None
4801
4875
  direct = _LINGZHU_SHORT_COMMAND_DIRECT_MAP.get(normalized)
@@ -4822,6 +4896,27 @@ class DaemonApp:
4822
4896
  return command_name, []
4823
4897
  return None
4824
4898
 
4899
+ def _lingzhu_status_hint_text(self, quest_id: str | None) -> str:
4900
+ if not quest_id:
4901
+ latest = str(self._latest_quest_id() or "").strip()
4902
+ bind_hint = f"绑定{latest}" if latest else "绑定最新"
4903
+ return f"未绑定。可说:{bind_hint}/列表/帮助"
4904
+ snapshot = self.quest_service.snapshot(quest_id)
4905
+ runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
4906
+ latest = str(self._latest_quest_id() or "").strip()
4907
+ bind_hint = f"绑定{latest}" if latest and latest != quest_id else "绑定最新"
4908
+ if runtime_status in {"running", "active"}:
4909
+ return f"绑{quest_id},进行中。可说:状态/总结/{bind_hint}"
4910
+ if runtime_status == "waiting_for_user":
4911
+ return f"绑{quest_id},等你确认。可说:状态/总结/{bind_hint}"
4912
+ if runtime_status in {"paused", "stopped"}:
4913
+ return f"绑{quest_id},已暂停。可说:恢复/{bind_hint}/列表"
4914
+ if runtime_status == "completed":
4915
+ return f"绑{quest_id},已完成。可说:总结/{bind_hint}/列表"
4916
+ if runtime_status == "error":
4917
+ return f"绑{quest_id},出错了。可说:状态/恢复/{bind_hint}"
4918
+ return f"绑{quest_id},暂无新进展。可说:状态/总结/{bind_hint}"
4919
+
4825
4920
  def _lingzhu_unbound_help_text(self) -> str:
4826
4921
  latest = str(self._latest_quest_id() or "none")
4827
4922
  return (
@@ -5354,21 +5449,7 @@ class DaemonApp:
5354
5449
  return emitted
5355
5450
 
5356
5451
  def _lingzhu_short_status_text(self, quest_id: str | None) -> str:
5357
- if not quest_id:
5358
- return self._lingzhu_unbound_help_text()
5359
- snapshot = self.quest_service.snapshot(quest_id)
5360
- runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
5361
- if runtime_status in {"running", "active"}:
5362
- return "进行中"
5363
- if runtime_status == "waiting_for_user":
5364
- return "等你确认"
5365
- if runtime_status in {"paused", "stopped"}:
5366
- return "已暂停"
5367
- if runtime_status == "completed":
5368
- return "已完成"
5369
- if runtime_status == "error":
5370
- return "出错了"
5371
- return "暂无新进展"
5452
+ return self._lingzhu_status_hint_text(quest_id)
5372
5453
 
5373
5454
  @staticmethod
5374
5455
  def _lingzhu_reply_payload(result: dict[str, Any]) -> tuple[str, str | None, str]:
@@ -11,6 +11,7 @@ from ..artifact import ArtifactService
11
11
  from ..artifact.metrics import MetricContractValidationError
12
12
  from ..bash_exec import BashExecService
13
13
  from ..memory import MemoryService
14
+ from ..quest import QuestService
14
15
  from .context import McpContext
15
16
 
16
17
  DEFAULT_INLINE_BASH_LOG_LINE_LIMIT = 2000
@@ -18,16 +19,123 @@ DEFAULT_INLINE_BASH_LOG_HEAD_LINES = 500
18
19
  DEFAULT_INLINE_BASH_LOG_TAIL_LINES = 1500
19
20
  DEFAULT_INLINE_BASH_LOG_WINDOW_LINES = 200
20
21
  MAX_INLINE_BASH_LOG_WINDOW_LINES = 2000
22
+ INTERACTION_WATCHDOG_TOOL_CALL_THRESHOLD = 25
23
+ INTERACTION_WATCHDOG_SILENCE_THRESHOLD_SECONDS = 30 * 60
21
24
  LONG_BASH_LOG_HINT = (
22
25
  "Use `bash_exec(mode='read', id=..., start=..., tail=...)` to inspect a specific log window, "
23
26
  "or `bash_exec(mode='read', id=..., tail=...)` to inspect the latest rendered lines."
24
27
  )
28
+ ARTIFACT_STATE_CHANGE_WATCHDOG_NOTES = {
29
+ "confirm_baseline": (
30
+ "Baseline confirmation changed durable quest state and this tool does not send a user-visible "
31
+ "summary on its own. Send one concise artifact.interact(...) update now."
32
+ ),
33
+ "waive_baseline": (
34
+ "Baseline waiver changed durable quest state and this tool does not send a user-visible summary "
35
+ "on its own. Send one concise artifact.interact(...) update now."
36
+ ),
37
+ "submit_paper_outline": (
38
+ "Paper outline state changed durably and this tool does not send a user-visible summary on its own. "
39
+ "Send one concise artifact.interact(...) update now."
40
+ ),
41
+ "publish_baseline": (
42
+ "Baseline publication changed durable state and this tool does not send a user-visible summary "
43
+ "on its own. Send one concise artifact.interact(...) update now."
44
+ ),
45
+ "attach_baseline": (
46
+ "Baseline attachment changed durable quest state and this tool does not send a user-visible summary "
47
+ "on its own. Send one concise artifact.interact(...) update now."
48
+ ),
49
+ "complete_quest": (
50
+ "Quest completion changed durable state and this tool does not send a final user-visible summary "
51
+ "on its own. Send one concise artifact.interact(...) closing update now unless the user already "
52
+ "received an equivalent completion summary."
53
+ ),
54
+ }
25
55
 
26
56
 
27
57
  def _metric_validation_error_payload(exc: MetricContractValidationError) -> dict[str, Any]:
28
58
  return exc.as_payload()
29
59
 
30
60
 
61
+ def _progress_watchdog_note(tool_call_count: int) -> str:
62
+ return (
63
+ "By the way, you have gone "
64
+ f"{tool_call_count} tool calls without notifying the user via artifact.interact(...). "
65
+ "Please report your latest progress now."
66
+ )
67
+
68
+
69
+ def _visibility_watchdog_note(seconds_since_last_update: int) -> str:
70
+ minutes = max(1, seconds_since_last_update // 60)
71
+ return (
72
+ "By the way, it has been "
73
+ f"{minutes} minutes since the last user-visible artifact.interact(...). "
74
+ "Send one concise progress update now before continuing with background work."
75
+ )
76
+
77
+
78
+ def _collect_interaction_watchdog_notes(
79
+ watchdog: dict[str, Any],
80
+ *,
81
+ state_change_note: str | None = None,
82
+ ) -> list[dict[str, str]]:
83
+ notes: list[dict[str, str]] = []
84
+ count = int((watchdog or {}).get("tool_calls_since_last_artifact_interact") or 0)
85
+ if count >= INTERACTION_WATCHDOG_TOOL_CALL_THRESHOLD:
86
+ notes.append(
87
+ {
88
+ "kind": "progress",
89
+ "message": _progress_watchdog_note(count),
90
+ }
91
+ )
92
+ silence_seconds = int((watchdog or {}).get("seconds_since_last_artifact_interact") or 0)
93
+ if count > 0 and silence_seconds >= INTERACTION_WATCHDOG_SILENCE_THRESHOLD_SECONDS:
94
+ notes.append(
95
+ {
96
+ "kind": "visibility",
97
+ "message": _visibility_watchdog_note(silence_seconds),
98
+ }
99
+ )
100
+ if state_change_note:
101
+ notes.append(
102
+ {
103
+ "kind": "state_change",
104
+ "message": state_change_note,
105
+ }
106
+ )
107
+ return notes
108
+
109
+
110
+ def _attach_interaction_watchdog(
111
+ payload: dict[str, Any],
112
+ watchdog: dict[str, Any],
113
+ *,
114
+ state_change_note: str | None = None,
115
+ ) -> dict[str, Any]:
116
+ enriched = dict(payload)
117
+ enriched["interaction_watchdog"] = dict(watchdog or {})
118
+ notes = _collect_interaction_watchdog_notes(
119
+ watchdog,
120
+ state_change_note=state_change_note,
121
+ )
122
+ if not notes:
123
+ return enriched
124
+ enriched["watchdog_notes"] = notes
125
+ for item in notes:
126
+ kind = str(item.get("kind") or "").strip()
127
+ message = str(item.get("message") or "").strip()
128
+ if not message:
129
+ continue
130
+ if kind == "progress":
131
+ enriched["progress_watchdog_note"] = message
132
+ elif kind == "visibility":
133
+ enriched["visibility_watchdog_note"] = message
134
+ elif kind == "state_change":
135
+ enriched["state_change_watchdog_note"] = message
136
+ return enriched
137
+
138
+
31
139
  def _split_bash_log_lines(log_text: str) -> list[str]:
32
140
  return log_text.splitlines()
33
141
 
@@ -341,6 +449,7 @@ def build_memory_server(context: McpContext) -> FastMCP:
341
449
 
342
450
  def build_artifact_server(context: McpContext) -> FastMCP:
343
451
  service = ArtifactService(context.home)
452
+ quest_service = service.quest_service
344
453
  server = FastMCP(
345
454
  "artifact",
346
455
  instructions=(
@@ -351,6 +460,19 @@ def build_artifact_server(context: McpContext) -> FastMCP:
351
460
  log_level="ERROR",
352
461
  )
353
462
 
463
+ def finalize_state_changing_artifact_tool(payload: dict[str, Any], *, tool_name: str) -> dict[str, Any]:
464
+ quest_root = context.require_quest_root().resolve()
465
+ quest_service.record_tool_activity(
466
+ quest_root,
467
+ tool_name=f"artifact.{tool_name}",
468
+ )
469
+ watchdog = quest_service.artifact_interaction_watchdog_status(quest_root)
470
+ return _attach_interaction_watchdog(
471
+ payload,
472
+ watchdog,
473
+ state_change_note=ARTIFACT_STATE_CHANGE_WATCHDOG_NOTES.get(tool_name),
474
+ )
475
+
354
476
  @server.tool(name="record", description="Write a structured artifact record under the current quest.")
355
477
  def record(payload: dict[str, Any], comment: str | dict[str, Any] | None = None) -> dict[str, Any]:
356
478
  enriched = dict(payload)
@@ -620,7 +742,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
620
742
  selected_reason: str | None = None,
621
743
  comment: str | dict[str, Any] | None = None,
622
744
  ) -> dict[str, Any]:
623
- return service.submit_paper_outline(
745
+ result = service.submit_paper_outline(
624
746
  context.require_quest_root(),
625
747
  mode=mode,
626
748
  outline_id=outline_id,
@@ -632,6 +754,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
632
754
  review_result=review_result,
633
755
  selected_reason=selected_reason,
634
756
  )
757
+ return finalize_state_changing_artifact_tool(result, tool_name="submit_paper_outline")
635
758
 
636
759
  @server.tool(
637
760
  name="list_paper_outlines",
@@ -733,7 +856,8 @@ def build_artifact_server(context: McpContext) -> FastMCP:
733
856
  if comment is not None and "comment" not in enriched:
734
857
  enriched["comment"] = comment
735
858
  enriched.setdefault("source", {"kind": "artifact_publish", "quest_id": context.quest_id, "quest_root": str(context.require_quest_root())})
736
- return service.publish_baseline(context.require_quest_root(), enriched)
859
+ result = service.publish_baseline(context.require_quest_root(), enriched)
860
+ return finalize_state_changing_artifact_tool(result, tool_name="publish_baseline")
737
861
 
738
862
  @server.tool(name="attach_baseline", description="Attach a published baseline to the current quest.")
739
863
  def attach_baseline(
@@ -741,7 +865,8 @@ def build_artifact_server(context: McpContext) -> FastMCP:
741
865
  variant_id: str | None = None,
742
866
  comment: str | dict[str, Any] | None = None,
743
867
  ) -> dict[str, Any]:
744
- return service.attach_baseline(context.require_quest_root(), baseline_id, variant_id)
868
+ result = service.attach_baseline(context.require_quest_root(), baseline_id, variant_id)
869
+ return finalize_state_changing_artifact_tool(result, tool_name="attach_baseline")
745
870
 
746
871
  @server.tool(
747
872
  name="confirm_baseline",
@@ -764,7 +889,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
764
889
  comment: str | dict[str, Any] | None = None,
765
890
  ) -> dict[str, Any]:
766
891
  try:
767
- return service.confirm_baseline(
892
+ result = service.confirm_baseline(
768
893
  context.require_quest_root(),
769
894
  baseline_path=baseline_path,
770
895
  comment=comment,
@@ -779,6 +904,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
779
904
  auto_advance=auto_advance,
780
905
  strict_metric_contract=True,
781
906
  )
907
+ return finalize_state_changing_artifact_tool(result, tool_name="confirm_baseline")
782
908
  except MetricContractValidationError as exc:
783
909
  return _metric_validation_error_payload(exc)
784
910
 
@@ -791,12 +917,13 @@ def build_artifact_server(context: McpContext) -> FastMCP:
791
917
  auto_advance: bool = True,
792
918
  comment: str | dict[str, Any] | None = None,
793
919
  ) -> dict[str, Any]:
794
- return service.waive_baseline(
920
+ result = service.waive_baseline(
795
921
  context.require_quest_root(),
796
922
  reason=reason,
797
923
  comment=comment,
798
924
  auto_advance=auto_advance,
799
925
  )
926
+ return finalize_state_changing_artifact_tool(result, tool_name="waive_baseline")
800
927
 
801
928
  @server.tool(
802
929
  name="arxiv",
@@ -852,7 +979,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
852
979
  supersede_open_requests: bool = True,
853
980
  comment: str | dict[str, Any] | None = None,
854
981
  ) -> dict[str, Any]:
855
- return service.interact(
982
+ result = service.interact(
856
983
  context.require_quest_root(),
857
984
  kind=kind,
858
985
  message=message,
@@ -873,6 +1000,8 @@ def build_artifact_server(context: McpContext) -> FastMCP:
873
1000
  reply_to_interaction_id=reply_to_interaction_id,
874
1001
  supersede_open_requests=supersede_open_requests,
875
1002
  )
1003
+ result["interaction_watchdog"] = quest_service.artifact_interaction_watchdog_status(context.require_quest_root())
1004
+ return result
876
1005
 
877
1006
  @server.tool(
878
1007
  name="complete_quest",
@@ -885,16 +1014,20 @@ def build_artifact_server(context: McpContext) -> FastMCP:
885
1014
  summary: str = "",
886
1015
  comment: str | dict[str, Any] | None = None,
887
1016
  ) -> dict[str, Any]:
888
- return service.complete_quest(
1017
+ result = service.complete_quest(
889
1018
  context.require_quest_root(),
890
1019
  summary=summary,
891
1020
  )
1021
+ if result.get("ok") is True and str(result.get("status") or "").strip() == "completed":
1022
+ return finalize_state_changing_artifact_tool(result, tool_name="complete_quest")
1023
+ return result
892
1024
 
893
1025
  return server
894
1026
 
895
1027
 
896
1028
  def build_bash_exec_server(context: McpContext) -> FastMCP:
897
1029
  service = BashExecService(context.home)
1030
+ quest_service = QuestService(context.home)
898
1031
  server = FastMCP(
899
1032
  "bash_exec",
900
1033
  instructions=(
@@ -944,6 +1077,15 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
944
1077
  comment: str | dict[str, Any] | None = None,
945
1078
  ) -> dict[str, Any]:
946
1079
  quest_root = context.require_quest_root().resolve()
1080
+
1081
+ def finalize(payload: dict[str, Any]) -> dict[str, Any]:
1082
+ quest_service.record_tool_activity(
1083
+ quest_root,
1084
+ tool_name=f"bash_exec.{normalized_mode}",
1085
+ )
1086
+ watchdog = quest_service.artifact_interaction_watchdog_status(quest_root)
1087
+ return _attach_interaction_watchdog(payload, watchdog)
1088
+
947
1089
  normalized_mode = (mode or "detach").strip().lower()
948
1090
  if normalized_mode == "create":
949
1091
  normalized_mode = "await"
@@ -973,12 +1115,12 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
973
1115
  "history_lines": history_lines,
974
1116
  }
975
1117
  if normalized_mode == "history":
976
- return {
1118
+ return finalize({
977
1119
  "count": len(items),
978
1120
  "lines": history_lines,
979
1121
  "items": items,
980
- }
981
- return payload
1122
+ })
1123
+ return finalize(payload)
982
1124
  if normalized_mode == "read":
983
1125
  bash_id = service.resolve_session_id(quest_root, id)
984
1126
  session = service.get_session(quest_root, bash_id)
@@ -1007,7 +1149,7 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
1007
1149
  tail=tail if tail is not None else tail_limit,
1008
1150
  )
1009
1151
  )
1010
- return payload
1152
+ return finalize(payload)
1011
1153
  use_tail = tail_limit is not None or before_seq is not None or after_seq is not None or normalized_order != "asc"
1012
1154
  if use_tail:
1013
1155
  resolved_tail_limit = max(1, min(int(tail_limit or 200), 1000))
@@ -1034,7 +1176,7 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
1034
1176
  payload["after_seq"] = tail_meta.get("after_seq")
1035
1177
  payload["before_seq"] = tail_meta.get("before_seq")
1036
1178
  payload["order"] = normalized_order
1037
- return payload
1179
+ return finalize(payload)
1038
1180
  payload = service.build_tool_result(
1039
1181
  context,
1040
1182
  session=session,
@@ -1043,7 +1185,7 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
1043
1185
  export_log_to=export_log_to,
1044
1186
  )
1045
1187
  payload.update(_build_default_bash_log_payload_from_path(service.terminal_log_path(quest_root, bash_id)))
1046
- return payload
1188
+ return finalize(payload)
1047
1189
  if normalized_mode == "kill":
1048
1190
  bash_id = service.resolve_session_id(quest_root, id)
1049
1191
  session = service.request_stop(
@@ -1055,17 +1197,17 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
1055
1197
  )
1056
1198
  if wait:
1057
1199
  session = service.wait_for_session(quest_root, bash_id, timeout_seconds=timeout_seconds)
1058
- return service.build_tool_result(context, session=session, include_log=False)
1200
+ return finalize(service.build_tool_result(context, session=session, include_log=False))
1059
1201
  if normalized_mode == "await" and not command:
1060
1202
  bash_id = service.resolve_session_id(quest_root, id)
1061
1203
  session = service.wait_for_session(quest_root, bash_id, timeout_seconds=timeout_seconds)
1062
- return service.build_tool_result(
1204
+ return finalize(service.build_tool_result(
1063
1205
  context,
1064
1206
  session=session,
1065
1207
  include_log=False,
1066
1208
  export_log=export_log,
1067
1209
  export_log_to=export_log_to,
1068
- )
1210
+ ))
1069
1211
  if not (command or "").strip():
1070
1212
  raise ValueError("command is required for `detach` and `await`.")
1071
1213
  session = service.start_session(
@@ -1078,15 +1220,15 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
1078
1220
  comment=comment,
1079
1221
  )
1080
1222
  if normalized_mode == "detach":
1081
- return service.build_tool_result(context, session=session, include_log=False)
1223
+ return finalize(service.build_tool_result(context, session=session, include_log=False))
1082
1224
  session = service.wait_for_session(quest_root, str(session["bash_id"]), timeout_seconds=timeout_seconds)
1083
- return service.build_tool_result(
1225
+ return finalize(service.build_tool_result(
1084
1226
  context,
1085
1227
  session=session,
1086
1228
  include_log=False,
1087
1229
  export_log=export_log,
1088
1230
  export_log_to=export_log_to,
1089
- )
1231
+ ))
1090
1232
 
1091
1233
  return server
1092
1234