@researai/deepscientist 1.5.8 → 1.5.11

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 (148) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +108 -95
  3. package/assets/branding/connector-qq.png +0 -0
  4. package/assets/branding/connector-rokid.png +0 -0
  5. package/assets/branding/connector-weixin.png +0 -0
  6. package/assets/branding/projects.png +0 -0
  7. package/bin/ds.js +172 -13
  8. package/docs/assets/branding/projects.png +0 -0
  9. package/docs/en/00_QUICK_START.md +308 -70
  10. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  11. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  12. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  13. package/docs/en/09_DOCTOR.md +41 -5
  14. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  15. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  16. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  17. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  18. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +79 -0
  21. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  23. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  24. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  25. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  26. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  27. package/docs/zh/00_QUICK_START.md +315 -74
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +41 -5
  32. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  33. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  34. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  35. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  36. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  37. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  38. package/docs/zh/README.md +126 -0
  39. package/install.sh +0 -34
  40. package/package.json +3 -3
  41. package/pyproject.toml +2 -2
  42. package/src/deepscientist/__init__.py +1 -1
  43. package/src/deepscientist/annotations.py +343 -0
  44. package/src/deepscientist/artifact/arxiv.py +484 -37
  45. package/src/deepscientist/artifact/metrics.py +1 -3
  46. package/src/deepscientist/artifact/service.py +1347 -111
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/service.py +9 -0
  49. package/src/deepscientist/bridges/builtins.py +2 -0
  50. package/src/deepscientist/bridges/connectors.py +447 -0
  51. package/src/deepscientist/channels/__init__.py +2 -0
  52. package/src/deepscientist/channels/builtins.py +3 -1
  53. package/src/deepscientist/channels/qq.py +1 -1
  54. package/src/deepscientist/channels/qq_gateway.py +1 -1
  55. package/src/deepscientist/channels/relay.py +7 -1
  56. package/src/deepscientist/channels/weixin.py +59 -0
  57. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  58. package/src/deepscientist/config/models.py +22 -2
  59. package/src/deepscientist/config/service.py +431 -60
  60. package/src/deepscientist/connector/__init__.py +4 -0
  61. package/src/deepscientist/connector/connector_profiles.py +481 -0
  62. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  63. package/src/deepscientist/connector/qq_profiles.py +206 -0
  64. package/src/deepscientist/connector/weixin_support.py +663 -0
  65. package/src/deepscientist/connector_profiles.py +1 -374
  66. package/src/deepscientist/connector_runtime.py +2 -0
  67. package/src/deepscientist/daemon/api/handlers.py +295 -5
  68. package/src/deepscientist/daemon/api/router.py +16 -1
  69. package/src/deepscientist/daemon/app.py +1130 -61
  70. package/src/deepscientist/doctor.py +5 -2
  71. package/src/deepscientist/gitops/diff.py +120 -29
  72. package/src/deepscientist/lingzhu_support.py +1 -182
  73. package/src/deepscientist/mcp/server.py +14 -5
  74. package/src/deepscientist/prompts/builder.py +29 -1
  75. package/src/deepscientist/qq_profiles.py +1 -196
  76. package/src/deepscientist/quest/node_traces.py +152 -2
  77. package/src/deepscientist/quest/service.py +169 -43
  78. package/src/deepscientist/quest/stage_views.py +172 -9
  79. package/src/deepscientist/registries/baseline.py +56 -4
  80. package/src/deepscientist/runners/codex.py +55 -3
  81. package/src/deepscientist/weixin_support.py +1 -0
  82. package/src/prompts/connectors/lingzhu.md +3 -1
  83. package/src/prompts/connectors/weixin.md +230 -0
  84. package/src/prompts/system.md +9 -0
  85. package/src/skills/idea/SKILL.md +16 -0
  86. package/src/skills/idea/references/literature-survey-template.md +24 -0
  87. package/src/skills/idea/references/related-work-playbook.md +4 -0
  88. package/src/skills/idea/references/selection-gate.md +9 -0
  89. package/src/skills/write/SKILL.md +1 -1
  90. package/src/tui/package.json +1 -1
  91. package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  92. package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  93. package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
  94. package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  95. package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  96. package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  97. package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  98. package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  99. package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
  100. package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
  101. package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
  102. package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  103. package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
  104. package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
  105. package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  106. package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
  107. package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  108. package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  109. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  110. package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
  111. package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  112. package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
  113. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  114. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  115. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  116. package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
  117. package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
  118. package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
  119. package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
  120. package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
  121. package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
  122. package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
  123. package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
  124. package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
  125. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  126. package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
  127. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  128. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  129. package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
  130. package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
  131. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  132. package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
  133. package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
  134. package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
  135. package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
  136. package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  137. package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
  138. package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
  139. package/src/ui/dist/index.html +2 -2
  140. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  141. package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
  142. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  143. package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
  144. package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
  145. package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
  146. package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
  147. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  148. package/src/ui/dist/assets/tooltip-B1OspAkx.js +0 -108
@@ -19,9 +19,10 @@ except ImportError: # pragma: no cover
19
19
 
20
20
  from ..artifact.metrics import build_metrics_timeline, extract_latest_metric
21
21
  from ..config import ConfigManager
22
- from ..connector_runtime import conversation_identity_key, normalize_conversation_id
22
+ from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
23
23
  from ..gitops import current_branch, export_git_graph, head_commit, init_repo
24
24
  from ..home import repo_root
25
+ from ..registries import BaselineRegistry
25
26
  from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
26
27
  from ..skills import SkillInstaller
27
28
  from ..web_search import extract_web_search_payload
@@ -48,6 +49,7 @@ class QuestService:
48
49
  self.home = home
49
50
  self.quests_root = home / "quests"
50
51
  self.skill_installer = skill_installer
52
+ self.baseline_registry = BaselineRegistry(home)
51
53
  self._file_cache_lock = threading.Lock()
52
54
  self._file_cache: dict[str, dict[str, Any]] = {}
53
55
  self._jsonl_cache_lock = threading.Lock()
@@ -62,6 +64,35 @@ class QuestService:
62
64
  def _quest_root(self, quest_id: str) -> Path:
63
65
  return self.quests_root / quest_id
64
66
 
67
+ def _normalized_binding_sources(self, sources: list[Any] | None) -> list[str]:
68
+ local_present = False
69
+ external_source: str | None = None
70
+ for raw in sources or []:
71
+ normalized = self._normalize_binding_source(raw)
72
+ if not normalized:
73
+ continue
74
+ if normalized == "local:default":
75
+ local_present = True
76
+ continue
77
+ parsed = parse_conversation_id(normalized)
78
+ connector = str((parsed or {}).get("connector") or "").strip().lower()
79
+ if connector == "local":
80
+ local_present = True
81
+ continue
82
+ external_source = normalized
83
+ if external_source:
84
+ return ["local:default", external_source]
85
+ if local_present:
86
+ return ["local:default"]
87
+ return ["local:default"]
88
+
89
+ def _binding_sources_payload(self, quest_root: Path) -> dict[str, list[str]]:
90
+ bindings_path = quest_root / ".ds" / "bindings.json"
91
+ payload = read_json(bindings_path, {"sources": ["local:default"]})
92
+ raw_sources = payload.get("sources") if isinstance(payload, dict) else ["local:default"]
93
+ sources = self._normalized_binding_sources(raw_sources if isinstance(raw_sources, list) else ["local:default"])
94
+ return {"sources": sources}
95
+
65
96
  def preferred_locale(self, quest_root: Path | None = None) -> str:
66
97
  if quest_root is not None:
67
98
  try:
@@ -116,6 +147,10 @@ class QuestService:
116
147
  def _research_state_path(quest_root: Path) -> Path:
117
148
  return quest_root / ".ds" / "research_state.json"
118
149
 
150
+ @staticmethod
151
+ def _lab_canvas_state_path(quest_root: Path) -> Path:
152
+ return quest_root / ".ds" / "lab_canvas_state.json"
153
+
119
154
  def _default_research_state(self, quest_root: Path) -> dict[str, Any]:
120
155
  return {
121
156
  "version": 1,
@@ -138,6 +173,18 @@ class QuestService:
138
173
  "updated_at": utc_now(),
139
174
  }
140
175
 
176
+ def _default_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
177
+ return {
178
+ "version": 1,
179
+ "layout_json": {
180
+ "branch": {},
181
+ "event": {},
182
+ "stage": {},
183
+ "preferences": {},
184
+ },
185
+ "updated_at": utc_now(),
186
+ }
187
+
141
188
  def read_research_state(self, quest_root: Path) -> dict[str, Any]:
142
189
  self._initialize_runtime_files(quest_root)
143
190
  defaults = self._default_research_state(quest_root)
@@ -172,6 +219,39 @@ class QuestService:
172
219
  current[key] = str(value) if isinstance(value, Path) else value
173
220
  return self.write_research_state(quest_root, current)
174
221
 
222
+ def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
223
+ self._initialize_runtime_files(quest_root)
224
+ defaults = self._default_lab_canvas_state(quest_root)
225
+ payload = self._read_cached_json(self._lab_canvas_state_path(quest_root), defaults)
226
+ if not isinstance(payload, dict):
227
+ payload = defaults
228
+ merged = {**defaults, **payload}
229
+ layout_json = dict(merged.get("layout_json") or {}) if isinstance(merged.get("layout_json"), dict) else {}
230
+ for key in ("branch", "event", "stage", "preferences"):
231
+ if not isinstance(layout_json.get(key), dict):
232
+ layout_json[key] = {}
233
+ merged["layout_json"] = layout_json
234
+ return merged
235
+
236
+ def update_lab_canvas_state(
237
+ self,
238
+ quest_root: Path,
239
+ *,
240
+ layout_json: dict[str, Any] | None = None,
241
+ ) -> dict[str, Any]:
242
+ current = self.read_lab_canvas_state(quest_root)
243
+ normalized_layout = dict(layout_json or {}) if isinstance(layout_json, dict) else {}
244
+ for key in ("branch", "event", "stage", "preferences"):
245
+ if not isinstance(normalized_layout.get(key), dict):
246
+ normalized_layout[key] = {}
247
+ payload = {
248
+ **current,
249
+ "layout_json": normalized_layout,
250
+ "updated_at": utc_now(),
251
+ }
252
+ write_json(self._lab_canvas_state_path(quest_root), payload)
253
+ return payload
254
+
175
255
  def workspace_roots(self, quest_root: Path) -> list[Path]:
176
256
  roots: list[Path] = [quest_root]
177
257
  state = self.read_research_state(quest_root)
@@ -305,6 +385,9 @@ class QuestService:
305
385
  continue
306
386
  seen_paths.add(key)
307
387
  payload = self._read_cached_yaml(path, {})
388
+ baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
389
+ if baseline_id and self.baseline_registry.is_deleted(baseline_id):
390
+ continue
308
391
  if isinstance(payload, dict) and payload:
309
392
  attachments.append(payload)
310
393
  if not attachments:
@@ -684,7 +767,7 @@ class QuestService:
684
767
  "last_artifact_interact_at": runtime_state.get("last_artifact_interact_at"),
685
768
  "last_delivered_batch_id": runtime_state.get("last_delivered_batch_id"),
686
769
  "last_delivered_at": runtime_state.get("last_delivered_at"),
687
- "bound_conversations": (self._read_cached_json(quest_root / ".ds" / "bindings.json", {}).get("sources") or ["local:default"]),
770
+ "bound_conversations": self._binding_sources_payload(quest_root).get("sources") or ["local:default"],
688
771
  "created_at": quest_yaml.get("created_at"),
689
772
  "updated_at": quest_yaml.get("updated_at"),
690
773
  "branch": research_state.get("current_workspace_branch") or research_state.get("research_head_branch"),
@@ -1039,7 +1122,7 @@ class QuestService:
1039
1122
  "last_artifact_interact_at": runtime_state.get("last_artifact_interact_at"),
1040
1123
  "last_delivered_batch_id": runtime_state.get("last_delivered_batch_id"),
1041
1124
  "last_delivered_at": runtime_state.get("last_delivered_at"),
1042
- "bound_conversations": (self._read_cached_json(quest_root / ".ds" / "bindings.json", {}).get("sources") or ["local:default"]),
1125
+ "bound_conversations": self._binding_sources_payload(quest_root).get("sources") or ["local:default"],
1043
1126
  "created_at": quest_yaml.get("created_at"),
1044
1127
  "updated_at": quest_yaml.get("updated_at"),
1045
1128
  "branch": current_branch(workspace_root),
@@ -1308,61 +1391,30 @@ class QuestService:
1308
1391
  def bind_source(self, quest_id: str, source: str) -> dict:
1309
1392
  quest_root = self._quest_root(quest_id)
1310
1393
  bindings_path = quest_root / ".ds" / "bindings.json"
1311
- bindings = read_json(bindings_path, {"sources": []})
1394
+ bindings = self._binding_sources_payload(quest_root)
1312
1395
  normalized_source = self._normalize_binding_source(source)
1313
- normalized_key = conversation_identity_key(normalized_source)
1314
- changed = False
1315
- replaced = False
1316
- sources: list[str] = []
1317
- for item in list(bindings.get("sources") or []):
1318
- existing = self._normalize_binding_source(str(item))
1319
- if conversation_identity_key(existing) == normalized_key:
1320
- if not replaced:
1321
- sources.append(normalized_source)
1322
- replaced = True
1323
- if existing != normalized_source:
1324
- changed = True
1325
- else:
1326
- changed = True
1327
- continue
1328
- sources.append(existing)
1329
- if existing != item:
1330
- changed = True
1331
- if not replaced:
1332
- sources.append(normalized_source)
1333
- changed = True
1396
+ next_sources = self._normalized_binding_sources([*(bindings.get("sources") or []), normalized_source])
1397
+ changed = list(bindings.get("sources") or []) != next_sources
1334
1398
  if changed:
1335
- bindings["sources"] = sources
1399
+ bindings["sources"] = next_sources
1336
1400
  write_json(bindings_path, bindings)
1337
1401
  return bindings
1338
1402
 
1339
1403
  def binding_sources(self, quest_id: str) -> list[str]:
1340
1404
  quest_root = self._quest_root(quest_id)
1341
- bindings_path = quest_root / ".ds" / "bindings.json"
1342
- bindings = read_json(bindings_path, {"sources": ["local:default"]})
1343
- sources = [self._normalize_binding_source(item) for item in (bindings.get("sources") or [])]
1344
- return [item for item in sources if item]
1405
+ return list(self._binding_sources_payload(quest_root).get("sources") or ["local:default"])
1345
1406
 
1346
1407
  def set_binding_sources(self, quest_id: str, sources: list[str]) -> dict:
1347
1408
  quest_root = self._quest_root(quest_id)
1348
1409
  bindings_path = quest_root / ".ds" / "bindings.json"
1349
- normalized_sources = [self._normalize_binding_source(item) for item in sources]
1350
- ordered: list[str] = []
1351
- seen: set[str] = set()
1352
- for item in normalized_sources:
1353
- key = conversation_identity_key(item)
1354
- if not item or key in seen:
1355
- continue
1356
- seen.add(key)
1357
- ordered.append(item)
1358
- payload = {"sources": ordered}
1410
+ payload = {"sources": self._normalized_binding_sources(sources)}
1359
1411
  write_json(bindings_path, payload)
1360
1412
  return payload
1361
1413
 
1362
1414
  def unbind_source(self, quest_id: str, source: str) -> dict:
1363
1415
  quest_root = self._quest_root(quest_id)
1364
1416
  bindings_path = quest_root / ".ds" / "bindings.json"
1365
- bindings = read_json(bindings_path, {"sources": []})
1417
+ bindings = self._binding_sources_payload(quest_root)
1366
1418
  normalized_source = self._normalize_binding_source(source)
1367
1419
  normalized_key = conversation_identity_key(normalized_source)
1368
1420
  changed = False
@@ -1375,8 +1427,11 @@ class QuestService:
1375
1427
  sources.append(existing)
1376
1428
  if existing != item:
1377
1429
  changed = True
1430
+ normalized_sources = self._normalized_binding_sources(sources)
1431
+ if normalized_sources != list(bindings.get("sources") or []):
1432
+ changed = True
1378
1433
  if changed:
1379
- bindings["sources"] = sources
1434
+ bindings["sources"] = normalized_sources
1380
1435
  write_json(bindings_path, bindings)
1381
1436
  return bindings
1382
1437
 
@@ -1770,6 +1825,12 @@ class QuestService:
1770
1825
  resolved_selection = dict(selection or {})
1771
1826
  selection_ref = str(resolved_selection.get("selection_ref") or "").strip()
1772
1827
  selection_type = str(resolved_selection.get("selection_type") or "stage_node").strip() or None
1828
+ if (
1829
+ selection_type == "branch_node"
1830
+ and selection_ref
1831
+ and not str(resolved_selection.get("branch_name") or "").strip()
1832
+ ):
1833
+ resolved_selection["branch_name"] = selection_ref
1773
1834
  trace = None
1774
1835
  if selection_ref:
1775
1836
  try:
@@ -2026,7 +2087,18 @@ class QuestService:
2026
2087
  if document_id.startswith(("questpath::", "memory::"))
2027
2088
  else workspace_root
2028
2089
  )
2029
- path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
2090
+ try:
2091
+ path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
2092
+ except FileNotFoundError:
2093
+ legacy_relative = None
2094
+ if document_id.startswith("path::"):
2095
+ legacy_relative = document_id.split("::", 1)[1].lstrip("/")
2096
+ if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
2097
+ path, writable, scope, source_kind = self._resolve_document(
2098
+ quest_root, f"questpath::{legacy_relative}"
2099
+ )
2100
+ else:
2101
+ raise
2030
2102
  renderer_hint, mime_type = self._renderer_hint_for(path)
2031
2103
  is_text = self._is_text_document(path, mime_type, renderer_hint)
2032
2104
  content = read_text(path) if is_text else ""
@@ -2417,6 +2489,9 @@ class QuestService:
2417
2489
  research_state_path = self._research_state_path(quest_root)
2418
2490
  if not research_state_path.exists():
2419
2491
  write_json(research_state_path, self._default_research_state(quest_root))
2492
+ lab_canvas_state_path = self._lab_canvas_state_path(quest_root)
2493
+ if not lab_canvas_state_path.exists():
2494
+ write_json(lab_canvas_state_path, self._default_lab_canvas_state(quest_root))
2420
2495
  agent_status_path = self._agent_status_path(quest_root)
2421
2496
  if not agent_status_path.exists():
2422
2497
  write_json(agent_status_path, self._default_agent_status(quest_root))
@@ -3492,7 +3567,35 @@ def _tool_name(event: dict, item: dict) -> str:
3492
3567
  return "tool"
3493
3568
 
3494
3569
 
3570
+ def _structured_text(value: object) -> str:
3571
+ if value is None:
3572
+ return ""
3573
+ if isinstance(value, str):
3574
+ return value.strip()
3575
+ try:
3576
+ return json.dumps(value, ensure_ascii=False, indent=2)
3577
+ except TypeError:
3578
+ return str(value)
3579
+
3580
+
3581
+ def _is_bash_exec_item(event: dict, item: dict) -> bool:
3582
+ server = str(item.get("server") or event.get("server") or "").strip()
3583
+ tool = str(item.get("tool") or event.get("tool") or "").strip()
3584
+ return server == "bash_exec" and tool == "bash_exec"
3585
+
3586
+
3495
3587
  def _tool_args(event: dict, item: dict) -> str:
3588
+ if _is_bash_exec_item(event, item):
3589
+ for value in (
3590
+ item.get("arguments"),
3591
+ event.get("arguments"),
3592
+ item.get("input"),
3593
+ event.get("input"),
3594
+ ):
3595
+ text = _structured_text(value)
3596
+ if text:
3597
+ return text
3598
+ return ""
3496
3599
  for value in (
3497
3600
  item.get("command"),
3498
3601
  item.get("query"),
@@ -3512,6 +3615,21 @@ def _tool_args(event: dict, item: dict) -> str:
3512
3615
 
3513
3616
 
3514
3617
  def _tool_output(event: dict, item: dict) -> str:
3618
+ if _is_bash_exec_item(event, item):
3619
+ for value in (
3620
+ item.get("result"),
3621
+ item.get("output"),
3622
+ item.get("content"),
3623
+ event.get("result"),
3624
+ event.get("output"),
3625
+ event.get("content"),
3626
+ item.get("aggregated_output"),
3627
+ event.get("aggregated_output"),
3628
+ ):
3629
+ text = _structured_text(value)
3630
+ if text:
3631
+ return text
3632
+ return ""
3515
3633
  for value in (
3516
3634
  item.get("aggregated_output"),
3517
3635
  item.get("changes"),
@@ -3554,17 +3672,25 @@ def _mcp_tool_metadata(*, quest_id: str, run_id: str, server: str, tool: str, it
3554
3672
  for key in ("command", "workdir", "mode", "timeout_seconds", "comment"):
3555
3673
  if key in arguments:
3556
3674
  metadata[key] = arguments.get(key)
3675
+ if server == "bash_exec" and tool == "bash_exec" and isinstance(arguments.get("id"), str):
3676
+ metadata["bash_id"] = arguments.get("id")
3557
3677
  result_payload = _mcp_result_payload(item)
3558
3678
  if server == "bash_exec" and tool == "bash_exec":
3559
3679
  for key in (
3560
3680
  "bash_id",
3561
3681
  "status",
3682
+ "command",
3683
+ "workdir",
3684
+ "cwd",
3685
+ "kind",
3686
+ "comment",
3562
3687
  "started_at",
3563
3688
  "finished_at",
3564
3689
  "exit_code",
3565
3690
  "stop_reason",
3566
3691
  "last_progress",
3567
3692
  "log_path",
3693
+ "watchdog_after_seconds",
3568
3694
  ):
3569
3695
  if key in result_payload:
3570
3696
  metadata[key] = result_payload.get(key)
@@ -153,8 +153,69 @@ class QuestStageViewBuilder:
153
153
  return candidate
154
154
  return self.quest_root
155
155
 
156
+ def _infer_stage_from_branch_name(self) -> str | None:
157
+ normalized = str(self.branch_name or "").strip().lower()
158
+ if not normalized:
159
+ return None
160
+ if normalized.startswith("analysis/"):
161
+ return "analysis"
162
+ if normalized.startswith("run/"):
163
+ return "experiment"
164
+ if normalized.startswith("idea/"):
165
+ return "idea"
166
+ if normalized.startswith("paper/") or normalized.startswith("write/"):
167
+ return "paper"
168
+ if normalized.startswith("baseline/"):
169
+ return "baseline"
170
+ return None
171
+
172
+ def _has_paper_state(self) -> bool:
173
+ paper_root = self._paper_root()
174
+ return bool(
175
+ self._paper_candidates()
176
+ or (paper_root / "selected_outline.json").exists()
177
+ or (paper_root / "draft.md").exists()
178
+ or self._paper_bundle_manifest()
179
+ )
180
+
181
+ def _resolve_effective_stage_key(self) -> str:
182
+ normalized = normalize_stage_key(self.stage_key)
183
+ if normalized in {"baseline", "idea", "experiment", "analysis", "paper"}:
184
+ return normalized
185
+ if normalized != "general":
186
+ return normalized
187
+
188
+ inferred = self._infer_stage_from_branch_name()
189
+ if inferred:
190
+ return inferred
191
+ if self._analysis_stage_items(None):
192
+ return "analysis"
193
+ if self._experiment_stage_items():
194
+ return "experiment"
195
+ if self._idea_stage_items():
196
+ return "idea"
197
+ if self._has_paper_state():
198
+ return "paper"
199
+ if self._baseline_stage_items():
200
+ return "baseline"
201
+ return normalized
202
+
203
+ @staticmethod
204
+ def _artifact_detail(item: dict[str, Any] | None, payload: dict[str, Any]) -> dict[str, Any] | None:
205
+ if not isinstance(payload, dict) or not payload:
206
+ return None
207
+ record = dict(item or {})
208
+ return {
209
+ "artifact_id": payload.get("artifact_id") or payload.get("id"),
210
+ "artifact_kind": payload.get("kind"),
211
+ "artifact_path": record.get("path"),
212
+ "payload": payload,
213
+ }
214
+
156
215
  def build(self) -> dict[str, Any]:
157
- if str(self.selection.get("selection_type") or "").strip() == "branch_node":
216
+ selection_type = str(self.selection.get("selection_type") or "").strip()
217
+ self.stage_key = self._resolve_effective_stage_key()
218
+ if selection_type == "branch_node" and self.stage_key not in {"experiment", "analysis", "paper"}:
158
219
  return self._build_branch()
159
220
  if self.stage_key == "baseline":
160
221
  return self._build_baseline()
@@ -332,6 +393,52 @@ class QuestStageViewBuilder:
332
393
  text = str(body or "").strip()
333
394
  return text or None
334
395
 
396
+ def _recent_trace_actions(self, *, limit: int = 6) -> list[dict[str, Any]]:
397
+ raw_actions = self.trace.get("actions") if isinstance(self.trace, dict) else []
398
+ if not isinstance(raw_actions, list) or not raw_actions:
399
+ return []
400
+ normalized: list[dict[str, Any]] = []
401
+ for item in raw_actions[-limit:]:
402
+ if not isinstance(item, dict):
403
+ continue
404
+ summary = _compact(item.get("summary") or item.get("output") or item.get("args"), limit=1000)
405
+ normalized.append(
406
+ {
407
+ "action_id": item.get("action_id"),
408
+ "title": item.get("title") or item.get("tool_name") or item.get("raw_event_type") or item.get("kind"),
409
+ "summary": summary,
410
+ "status": item.get("status"),
411
+ "created_at": item.get("created_at"),
412
+ "tool_name": item.get("tool_name"),
413
+ "artifact_kind": item.get("artifact_kind"),
414
+ }
415
+ )
416
+ return normalized
417
+
418
+ def _trace_summary(self) -> str | None:
419
+ if not isinstance(self.trace, dict):
420
+ return None
421
+ return _compact(
422
+ self.trace.get("summary")
423
+ or self.selection.get("summary")
424
+ or self.trace.get("title"),
425
+ limit=600,
426
+ )
427
+
428
+ def _trace_markdown(self, *, limit: int = 5) -> str | None:
429
+ items = self._recent_trace_actions(limit=limit)
430
+ if not items:
431
+ return None
432
+ lines: list[str] = []
433
+ for item in items:
434
+ title = str(item.get("title") or "Trace").strip() or "Trace"
435
+ summary = str(item.get("summary") or "").strip()
436
+ if summary:
437
+ lines.append(f"- **{title}**: {summary}")
438
+ else:
439
+ lines.append(f"- **{title}**")
440
+ return "\n".join(lines) if lines else None
441
+
335
442
  def _file_entry(
336
443
  self,
337
444
  raw_path: object,
@@ -728,6 +835,8 @@ class QuestStageViewBuilder:
728
835
  or self.snapshot.get("active_idea_draft_path")
729
836
  or str(Path(worktree_root) / "memory" / "ideas" / idea_id / "draft.md")
730
837
  )
838
+ idea_markdown = self._markdown_body_for_path(idea_md_path)
839
+ idea_md_rel_path = self._relative_path_or_raw(idea_md_path)
731
840
  draft_md_rel_path = self._relative_path_or_raw(draft_md_path)
732
841
  draft_markdown = self._markdown_body_for_path(draft_md_path)
733
842
  lineage_intent = str(payload.get("lineage_intent") or details.get("lineage_intent") or "").strip() or None
@@ -800,10 +909,14 @@ class QuestStageViewBuilder:
800
909
  "risks": details.get("risks") or [],
801
910
  "evidence_paths": details.get("evidence_paths") or [],
802
911
  "lineage_intent": lineage_intent,
912
+ "idea_path": idea_md_rel_path,
913
+ "idea_markdown": idea_markdown,
803
914
  "draft_path": draft_md_rel_path,
804
915
  "draft_markdown": draft_markdown,
805
916
  "literature_files": literature_files,
806
- }
917
+ "decision_reason": payload.get("reason"),
918
+ },
919
+ "latest_artifact": self._artifact_detail(latest, payload),
807
920
  },
808
921
  lineage_intent=lineage_intent,
809
922
  idea_draft_path=draft_md_rel_path,
@@ -869,6 +982,7 @@ class QuestStageViewBuilder:
869
982
  if isinstance(latest_experiment_payload.get("paths"), dict)
870
983
  else {}
871
984
  )
985
+ latest_run_markdown = self._markdown_body_for_path(latest_experiment_paths.get("run_md"))
872
986
  latest_result_payload = (
873
987
  read_json(Path(str(latest_experiment_paths.get("result_json"))), {})
874
988
  if str(latest_experiment_paths.get("result_json") or "").strip()
@@ -904,6 +1018,7 @@ class QuestStageViewBuilder:
904
1018
  if str(analysis_manifest.get("campaign_id") or "").strip()
905
1019
  else None
906
1020
  )
1021
+ analysis_summary_markdown = self._markdown_body_for_path(analysis_summary_path)
907
1022
 
908
1023
  branch_items = [
909
1024
  item
@@ -1008,6 +1123,11 @@ class QuestStageViewBuilder:
1008
1123
  "next_target": next_target,
1009
1124
  "idea_draft_path": idea_draft_rel_path,
1010
1125
  "idea_draft_markdown": idea_draft_markdown,
1126
+ "idea_hypothesis": latest_idea_details.get("hypothesis"),
1127
+ "idea_mechanism": latest_idea_details.get("mechanism"),
1128
+ "idea_expected_gain": latest_idea_details.get("expected_gain"),
1129
+ "idea_risks": latest_idea_details.get("risks") or [],
1130
+ "decision_reason": latest_idea_payload.get("reason"),
1011
1131
  "latest_main_experiment": {
1012
1132
  "run_id": latest_run_id,
1013
1133
  "summary": latest_experiment_payload.get("summary"),
@@ -1017,11 +1137,16 @@ class QuestStageViewBuilder:
1017
1137
  "progress_eval": latest_progress_eval,
1018
1138
  "evaluation_summary": latest_evaluation_summary,
1019
1139
  "run_md_path": latest_experiment_paths.get("run_md"),
1140
+ "run_markdown": latest_run_markdown,
1020
1141
  "result_json_path": latest_experiment_paths.get("result_json"),
1142
+ "result_payload": latest_result_payload,
1021
1143
  }
1022
1144
  if latest_run_id
1023
1145
  else None,
1024
- }
1146
+ "analysis_summary_path": self._relative_path_or_raw(analysis_summary_path),
1147
+ "analysis_summary_markdown": analysis_summary_markdown,
1148
+ },
1149
+ "latest_artifact": self._artifact_detail(latest_experiment_item or latest_idea_item, latest_experiment_payload or latest_idea_payload),
1025
1150
  },
1026
1151
  lineage_intent=lineage_intent,
1027
1152
  idea_draft_path=idea_draft_rel_path,
@@ -1057,8 +1182,17 @@ class QuestStageViewBuilder:
1057
1182
  baseline_ref = payload.get("baseline_ref") or result_payload.get("baseline_ref") or {}
1058
1183
  evaluation_summary = _evaluation_summary(payload.get("evaluation_summary") or result_payload.get("evaluation_summary"))
1059
1184
  run_id = str(payload.get("run_id") or "pending").strip() or "pending"
1185
+ run_markdown = self._markdown_body_for_path(paths.get("run_md"))
1186
+ trace_summary = self._trace_summary()
1187
+ trace_markdown = self._trace_markdown()
1060
1188
  note = (
1061
- str(payload.get("summary") or result_payload.get("conclusion") or (progress_eval or {}).get("reason") or "").strip()
1189
+ str(
1190
+ payload.get("summary")
1191
+ or result_payload.get("conclusion")
1192
+ or (progress_eval or {}).get("reason")
1193
+ or trace_summary
1194
+ or ""
1195
+ ).strip()
1062
1196
  or "No durable main experiment result has been recorded yet."
1063
1197
  )
1064
1198
  title = f"Experiment · {run_id}"
@@ -1119,8 +1253,15 @@ class QuestStageViewBuilder:
1119
1253
  "metrics_summary": metrics_summary,
1120
1254
  "progress_eval": progress_eval,
1121
1255
  "evaluation_summary": evaluation_summary,
1256
+ "run_path": self._relative_path_or_raw(paths.get("run_md")),
1257
+ "run_markdown": run_markdown,
1258
+ "result_json_path": self._relative_path_or_raw(paths.get("result_json")),
1122
1259
  "result_payload": result_payload,
1123
- }
1260
+ "trace_summary": trace_summary,
1261
+ "trace_markdown": trace_markdown,
1262
+ "trace_actions": self._recent_trace_actions(),
1263
+ },
1264
+ "latest_artifact": self._artifact_detail(latest, payload),
1124
1265
  },
1125
1266
  )
1126
1267
 
@@ -1145,7 +1286,9 @@ class QuestStageViewBuilder:
1145
1286
  flow_type = str(payload.get("flow_type") or "").strip()
1146
1287
  if flow_type not in {"analysis_campaign", "analysis_slice"}:
1147
1288
  continue
1148
- if campaign_id and str(payload.get("campaign_id") or "").strip() != campaign_id:
1289
+ if campaign_id:
1290
+ if str(payload.get("campaign_id") or "").strip() == campaign_id:
1291
+ items.append(item)
1149
1292
  continue
1150
1293
  if self._branch_matches(payload, allow_parent=True, include_unscoped=False):
1151
1294
  items.append(item)
@@ -1228,13 +1371,23 @@ class QuestStageViewBuilder:
1228
1371
  "plan_path": item.get("plan_path"),
1229
1372
  "result_path": item.get("result_path"),
1230
1373
  "mirror_path": item.get("mirror_path"),
1374
+ "plan_markdown": self._markdown_body_for_path(item.get("plan_path")),
1375
+ "result_markdown": self._markdown_body_for_path(item.get("result_path")),
1376
+ "mirror_markdown": self._markdown_body_for_path(item.get("mirror_path")),
1231
1377
  }
1232
1378
  )
1233
1379
  title = f"Analysis · {campaign_id}"
1380
+ trace_summary = self._trace_summary()
1234
1381
  note = (
1235
- str(latest_payload.get("summary") or manifest.get("goal") or "").strip()
1382
+ str(latest_payload.get("summary") or manifest.get("goal") or trace_summary or "").strip()
1236
1383
  or "No durable analysis campaign has been created yet."
1237
1384
  )
1385
+ summary_path = (
1386
+ self.quest_root / "experiments" / "analysis-results" / campaign_id / "SUMMARY.md"
1387
+ if campaign_id != "pending"
1388
+ else None
1389
+ )
1390
+ summary_markdown = self._markdown_body_for_path(summary_path) if summary_path else None
1238
1391
  key_files = self._dedupe_files(
1239
1392
  [
1240
1393
  self._file_entry(manifest.get("_manifest_path"), label="Campaign Manifest", description="Structured analysis campaign manifest."),
@@ -1293,8 +1446,17 @@ class QuestStageViewBuilder:
1293
1446
  "summary": latest_payload.get("summary"),
1294
1447
  "manifest_path": manifest.get("_manifest_path"),
1295
1448
  "charter_path": manifest.get("charter_path"),
1449
+ "charter_markdown": self._markdown_body_for_path(manifest.get("charter_path")),
1296
1450
  "todo_manifest_path": manifest.get("todo_manifest_path"),
1297
- }
1451
+ "todo_manifest_markdown": self._markdown_body_for_path(manifest.get("todo_manifest_path")),
1452
+ "summary_path": self._relative_path_or_raw(summary_path) if summary_path else None,
1453
+ "summary_markdown": summary_markdown,
1454
+ "manifest_payload": manifest,
1455
+ "trace_summary": trace_summary,
1456
+ "trace_markdown": self._trace_markdown(),
1457
+ "trace_actions": self._recent_trace_actions(),
1458
+ },
1459
+ "latest_artifact": self._artifact_detail(latest, latest_payload),
1298
1460
  },
1299
1461
  )
1300
1462
 
@@ -1435,6 +1597,7 @@ class QuestStageViewBuilder:
1435
1597
  "latex_root_path": latex_root_rel,
1436
1598
  "main_tex_path": main_tex_rel,
1437
1599
  },
1438
- }
1600
+ },
1601
+ "latest_artifact": self._artifact_detail(paper_items[-1] if paper_items else None, self._payload(paper_items[-1] if paper_items else {})),
1439
1602
  },
1440
1603
  )