@researai/deepscientist 1.5.7 → 1.5.9

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 (156) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +8 -4
  3. package/bin/ds.js +224 -9
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/07_MEMORY_AND_MCP.md +40 -3
  6. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  7. package/docs/zh/00_QUICK_START.md +2 -2
  8. package/docs/zh/07_MEMORY_AND_MCP.md +40 -3
  9. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  10. package/install.sh +34 -0
  11. package/package.json +2 -2
  12. package/pyproject.toml +2 -2
  13. package/src/deepscientist/__init__.py +1 -1
  14. package/src/deepscientist/acp/envelope.py +1 -0
  15. package/src/deepscientist/artifact/metrics.py +814 -83
  16. package/src/deepscientist/artifact/schemas.py +1 -0
  17. package/src/deepscientist/artifact/service.py +2001 -229
  18. package/src/deepscientist/bash_exec/monitor.py +1 -1
  19. package/src/deepscientist/bash_exec/service.py +17 -9
  20. package/src/deepscientist/channels/qq.py +17 -0
  21. package/src/deepscientist/channels/relay.py +16 -0
  22. package/src/deepscientist/config/models.py +6 -0
  23. package/src/deepscientist/config/service.py +70 -2
  24. package/src/deepscientist/daemon/api/handlers.py +414 -14
  25. package/src/deepscientist/daemon/api/router.py +4 -0
  26. package/src/deepscientist/daemon/app.py +292 -21
  27. package/src/deepscientist/gitops/diff.py +6 -10
  28. package/src/deepscientist/mcp/server.py +191 -40
  29. package/src/deepscientist/prompts/builder.py +65 -19
  30. package/src/deepscientist/quest/node_traces.py +129 -2
  31. package/src/deepscientist/quest/service.py +140 -34
  32. package/src/deepscientist/quest/stage_views.py +175 -33
  33. package/src/deepscientist/registries/baseline.py +56 -4
  34. package/src/deepscientist/runners/codex.py +1 -1
  35. package/src/prompts/connectors/qq.md +1 -1
  36. package/src/prompts/contracts/shared_interaction.md +14 -0
  37. package/src/prompts/system.md +113 -32
  38. package/src/skills/analysis-campaign/SKILL.md +10 -14
  39. package/src/skills/baseline/SKILL.md +51 -38
  40. package/src/skills/baseline/references/baseline-plan-template.md +2 -0
  41. package/src/skills/decision/SKILL.md +12 -8
  42. package/src/skills/experiment/SKILL.md +28 -16
  43. package/src/skills/experiment/references/main-experiment-plan-template.md +2 -0
  44. package/src/skills/figure-polish/SKILL.md +1 -0
  45. package/src/skills/finalize/SKILL.md +3 -8
  46. package/src/skills/idea/SKILL.md +18 -8
  47. package/src/skills/idea/references/literature-survey-template.md +24 -0
  48. package/src/skills/idea/references/related-work-playbook.md +4 -0
  49. package/src/skills/idea/references/selection-gate.md +9 -0
  50. package/src/skills/intake-audit/SKILL.md +2 -8
  51. package/src/skills/rebuttal/SKILL.md +2 -8
  52. package/src/skills/review/SKILL.md +2 -8
  53. package/src/skills/scout/SKILL.md +2 -8
  54. package/src/skills/write/SKILL.md +53 -17
  55. package/src/skills/write/templates/DEEPSCIENTIST_NOTES.md +21 -0
  56. package/src/skills/write/templates/README.md +408 -0
  57. package/src/skills/write/templates/UPSTREAM_LICENSE.txt +21 -0
  58. package/src/skills/write/templates/aaai2026/README.md +534 -0
  59. package/src/skills/write/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  60. package/src/skills/write/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  61. package/src/skills/write/templates/aaai2026/aaai2026.bib +111 -0
  62. package/src/skills/write/templates/aaai2026/aaai2026.bst +1493 -0
  63. package/src/skills/write/templates/aaai2026/aaai2026.sty +315 -0
  64. package/src/skills/write/templates/acl/README.md +50 -0
  65. package/src/skills/write/templates/acl/acl.sty +312 -0
  66. package/src/skills/write/templates/acl/acl_latex.tex +377 -0
  67. package/src/skills/write/templates/acl/acl_lualatex.tex +101 -0
  68. package/src/skills/write/templates/acl/acl_natbib.bst +1940 -0
  69. package/src/skills/write/templates/acl/anthology.bib.txt +26 -0
  70. package/src/skills/write/templates/acl/custom.bib +70 -0
  71. package/src/skills/write/templates/acl/formatting.md +326 -0
  72. package/src/skills/write/templates/asplos2027/main.tex +459 -0
  73. package/src/skills/write/templates/asplos2027/references.bib +135 -0
  74. package/src/skills/write/templates/colm2025/README.md +3 -0
  75. package/src/skills/write/templates/colm2025/colm2025_conference.bib +11 -0
  76. package/src/skills/write/templates/colm2025/colm2025_conference.bst +1440 -0
  77. package/src/skills/write/templates/colm2025/colm2025_conference.sty +218 -0
  78. package/src/skills/write/templates/colm2025/colm2025_conference.tex +305 -0
  79. package/src/skills/write/templates/colm2025/fancyhdr.sty +485 -0
  80. package/src/skills/write/templates/colm2025/math_commands.tex +508 -0
  81. package/src/skills/write/templates/colm2025/natbib.sty +1246 -0
  82. package/src/skills/write/templates/iclr2026/fancyhdr.sty +485 -0
  83. package/src/skills/write/templates/iclr2026/iclr2026_conference.bib +24 -0
  84. package/src/skills/write/templates/iclr2026/iclr2026_conference.bst +1440 -0
  85. package/src/skills/write/templates/iclr2026/iclr2026_conference.sty +246 -0
  86. package/src/skills/write/templates/iclr2026/iclr2026_conference.tex +414 -0
  87. package/src/skills/write/templates/iclr2026/math_commands.tex +508 -0
  88. package/src/skills/write/templates/iclr2026/natbib.sty +1246 -0
  89. package/src/skills/write/templates/icml2026/algorithm.sty +79 -0
  90. package/src/skills/write/templates/icml2026/algorithmic.sty +201 -0
  91. package/src/skills/write/templates/icml2026/example_paper.bib +75 -0
  92. package/src/skills/write/templates/icml2026/example_paper.tex +662 -0
  93. package/src/skills/write/templates/icml2026/fancyhdr.sty +864 -0
  94. package/src/skills/write/templates/icml2026/icml2026.bst +1443 -0
  95. package/src/skills/write/templates/icml2026/icml2026.sty +767 -0
  96. package/src/skills/write/templates/neurips2025/Makefile +36 -0
  97. package/src/skills/write/templates/neurips2025/extra_pkgs.tex +53 -0
  98. package/src/skills/write/templates/neurips2025/main.tex +38 -0
  99. package/src/skills/write/templates/neurips2025/neurips.sty +382 -0
  100. package/src/skills/write/templates/nsdi2027/main.tex +426 -0
  101. package/src/skills/write/templates/nsdi2027/references.bib +151 -0
  102. package/src/skills/write/templates/nsdi2027/usenix-2020-09.sty +83 -0
  103. package/src/skills/write/templates/osdi2026/main.tex +429 -0
  104. package/src/skills/write/templates/osdi2026/references.bib +150 -0
  105. package/src/skills/write/templates/osdi2026/usenix-2020-09.sty +83 -0
  106. package/src/skills/write/templates/sosp2026/main.tex +532 -0
  107. package/src/skills/write/templates/sosp2026/references.bib +148 -0
  108. package/src/tui/package.json +1 -1
  109. package/src/ui/dist/assets/{AiManusChatView-BS3V4ZOk.js → AiManusChatView-BKZ103sn.js} +110 -14
  110. package/src/ui/dist/assets/{AnalysisPlugin-DLPXQsmr.js → AnalysisPlugin-mTTzGAlK.js} +1 -1
  111. package/src/ui/dist/assets/{AutoFigurePlugin-C-Fr9knQ.js → AutoFigurePlugin-C_wWw4AP.js} +5 -5
  112. package/src/ui/dist/assets/{CliPlugin-Dd8AHzFg.js → CliPlugin-BH58n3GY.js} +9 -9
  113. package/src/ui/dist/assets/{CodeEditorPlugin-Dg-RepTl.js → CodeEditorPlugin-BKGRUH7e.js} +8 -8
  114. package/src/ui/dist/assets/{CodeViewerPlugin-D2J_3nyt.js → CodeViewerPlugin-BMADwFWJ.js} +5 -5
  115. package/src/ui/dist/assets/{DocViewerPlugin-ChRLLKNb.js → DocViewerPlugin-ZOnTIHLN.js} +3 -3
  116. package/src/ui/dist/assets/{GitDiffViewerPlugin-DgHfcved.js → GitDiffViewerPlugin-CQ7h1Djm.js} +830 -86
  117. package/src/ui/dist/assets/{ImageViewerPlugin-C89GZMBy.js → ImageViewerPlugin-GVS5MsnC.js} +5 -5
  118. package/src/ui/dist/assets/{LabCopilotPanel-BUfIwUcb.js → LabCopilotPanel-BZNv1JML.js} +10 -10
  119. package/src/ui/dist/assets/{LabPlugin-zvUmQUMq.js → LabPlugin-TWcJsdQA.js} +1 -1
  120. package/src/ui/dist/assets/{LatexPlugin-C1SSNuWp.js → LatexPlugin-DIjHiR2x.js} +7 -7
  121. package/src/ui/dist/assets/{MarkdownViewerPlugin-D2Mf5tU5.js → MarkdownViewerPlugin-D3ooGAH0.js} +4 -4
  122. package/src/ui/dist/assets/{MarketplacePlugin-CF4LgiS2.js → MarketplacePlugin-DfVfE9hN.js} +3 -3
  123. package/src/ui/dist/assets/{NotebookEditor-BM7Bgwlv.js → NotebookEditor-DDl0_Mc0.js} +1 -1
  124. package/src/ui/dist/assets/{index-Be0NAmh8.js → NotebookEditor-s8JhzuX1.js} +12 -155
  125. package/src/ui/dist/assets/{PdfLoader-Bc5qfD-Z.js → PdfLoader-C2Sf6SJM.js} +1 -1
  126. package/src/ui/dist/assets/{PdfMarkdownPlugin-sh1-IRcp.js → PdfMarkdownPlugin-CXFLoIsa.js} +3 -3
  127. package/src/ui/dist/assets/{PdfViewerPlugin-C_a7CpWG.js → PdfViewerPlugin-BYTmz2fK.js} +10 -10
  128. package/src/ui/dist/assets/{SearchPlugin-L4z3HcLf.js → SearchPlugin-CjWBI1O9.js} +1 -1
  129. package/src/ui/dist/assets/{Stepper-Dk4aQ3fN.js → Stepper-B0Dd8CxK.js} +1 -1
  130. package/src/ui/dist/assets/{TextViewerPlugin-BsNtlKVo.js → TextViewerPlugin-DdOBU3-S.js} +4 -4
  131. package/src/ui/dist/assets/{VNCViewer-BpeDcZ5_.js → VNCViewer-B8HGgLwQ.js} +9 -9
  132. package/src/ui/dist/assets/{bibtex-C4QI-bbj.js → bibtex-CKaefIN2.js} +1 -1
  133. package/src/ui/dist/assets/{code-DuMINRsg.js → code-BWAY76JP.js} +1 -1
  134. package/src/ui/dist/assets/{file-content-C3N-432K.js → file-content-C1NwU5oQ.js} +1 -1
  135. package/src/ui/dist/assets/{file-diff-panel-CffQ4ZMg.js → file-diff-panel-CywslwB9.js} +1 -1
  136. package/src/ui/dist/assets/{file-socket-CRH59PCO.js → file-socket-B4kzuOBQ.js} +1 -1
  137. package/src/ui/dist/assets/{file-utils-vYGtW2mI.js → file-utils-H2fjA46S.js} +1 -1
  138. package/src/ui/dist/assets/{image-DBVGaooo.js → image-D-NZM-6P.js} +1 -1
  139. package/src/ui/dist/assets/{index-B1P6hQRJ.js → index-7Chr1g9c.js} +3734 -1862
  140. package/src/ui/dist/assets/{index-DjSFDmgB.js → index-BdM1Gqfr.js} +2 -2
  141. package/src/ui/dist/assets/{index-BpjYH9Vg.js → index-CDxNdQdz.js} +1 -1
  142. package/src/ui/dist/assets/{index-Do9N28uB.css → index-DGIYDuTv.css} +163 -34
  143. package/src/ui/dist/assets/index-DHZJ_0TI.js +159 -0
  144. package/src/ui/dist/assets/{message-square-BsPDBhiY.js → message-square-BzjLiXir.js} +1 -1
  145. package/src/ui/dist/assets/{monaco-BTkdPojV.js → monaco-Cb2uKKe6.js} +1 -1
  146. package/src/ui/dist/assets/{popover-cWjCk-vc.js → popover-Bg72DGgT.js} +1 -1
  147. package/src/ui/dist/assets/{project-sync-CXn530xb.js → project-sync-Ce_0BglY.js} +1 -1
  148. package/src/ui/dist/assets/{sigma-04Jr12jg.js → sigma-DPaACDrh.js} +1 -1
  149. package/src/ui/dist/assets/{tooltip-BdVDl0G5.js → tooltip-C_mA6R0w.js} +1 -1
  150. package/src/ui/dist/assets/{trash-CB_GlQyC.js → trash-BvTgE5__.js} +1 -1
  151. package/src/ui/dist/assets/{useCliAccess-BL932NwS.js → useCliAccess-CgPeMOwP.js} +1 -1
  152. package/src/ui/dist/assets/{useFileDiffOverlay-B2WK7Tvq.js → useFileDiffOverlay-xPhz7P5B.js} +1 -1
  153. package/src/ui/dist/assets/{wrap-text-YC68g12z.js → wrap-text-C3Un3YQr.js} +1 -1
  154. package/src/ui/dist/assets/{zoom-out-C0RJvFiJ.js → zoom-out-BgxLa0Ri.js} +1 -1
  155. package/src/ui/dist/index.html +5 -2
  156. /package/src/ui/dist/assets/{index-CccQYZjX.css → NotebookEditor-CccQYZjX.css} +0 -0
@@ -14,7 +14,7 @@ from ... import __version__ as DEEPSCIENTIST_VERSION
14
14
  from ...gitops import commit_detail, compare_refs, diff_file_between_refs, diff_file_for_commit, export_git_graph, list_branch_canvas, log_ref_history
15
15
  from ...memory import MemoryService
16
16
  from ...quest import QuestService
17
- from ...shared import generate_id, read_text, resolve_within, sha256_text, utc_now
17
+ from ...shared import generate_id, read_json, read_text, resolve_within, run_command, sha256_text, utc_now
18
18
  from ...runners import RunRequest
19
19
 
20
20
 
@@ -171,9 +171,9 @@ npm --prefix src/ui run build</pre>
171
171
 
172
172
  def cli_health(self) -> dict:
173
173
  online_channels = [
174
- channel.status()
175
- for channel in self.app.channels.values()
176
- if channel.status().get("enabled") is not False
174
+ snapshot
175
+ for snapshot in self.app.list_connector_statuses()
176
+ if snapshot.get("enabled") is not False
177
177
  ]
178
178
  return {
179
179
  "status": "ok",
@@ -211,6 +211,14 @@ npm --prefix src/ui run build</pre>
211
211
  def baselines(self) -> list[dict]:
212
212
  return self.app.artifact_service.baselines.list_entries()
213
213
 
214
+ def baseline_delete(self, baseline_id: str) -> dict | tuple[int, dict]:
215
+ try:
216
+ return self.app.artifact_service.delete_baseline(baseline_id)
217
+ except FileNotFoundError as exc:
218
+ return 404, {"ok": False, "message": str(exc), "baseline_id": baseline_id}
219
+ except ValueError as exc:
220
+ return 400, {"ok": False, "message": str(exc), "baseline_id": baseline_id}
221
+
214
222
  def qq_bindings(self) -> list[dict]:
215
223
  return self.app.list_qq_bindings()
216
224
 
@@ -248,12 +256,15 @@ npm --prefix src/ui run build</pre>
248
256
  else []
249
257
  )
250
258
  force_connector_rebind_raw = body.get("force_connector_rebind")
251
- force_connector_rebind = bool(force_connector_rebind_raw) and str(force_connector_rebind_raw).strip().lower() not in {
252
- "0",
253
- "false",
254
- "no",
255
- "off",
256
- }
259
+ if force_connector_rebind_raw is None:
260
+ force_connector_rebind = True
261
+ else:
262
+ force_connector_rebind = bool(force_connector_rebind_raw) and str(force_connector_rebind_raw).strip().lower() not in {
263
+ "0",
264
+ "false",
265
+ "no",
266
+ "off",
267
+ }
257
268
  requested_baseline_ref = body.get("requested_baseline_ref")
258
269
  startup_contract = body.get("startup_contract")
259
270
  auto_start = body.get("auto_start") is True
@@ -645,8 +656,6 @@ npm --prefix src/ui run build</pre>
645
656
  session = self.app.bash_exec_service.get_session(quest_root, session_id)
646
657
  except FileNotFoundError:
647
658
  return 404, {"ok": False, "message": f"Unknown terminal session `{session_id}`."}
648
- if str(session.get("kind") or "").lower() != "terminal":
649
- return 400, {"ok": False, "message": "not_terminal_session"}
650
659
  if str(session.get("status") or "").lower() in {"completed", "failed", "terminated"}:
651
660
  return 409, {"ok": False, "message": "terminal_session_inactive", "session": session}
652
661
  try:
@@ -720,6 +729,28 @@ npm --prefix src/ui run build</pre>
720
729
  def workflow(self, quest_id: str) -> dict:
721
730
  return self.app.quest_service.workflow(quest_id)
722
731
 
732
+ def quest_layout(self, quest_id: str) -> dict:
733
+ quest_root = self._fresh_quest_service()._quest_root(quest_id)
734
+ payload = self.app.quest_service.read_lab_canvas_state(quest_root)
735
+ return {
736
+ "layout_json": payload.get("layout_json") if isinstance(payload.get("layout_json"), dict) else {},
737
+ "updated_at": payload.get("updated_at"),
738
+ }
739
+
740
+ def quest_layout_update(self, quest_id: str, body: dict) -> dict | tuple[int, dict]:
741
+ quest_root = self._fresh_quest_service()._quest_root(quest_id)
742
+ raw_layout = body.get("layout_json")
743
+ if raw_layout is not None and not isinstance(raw_layout, dict):
744
+ return 400, {"ok": False, "message": "`layout_json` must be an object."}
745
+ payload = self.app.quest_service.update_lab_canvas_state(
746
+ quest_root,
747
+ layout_json=dict(raw_layout or {}),
748
+ )
749
+ return {
750
+ "layout_json": payload.get("layout_json") if isinstance(payload.get("layout_json"), dict) else {},
751
+ "updated_at": payload.get("updated_at"),
752
+ }
753
+
723
754
  def node_traces(self, quest_id: str, path: str) -> dict:
724
755
  query = self.parse_query(path)
725
756
  selection_type = ((query.get("selection_type") or [""])[0] or "").strip() or None
@@ -747,6 +778,20 @@ npm --prefix src/ui run build</pre>
747
778
  def git_branches(self, quest_id: str) -> dict:
748
779
  quest_root = self._fresh_quest_service()._quest_root(quest_id)
749
780
  payload = list_branch_canvas(quest_root, quest_id=quest_id)
781
+ research_state = self.app.quest_service.read_research_state(quest_root)
782
+ active_workspace_branch = str(research_state.get("current_workspace_branch") or "").strip() or None
783
+ research_head_branch = str(research_state.get("research_head_branch") or "").strip() or None
784
+ payload["active_workspace_ref"] = active_workspace_branch
785
+ payload["research_head_ref"] = research_head_branch
786
+ payload["workspace_mode"] = str(research_state.get("workspace_mode") or "quest").strip() or "quest"
787
+ quest_data = self.app.quest_service.read_quest_yaml(quest_root)
788
+ active_anchor = str(quest_data.get("active_anchor") or "").strip().lower()
789
+ active_analysis_campaign_id = str(research_state.get("active_analysis_campaign_id") or "").strip() or None
790
+ current_workspace_branch = str(research_state.get("current_workspace_branch") or "").strip() or None
791
+ workspace_mode = str(research_state.get("workspace_mode") or "").strip().lower() or "quest"
792
+ paper_parent_branch = str(research_state.get("paper_parent_branch") or "").strip() or None
793
+ paper_parent_run_id = str(research_state.get("paper_parent_run_id") or "").strip() or None
794
+ next_pending_slice_id = str(research_state.get("next_pending_slice_id") or "").strip() or None
750
795
  try:
751
796
  branch_summary = self.app.artifact_service.list_research_branches(quest_root)
752
797
  except Exception:
@@ -756,11 +801,104 @@ npm --prefix src/ui run build</pre>
756
801
  for item in (branch_summary.get("branches") or [])
757
802
  if str(item.get("branch_name") or "").strip()
758
803
  }
804
+ active_campaign = {}
805
+ if active_analysis_campaign_id:
806
+ try:
807
+ active_campaign = self.app.artifact_service.get_analysis_campaign(
808
+ quest_root,
809
+ campaign_id=active_analysis_campaign_id,
810
+ )
811
+ except Exception:
812
+ active_campaign = {}
813
+ campaign_parent_branch = (
814
+ str(active_campaign.get("parent_branch") or "").strip() or None
815
+ if isinstance(active_campaign, dict)
816
+ else None
817
+ )
818
+ campaign_slices = [
819
+ dict(item)
820
+ for item in ((active_campaign or {}).get("slices") or [])
821
+ if isinstance(item, dict)
822
+ ]
823
+ campaign_total_slices = len(campaign_slices)
824
+ campaign_completed_slices = sum(
825
+ 1 for item in campaign_slices if str(item.get("status") or "").strip().lower() == "completed"
826
+ )
827
+ slice_by_branch = {
828
+ str(item.get("branch") or "").strip(): item
829
+ for item in campaign_slices
830
+ if str(item.get("branch") or "").strip()
831
+ }
832
+
833
+ def resolve_workflow_state(ref: str, summary: dict[str, object] | None) -> dict[str, object]:
834
+ branch_kind = self.app.artifact_service._branch_kind_from_name(ref)
835
+ has_main_result = bool((summary or {}).get("has_main_result"))
836
+ workflow_state: dict[str, object] = {
837
+ "analysis_state": "none",
838
+ "writing_state": "not_ready",
839
+ "analysis_campaign_id": active_analysis_campaign_id,
840
+ "total_slices": campaign_total_slices or None,
841
+ "completed_slices": campaign_completed_slices or None,
842
+ "next_pending_slice_id": next_pending_slice_id,
843
+ "paper_parent_branch": paper_parent_branch,
844
+ "paper_parent_run_id": paper_parent_run_id,
845
+ "status_reason": None,
846
+ }
847
+ if branch_kind == "analysis":
848
+ slice_entry = slice_by_branch.get(ref)
849
+ slice_status = str((slice_entry or {}).get("status") or "pending").strip().lower() or "pending"
850
+ if slice_status == "completed":
851
+ workflow_state["analysis_state"] = "completed"
852
+ workflow_state["status_reason"] = "Analysis slice completed."
853
+ elif ref == current_workspace_branch or str((slice_entry or {}).get("slice_id") or "").strip() == next_pending_slice_id:
854
+ workflow_state["analysis_state"] = "active"
855
+ workflow_state["status_reason"] = (
856
+ f"Analysis {campaign_completed_slices}/{campaign_total_slices} done"
857
+ if campaign_total_slices
858
+ else "Analysis slice active."
859
+ )
860
+ else:
861
+ workflow_state["analysis_state"] = "pending"
862
+ workflow_state["status_reason"] = "Analysis slice pending."
863
+ return workflow_state
864
+ if branch_kind == "paper":
865
+ if ref == current_workspace_branch and workspace_mode == "paper":
866
+ workflow_state["writing_state"] = "completed" if active_anchor == "finalize" else "active"
867
+ workflow_state["status_reason"] = (
868
+ "Writing finalized." if active_anchor == "finalize" else "Writing workspace active."
869
+ )
870
+ else:
871
+ workflow_state["writing_state"] = "ready"
872
+ workflow_state["status_reason"] = "Writing workspace prepared."
873
+ return workflow_state
874
+ if campaign_parent_branch and ref == campaign_parent_branch:
875
+ workflow_state["analysis_state"] = "completed" if next_pending_slice_id is None else "active"
876
+ if has_main_result:
877
+ workflow_state["writing_state"] = "ready" if next_pending_slice_id is None else "blocked_by_analysis"
878
+ workflow_state["status_reason"] = (
879
+ "Analysis complete. Ready for writing."
880
+ if next_pending_slice_id is None
881
+ else (
882
+ f"Analysis {campaign_completed_slices}/{campaign_total_slices} done"
883
+ + (f" · next: {next_pending_slice_id}" if next_pending_slice_id else "")
884
+ )
885
+ )
886
+ return workflow_state
887
+ if has_main_result:
888
+ workflow_state["writing_state"] = "ready"
889
+ workflow_state["status_reason"] = "Main experiment recorded. Ready for writing."
890
+ return workflow_state
891
+ workflow_state["status_reason"] = "Awaiting main experiment result."
892
+ return workflow_state
893
+
759
894
  for node in payload.get("nodes", []):
760
895
  ref = str(node.get("ref") or "").strip()
761
896
  if not ref:
762
897
  continue
763
898
  summary = branch_summary_by_name.get(ref)
899
+ node["active_workspace"] = ref == active_workspace_branch
900
+ node["research_head"] = ref == research_head_branch
901
+ node["workflow_state"] = resolve_workflow_state(ref, summary if isinstance(summary, dict) else None)
764
902
  if not isinstance(summary, dict):
765
903
  continue
766
904
  node["branch_no"] = summary.get("branch_no")
@@ -775,6 +913,7 @@ npm --prefix src/ui run build</pre>
775
913
  node["idea_draft_path"] = summary.get("idea_draft_path")
776
914
  node["latest_main_experiment"] = summary.get("latest_main_experiment")
777
915
  node["experiment_count"] = summary.get("experiment_count")
916
+ node["has_main_result"] = summary.get("has_main_result")
778
917
  return payload
779
918
 
780
919
  def git_log(self, quest_id: str, path: str) -> dict:
@@ -818,6 +957,126 @@ npm --prefix src/ui run build</pre>
818
957
  quest_root = self._fresh_quest_service()._quest_root(quest_id)
819
958
  return diff_file_between_refs(quest_root, base=base, head=head, path=file_path)
820
959
 
960
+ def file_change_diff(self, quest_id: str, path: str) -> dict:
961
+ query = self.parse_query(path)
962
+ run_id = ((query.get("run_id") or [""])[0] or "").strip()
963
+ event_id = ((query.get("event_id") or [""])[0] or "").strip() or None
964
+ raw_path = ((query.get("path") or [""])[0] or "").strip()
965
+ if not run_id or not raw_path:
966
+ return self._file_change_diff_unavailable(
967
+ raw_path=raw_path,
968
+ run_id=run_id or None,
969
+ event_id=event_id,
970
+ message="`run_id` and `path` are required.",
971
+ ok=False,
972
+ )
973
+
974
+ quest_root = self._fresh_quest_service()._quest_root(quest_id)
975
+ run_artifact = read_json(quest_root / ".ds" / "runs" / run_id / "artifact.json", default={})
976
+ if not isinstance(run_artifact, dict):
977
+ return self._file_change_diff_unavailable(
978
+ raw_path=raw_path,
979
+ run_id=run_id,
980
+ event_id=event_id,
981
+ message="Historical patch unavailable. Run artifact metadata is missing.",
982
+ ok=False,
983
+ )
984
+
985
+ record = run_artifact.get("record") if isinstance(run_artifact.get("record"), dict) else {}
986
+ checkpoint = run_artifact.get("checkpoint") if isinstance(run_artifact.get("checkpoint"), dict) else {}
987
+ base = str(record.get("head_commit") or "").strip()
988
+ head = str(checkpoint.get("head") or "").strip()
989
+ branch = str(record.get("branch") or "").strip() or None
990
+ workspace_root = self._file_change_workspace_root(run_artifact, record)
991
+ relative_path = None
992
+ display_path = None
993
+
994
+ if not base or not head:
995
+ relative_path = self._relative_file_change_path(None, raw_path, workspace_root)
996
+ display_path = self._display_file_change_path(quest_root, raw_path, relative_path=relative_path)
997
+ return self._file_change_diff_unavailable(
998
+ raw_path=raw_path,
999
+ run_id=run_id,
1000
+ event_id=event_id,
1001
+ base=base,
1002
+ head=head,
1003
+ branch=branch,
1004
+ relative_path=relative_path,
1005
+ display_path=display_path,
1006
+ message="Historical patch unavailable. Run artifact metadata is missing the recorded commit range.",
1007
+ ok=True,
1008
+ )
1009
+
1010
+ repo_root = self._resolve_git_repo_root_for_file_change(raw_path, workspace_root)
1011
+ relative_path = self._relative_file_change_path(repo_root, raw_path, workspace_root)
1012
+ display_path = self._display_file_change_path(quest_root, raw_path, relative_path=relative_path)
1013
+
1014
+ if repo_root is None or not relative_path:
1015
+ return self._file_change_diff_unavailable(
1016
+ raw_path=raw_path,
1017
+ run_id=run_id,
1018
+ event_id=event_id,
1019
+ base=base,
1020
+ head=head,
1021
+ branch=branch,
1022
+ relative_path=relative_path,
1023
+ display_path=display_path,
1024
+ message="Historical patch unavailable. DeepScientist could not map this file to a git worktree.",
1025
+ ok=True,
1026
+ )
1027
+
1028
+ try:
1029
+ diff = diff_file_between_refs(repo_root, base=base, head=head, path=relative_path)
1030
+ except Exception:
1031
+ return self._file_change_diff_unavailable(
1032
+ raw_path=raw_path,
1033
+ run_id=run_id,
1034
+ event_id=event_id,
1035
+ base=base,
1036
+ head=head,
1037
+ branch=branch,
1038
+ relative_path=relative_path,
1039
+ display_path=display_path,
1040
+ message=(
1041
+ "Historical patch unavailable. The saved run checkpoint does not match the git repository "
1042
+ "that currently owns this file."
1043
+ ),
1044
+ ok=True,
1045
+ )
1046
+
1047
+ if (
1048
+ not diff.get("binary")
1049
+ and not diff.get("lines")
1050
+ and int(diff.get("added") or 0) == 0
1051
+ and int(diff.get("removed") or 0) == 0
1052
+ ):
1053
+ return self._file_change_diff_unavailable(
1054
+ raw_path=raw_path,
1055
+ run_id=run_id,
1056
+ event_id=event_id,
1057
+ base=base,
1058
+ head=head,
1059
+ branch=branch,
1060
+ relative_path=relative_path,
1061
+ display_path=display_path,
1062
+ message=(
1063
+ "Historical patch unavailable. This event only preserved file-level metadata or the edit "
1064
+ "did not land in the run's final checkpoint."
1065
+ ),
1066
+ ok=True,
1067
+ )
1068
+
1069
+ return {
1070
+ **diff,
1071
+ "available": True,
1072
+ "source": "run_range",
1073
+ "display_path": display_path or relative_path,
1074
+ "run_id": run_id,
1075
+ "event_id": event_id,
1076
+ "branch": branch,
1077
+ "message": None,
1078
+ }
1079
+
821
1080
  def git_commit_file(self, quest_id: str, path: str) -> dict:
822
1081
  query = self.parse_query(path)
823
1082
  sha = ((query.get("sha") or [""])[0] or "").strip()
@@ -1207,6 +1466,16 @@ npm --prefix src/ui run build</pre>
1207
1466
  "ok": False,
1208
1467
  "message": str(exc),
1209
1468
  }
1469
+ raw_reasoning_effort = (
1470
+ body.get("model_reasoning_effort")
1471
+ if "model_reasoning_effort" in body
1472
+ else runner_cfg.get("model_reasoning_effort")
1473
+ )
1474
+ reasoning_effort = (
1475
+ str(raw_reasoning_effort).strip()
1476
+ if raw_reasoning_effort is not None and str(raw_reasoning_effort).strip()
1477
+ else ("xhigh" if raw_reasoning_effort is None else None)
1478
+ )
1210
1479
  request = RunRequest(
1211
1480
  quest_id=quest_id,
1212
1481
  quest_root=quest_root,
@@ -1218,8 +1487,7 @@ npm --prefix src/ui run build</pre>
1218
1487
  approval_policy=runner_cfg.get("approval_policy", "on-request"),
1219
1488
  sandbox_mode=runner_cfg.get("sandbox_mode", "workspace-write"),
1220
1489
  turn_reason=body.get("turn_reason") or "user_message",
1221
- reasoning_effort=body.get("model_reasoning_effort")
1222
- or runner_cfg.get("model_reasoning_effort", "xhigh"),
1490
+ reasoning_effort=reasoning_effort,
1223
1491
  )
1224
1492
  result = runner.run(request)
1225
1493
  if result.output_text:
@@ -1355,6 +1623,8 @@ npm --prefix src/ui run build</pre>
1355
1623
  result = self.app.config_manager.save_named_payload(name, body["structured"])
1356
1624
  else:
1357
1625
  result = self.app.config_manager.save_named_text(name, body.get("content", ""))
1626
+ if result.get("ok") and name == "config":
1627
+ result["runtime_reload"] = self.app.reload_runtime_config()
1358
1628
  if result.get("ok") and name == "connectors":
1359
1629
  result["runtime_reload"] = self.app.reload_connectors_config()
1360
1630
  if result.get("ok") and name == "runners":
@@ -1411,6 +1681,136 @@ npm --prefix src/ui run build</pre>
1411
1681
  return {}
1412
1682
  return json.loads(raw.decode("utf-8"))
1413
1683
 
1684
+ @staticmethod
1685
+ def _file_change_workspace_root(run_artifact: dict, record: dict) -> Path | None:
1686
+ workspace_root = str(run_artifact.get("workspace_root") or record.get("workspace_root") or "").strip()
1687
+ if not workspace_root:
1688
+ return None
1689
+ return Path(workspace_root).expanduser()
1690
+
1691
+ @staticmethod
1692
+ def _file_change_path_candidates(raw_path: str, workspace_root: Path | None) -> list[Path]:
1693
+ text = str(raw_path or "").strip()
1694
+ if not text:
1695
+ return []
1696
+ raw_candidate = Path(text).expanduser()
1697
+ candidates: list[Path] = []
1698
+ if raw_candidate.is_absolute():
1699
+ candidates.append(raw_candidate)
1700
+ elif workspace_root is not None:
1701
+ candidates.append(workspace_root / raw_candidate)
1702
+ else:
1703
+ candidates.append(raw_candidate)
1704
+ if workspace_root is not None:
1705
+ candidates.append(workspace_root)
1706
+
1707
+ resolved: list[Path] = []
1708
+ seen: set[str] = set()
1709
+ for candidate in candidates:
1710
+ for variant in (candidate, candidate.resolve(strict=False)):
1711
+ key = str(variant)
1712
+ if key in seen:
1713
+ continue
1714
+ seen.add(key)
1715
+ resolved.append(variant)
1716
+ return resolved
1717
+
1718
+ @staticmethod
1719
+ def _git_probe_root(candidate: Path) -> Path:
1720
+ if candidate.exists():
1721
+ return candidate if candidate.is_dir() else candidate.parent
1722
+ for parent in candidate.parents:
1723
+ if parent.exists():
1724
+ return parent
1725
+ return candidate.parent
1726
+
1727
+ def _resolve_git_repo_root_for_file_change(self, raw_path: str, workspace_root: Path | None) -> Path | None:
1728
+ for candidate in self._file_change_path_candidates(raw_path, workspace_root):
1729
+ probe_root = self._git_probe_root(candidate)
1730
+ if not str(probe_root).strip():
1731
+ continue
1732
+ result = run_command(["git", "rev-parse", "--show-toplevel"], cwd=probe_root, check=False)
1733
+ if result.returncode != 0:
1734
+ continue
1735
+ top_level = result.stdout.strip()
1736
+ if top_level:
1737
+ return Path(top_level).resolve()
1738
+ return None
1739
+
1740
+ def _relative_file_change_path(self, repo_root: Path | None, raw_path: str, workspace_root: Path | None) -> str | None:
1741
+ if repo_root is None:
1742
+ if Path(str(raw_path or "").strip()).is_absolute():
1743
+ return None
1744
+ normalized = str(raw_path or "").strip().replace("\\", "/").lstrip("/")
1745
+ return normalized or None
1746
+
1747
+ repo_root_resolved = repo_root.resolve()
1748
+ for candidate in self._file_change_path_candidates(raw_path, workspace_root):
1749
+ try:
1750
+ return candidate.relative_to(repo_root_resolved).as_posix()
1751
+ except ValueError:
1752
+ continue
1753
+ if Path(str(raw_path or "").strip()).is_absolute():
1754
+ return None
1755
+ normalized = str(raw_path or "").strip().replace("\\", "/").lstrip("/")
1756
+ return normalized or None
1757
+
1758
+ @staticmethod
1759
+ def _display_file_change_path(quest_root: Path, raw_path: str, *, relative_path: str | None = None) -> str:
1760
+ quest_root_resolved = quest_root.resolve()
1761
+ for candidate in [Path(str(raw_path or "").strip()).expanduser()]:
1762
+ for variant in (candidate, candidate.resolve(strict=False)):
1763
+ try:
1764
+ relative = variant.relative_to(quest_root_resolved)
1765
+ except ValueError:
1766
+ continue
1767
+ parts = relative.parts
1768
+ if len(parts) >= 3 and parts[0] == ".ds" and parts[1] == "worktrees":
1769
+ branch_root = parts[2]
1770
+ remainder = Path(*parts[3:]).as_posix() if len(parts) > 3 else ""
1771
+ return f"{branch_root}/{remainder}" if remainder else branch_root
1772
+ return relative.as_posix()
1773
+ if relative_path:
1774
+ return relative_path
1775
+ text = str(raw_path or "").strip()
1776
+ return Path(text).name or text
1777
+
1778
+ @staticmethod
1779
+ def _file_change_diff_unavailable(
1780
+ *,
1781
+ raw_path: str,
1782
+ run_id: str | None,
1783
+ event_id: str | None,
1784
+ message: str,
1785
+ ok: bool,
1786
+ base: str = "",
1787
+ head: str = "",
1788
+ branch: str | None = None,
1789
+ relative_path: str | None = None,
1790
+ display_path: str | None = None,
1791
+ ) -> dict:
1792
+ normalized_path = relative_path or str(raw_path or "").strip()
1793
+ return {
1794
+ "ok": ok,
1795
+ "available": False,
1796
+ "source": "unavailable",
1797
+ "run_id": run_id,
1798
+ "event_id": event_id,
1799
+ "branch": branch,
1800
+ "display_path": display_path or normalized_path,
1801
+ "base": base,
1802
+ "head": head,
1803
+ "path": normalized_path,
1804
+ "old_path": None,
1805
+ "status": "modified",
1806
+ "binary": False,
1807
+ "added": 0,
1808
+ "removed": 0,
1809
+ "lines": [],
1810
+ "truncated": False,
1811
+ "message": message,
1812
+ }
1813
+
1414
1814
  @staticmethod
1415
1815
  def _markdown_title(path: Path) -> str:
1416
1816
  content = read_text(path)
@@ -21,6 +21,7 @@ ROUTES: list[tuple[str, re.Pattern[str], str]] = [
21
21
  ("GET", re.compile(r"^/api/connectors/(?P<connector>[^/]+)/bindings$"), "connector_bindings"),
22
22
  ("POST", re.compile(r"^/api/connectors/(?P<connector>[^/]+)/inbound$"), "connector_inbound"),
23
23
  ("GET", re.compile(r"^/api/baselines$"), "baselines"),
24
+ ("DELETE", re.compile(r"^/api/baselines/(?P<baseline_id>[^/]+)$"), "baseline_delete"),
24
25
  ("GET", re.compile(r"^/api/quests$"), "quests"),
25
26
  ("GET", re.compile(r"^/api/quest-id/next$"), "quest_next_id"),
26
27
  ("POST", re.compile(r"^/api/quests$"), "quest_create"),
@@ -35,6 +36,8 @@ ROUTES: list[tuple[str, re.Pattern[str], str]] = [
35
36
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/events$"), "quest_events"),
36
37
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/artifacts$"), "quest_artifacts"),
37
38
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/workflow$"), "workflow"),
39
+ ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/layout$"), "quest_layout"),
40
+ ("POST", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/layout$"), "quest_layout_update"),
38
41
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/node-traces$"), "node_traces"),
39
42
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/node-traces/(?P<node_ref>.+)$"), "node_trace"),
40
43
  ("POST", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/stage-view$"), "stage_view"),
@@ -60,6 +63,7 @@ ROUTES: list[tuple[str, re.Pattern[str], str]] = [
60
63
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/commit$"), "git_commit"),
61
64
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/diff-file$"), "git_diff_file"),
62
65
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/commit-file$"), "git_commit_file"),
66
+ ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/operations/file-change-diff$"), "file_change_diff"),
63
67
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/runs$"), "runs"),
64
68
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/memory$"), "quest_memory"),
65
69
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/documents$"), "documents"),