@researai/deepscientist 1.5.7 → 1.5.8

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/README.md +4 -0
  2. package/bin/ds.js +220 -5
  3. package/docs/en/07_MEMORY_AND_MCP.md +40 -3
  4. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  5. package/docs/zh/07_MEMORY_AND_MCP.md +40 -3
  6. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  7. package/install.sh +34 -0
  8. package/package.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/src/deepscientist/__init__.py +1 -1
  11. package/src/deepscientist/acp/envelope.py +1 -0
  12. package/src/deepscientist/artifact/metrics.py +813 -80
  13. package/src/deepscientist/artifact/schemas.py +1 -0
  14. package/src/deepscientist/artifact/service.py +1101 -99
  15. package/src/deepscientist/bash_exec/monitor.py +1 -1
  16. package/src/deepscientist/bash_exec/service.py +17 -9
  17. package/src/deepscientist/channels/qq.py +17 -0
  18. package/src/deepscientist/channels/relay.py +16 -0
  19. package/src/deepscientist/config/models.py +6 -0
  20. package/src/deepscientist/config/service.py +70 -2
  21. package/src/deepscientist/daemon/api/handlers.py +284 -14
  22. package/src/deepscientist/daemon/api/router.py +1 -0
  23. package/src/deepscientist/daemon/app.py +291 -20
  24. package/src/deepscientist/gitops/diff.py +6 -10
  25. package/src/deepscientist/mcp/server.py +188 -39
  26. package/src/deepscientist/prompts/builder.py +51 -18
  27. package/src/deepscientist/quest/service.py +83 -34
  28. package/src/deepscientist/quest/stage_views.py +74 -29
  29. package/src/deepscientist/runners/codex.py +1 -1
  30. package/src/prompts/connectors/qq.md +1 -1
  31. package/src/prompts/contracts/shared_interaction.md +14 -0
  32. package/src/prompts/system.md +106 -32
  33. package/src/skills/analysis-campaign/SKILL.md +10 -14
  34. package/src/skills/baseline/SKILL.md +51 -38
  35. package/src/skills/baseline/references/baseline-plan-template.md +2 -0
  36. package/src/skills/decision/SKILL.md +12 -8
  37. package/src/skills/experiment/SKILL.md +28 -16
  38. package/src/skills/experiment/references/main-experiment-plan-template.md +2 -0
  39. package/src/skills/figure-polish/SKILL.md +1 -0
  40. package/src/skills/finalize/SKILL.md +3 -8
  41. package/src/skills/idea/SKILL.md +2 -8
  42. package/src/skills/intake-audit/SKILL.md +2 -8
  43. package/src/skills/rebuttal/SKILL.md +2 -8
  44. package/src/skills/review/SKILL.md +2 -8
  45. package/src/skills/scout/SKILL.md +2 -8
  46. package/src/skills/write/SKILL.md +52 -16
  47. package/src/skills/write/templates/DEEPSCIENTIST_NOTES.md +21 -0
  48. package/src/skills/write/templates/README.md +408 -0
  49. package/src/skills/write/templates/UPSTREAM_LICENSE.txt +21 -0
  50. package/src/skills/write/templates/aaai2026/README.md +534 -0
  51. package/src/skills/write/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  52. package/src/skills/write/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  53. package/src/skills/write/templates/aaai2026/aaai2026.bib +111 -0
  54. package/src/skills/write/templates/aaai2026/aaai2026.bst +1493 -0
  55. package/src/skills/write/templates/aaai2026/aaai2026.sty +315 -0
  56. package/src/skills/write/templates/acl/README.md +50 -0
  57. package/src/skills/write/templates/acl/acl.sty +312 -0
  58. package/src/skills/write/templates/acl/acl_latex.tex +377 -0
  59. package/src/skills/write/templates/acl/acl_lualatex.tex +101 -0
  60. package/src/skills/write/templates/acl/acl_natbib.bst +1940 -0
  61. package/src/skills/write/templates/acl/anthology.bib.txt +26 -0
  62. package/src/skills/write/templates/acl/custom.bib +70 -0
  63. package/src/skills/write/templates/acl/formatting.md +326 -0
  64. package/src/skills/write/templates/asplos2027/main.tex +459 -0
  65. package/src/skills/write/templates/asplos2027/references.bib +135 -0
  66. package/src/skills/write/templates/colm2025/README.md +3 -0
  67. package/src/skills/write/templates/colm2025/colm2025_conference.bib +11 -0
  68. package/src/skills/write/templates/colm2025/colm2025_conference.bst +1440 -0
  69. package/src/skills/write/templates/colm2025/colm2025_conference.sty +218 -0
  70. package/src/skills/write/templates/colm2025/colm2025_conference.tex +305 -0
  71. package/src/skills/write/templates/colm2025/fancyhdr.sty +485 -0
  72. package/src/skills/write/templates/colm2025/math_commands.tex +508 -0
  73. package/src/skills/write/templates/colm2025/natbib.sty +1246 -0
  74. package/src/skills/write/templates/iclr2026/fancyhdr.sty +485 -0
  75. package/src/skills/write/templates/iclr2026/iclr2026_conference.bib +24 -0
  76. package/src/skills/write/templates/iclr2026/iclr2026_conference.bst +1440 -0
  77. package/src/skills/write/templates/iclr2026/iclr2026_conference.sty +246 -0
  78. package/src/skills/write/templates/iclr2026/iclr2026_conference.tex +414 -0
  79. package/src/skills/write/templates/iclr2026/math_commands.tex +508 -0
  80. package/src/skills/write/templates/iclr2026/natbib.sty +1246 -0
  81. package/src/skills/write/templates/icml2026/algorithm.sty +79 -0
  82. package/src/skills/write/templates/icml2026/algorithmic.sty +201 -0
  83. package/src/skills/write/templates/icml2026/example_paper.bib +75 -0
  84. package/src/skills/write/templates/icml2026/example_paper.tex +662 -0
  85. package/src/skills/write/templates/icml2026/fancyhdr.sty +864 -0
  86. package/src/skills/write/templates/icml2026/icml2026.bst +1443 -0
  87. package/src/skills/write/templates/icml2026/icml2026.sty +767 -0
  88. package/src/skills/write/templates/neurips2025/Makefile +36 -0
  89. package/src/skills/write/templates/neurips2025/extra_pkgs.tex +53 -0
  90. package/src/skills/write/templates/neurips2025/main.tex +38 -0
  91. package/src/skills/write/templates/neurips2025/neurips.sty +382 -0
  92. package/src/skills/write/templates/nsdi2027/main.tex +426 -0
  93. package/src/skills/write/templates/nsdi2027/references.bib +151 -0
  94. package/src/skills/write/templates/nsdi2027/usenix-2020-09.sty +83 -0
  95. package/src/skills/write/templates/osdi2026/main.tex +429 -0
  96. package/src/skills/write/templates/osdi2026/references.bib +150 -0
  97. package/src/skills/write/templates/osdi2026/usenix-2020-09.sty +83 -0
  98. package/src/skills/write/templates/sosp2026/main.tex +532 -0
  99. package/src/skills/write/templates/sosp2026/references.bib +148 -0
  100. package/src/tui/package.json +1 -1
  101. package/src/ui/dist/assets/{AiManusChatView-BS3V4ZOk.js → AiManusChatView-m2FNtwbn.js} +110 -14
  102. package/src/ui/dist/assets/{AnalysisPlugin-DLPXQsmr.js → AnalysisPlugin-BMTF8EGL.js} +1 -1
  103. package/src/ui/dist/assets/{AutoFigurePlugin-C-Fr9knQ.js → AutoFigurePlugin-DxPdMUNb.js} +5 -5
  104. package/src/ui/dist/assets/{CliPlugin-Dd8AHzFg.js → CliPlugin-BEOWgxCI.js} +9 -9
  105. package/src/ui/dist/assets/{CodeEditorPlugin-Dg-RepTl.js → CodeEditorPlugin-BCXvjqmb.js} +8 -8
  106. package/src/ui/dist/assets/{CodeViewerPlugin-D2J_3nyt.js → CodeViewerPlugin-DaJcy3nD.js} +5 -5
  107. package/src/ui/dist/assets/{DocViewerPlugin-ChRLLKNb.js → DocViewerPlugin-ByfeIq4K.js} +3 -3
  108. package/src/ui/dist/assets/{GitDiffViewerPlugin-DgHfcved.js → GitDiffViewerPlugin-Cksf3VZ-.js} +830 -86
  109. package/src/ui/dist/assets/{ImageViewerPlugin-C89GZMBy.js → ImageViewerPlugin-CFz-OsTS.js} +5 -5
  110. package/src/ui/dist/assets/{LabCopilotPanel-BUfIwUcb.js → LabCopilotPanel-CJ1cJzoX.js} +10 -10
  111. package/src/ui/dist/assets/{LabPlugin-zvUmQUMq.js → LabPlugin-BF3dVJwa.js} +1 -1
  112. package/src/ui/dist/assets/{LatexPlugin-C1SSNuWp.js → LatexPlugin-DDkwZ6Sj.js} +7 -7
  113. package/src/ui/dist/assets/{MarkdownViewerPlugin-D2Mf5tU5.js → MarkdownViewerPlugin-HAuvurcT.js} +4 -4
  114. package/src/ui/dist/assets/{MarketplacePlugin-CF4LgiS2.js → MarketplacePlugin-BtoTYy2C.js} +3 -3
  115. package/src/ui/dist/assets/{index-Be0NAmh8.js → NotebookEditor-CSJYx7b-.js} +12 -155
  116. package/src/ui/dist/assets/{NotebookEditor-BM7Bgwlv.js → NotebookEditor-DQgRezm_.js} +1 -1
  117. package/src/ui/dist/assets/{PdfLoader-Bc5qfD-Z.js → PdfLoader-DPa_-fv6.js} +1 -1
  118. package/src/ui/dist/assets/{PdfMarkdownPlugin-sh1-IRcp.js → PdfMarkdownPlugin-BZpXOEjm.js} +3 -3
  119. package/src/ui/dist/assets/{PdfViewerPlugin-C_a7CpWG.js → PdfViewerPlugin-BT8a6wGR.js} +10 -10
  120. package/src/ui/dist/assets/{SearchPlugin-L4z3HcLf.js → SearchPlugin-D_blveZi.js} +1 -1
  121. package/src/ui/dist/assets/{Stepper-Dk4aQ3fN.js → Stepper-DH2k75Vo.js} +1 -1
  122. package/src/ui/dist/assets/{TextViewerPlugin-BsNtlKVo.js → TextViewerPlugin-Btx0M3hX.js} +4 -4
  123. package/src/ui/dist/assets/{VNCViewer-BpeDcZ5_.js → VNCViewer-DImJO4rO.js} +9 -9
  124. package/src/ui/dist/assets/{bibtex-C4QI-bbj.js → bibtex-B-Hqu0Sg.js} +1 -1
  125. package/src/ui/dist/assets/{code-DuMINRsg.js → code-BUfXGJSl.js} +1 -1
  126. package/src/ui/dist/assets/{file-content-C3N-432K.js → file-content-VqamwI3X.js} +1 -1
  127. package/src/ui/dist/assets/{file-diff-panel-CffQ4ZMg.js → file-diff-panel-C_wOoS7a.js} +1 -1
  128. package/src/ui/dist/assets/{file-socket-CRH59PCO.js → file-socket-D2bTuMVP.js} +1 -1
  129. package/src/ui/dist/assets/{file-utils-vYGtW2mI.js → file-utils--zJCPN1i.js} +1 -1
  130. package/src/ui/dist/assets/{image-DBVGaooo.js → image-BZkGJ4mM.js} +1 -1
  131. package/src/ui/dist/assets/{index-DjSFDmgB.js → index-CxkvSeKw.js} +2 -2
  132. package/src/ui/dist/assets/{index-BpjYH9Vg.js → index-D9QIGcmc.js} +1 -1
  133. package/src/ui/dist/assets/{index-Do9N28uB.css → index-DXZ1daiJ.css} +163 -34
  134. package/src/ui/dist/assets/index-DdRW6RMJ.js +159 -0
  135. package/src/ui/dist/assets/{index-B1P6hQRJ.js → index-DjggJovS.js} +3029 -1780
  136. package/src/ui/dist/assets/{message-square-BsPDBhiY.js → message-square-FUIPIhU2.js} +1 -1
  137. package/src/ui/dist/assets/{monaco-BTkdPojV.js → monaco-DHMc7kKM.js} +1 -1
  138. package/src/ui/dist/assets/{popover-cWjCk-vc.js → popover-B85oCgCS.js} +1 -1
  139. package/src/ui/dist/assets/{project-sync-CXn530xb.js → project-sync-DOMCcPac.js} +1 -1
  140. package/src/ui/dist/assets/{sigma-04Jr12jg.js → sigma-BO2rQrl3.js} +1 -1
  141. package/src/ui/dist/assets/{tooltip-BdVDl0G5.js → tooltip-B1OspAkx.js} +1 -1
  142. package/src/ui/dist/assets/{trash-CB_GlQyC.js → trash-BsVEH_dV.js} +1 -1
  143. package/src/ui/dist/assets/{useCliAccess-BL932NwS.js → useCliAccess-b8L6JuZm.js} +1 -1
  144. package/src/ui/dist/assets/{useFileDiffOverlay-B2WK7Tvq.js → useFileDiffOverlay-BY7uA9hV.js} +1 -1
  145. package/src/ui/dist/assets/{wrap-text-YC68g12z.js → wrap-text-BwyVuUIK.js} +1 -1
  146. package/src/ui/dist/assets/{zoom-out-C0RJvFiJ.js → zoom-out-RDpLugQP.js} +1 -1
  147. package/src/ui/dist/index.html +5 -2
  148. /package/src/ui/dist/assets/{index-CccQYZjX.css → NotebookEditor-CccQYZjX.css} +0 -0
@@ -448,7 +448,7 @@ def run_monitor(session_dir: Path) -> int:
448
448
  _terminate_process(process, process_group_id)
449
449
  stop_requested = True
450
450
 
451
- if session_kind == "terminal" and output_fd is not None and process.poll() is None:
451
+ if output_fd is not None and process.poll() is None:
452
452
  cursor_payload = read_json(input_cursor_path, {}) or {}
453
453
  offset = int(cursor_payload.get("offset") or 0)
454
454
  input_entries = read_jsonl(input_path)
@@ -555,6 +555,7 @@ class BashExecService:
555
555
  before_seq: int | None = None,
556
556
  after_seq: int | None = None,
557
557
  order: str = "asc",
558
+ prefer_visible: bool = False,
558
559
  ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
559
560
  if not self.meta_path(quest_root, bash_id).exists():
560
561
  raise FileNotFoundError(f"Unknown bash session `{bash_id}`.")
@@ -579,9 +580,16 @@ class BashExecService:
579
580
  entries = [entry for entry in entries if int(entry.get("seq") or 0) > normalized_after]
580
581
  if normalized_before is not None:
581
582
  entries = [entry for entry in entries if int(entry.get("seq") or 0) < normalized_before]
583
+ selection_pool = entries
584
+ if prefer_visible:
585
+ visible_entries = [
586
+ entry for entry in entries if str(entry.get("stream") or "") not in {"system", "prompt"}
587
+ ]
588
+ if visible_entries:
589
+ selection_pool = visible_entries
582
590
  normalized_limit = max(1, limit)
583
- truncated = len(entries) > normalized_limit
584
- selected = entries[-normalized_limit:]
591
+ truncated = len(selection_pool) > normalized_limit
592
+ selected = selection_pool[-normalized_limit:]
585
593
  if order == "desc":
586
594
  selected = list(reversed(selected))
587
595
  tail_start_seq = int(selected[0].get("seq") or 0) if selected else None
@@ -1025,15 +1033,14 @@ class BashExecService:
1025
1033
  if not normalized_data:
1026
1034
  raise ValueError("terminal_input_required")
1027
1035
  session = self.reconcile_session(quest_root, bash_id)
1028
- if _normalize_string(session.get("kind")).lower() != "terminal":
1029
- raise ValueError("not_terminal_session")
1030
1036
  status = _normalize_string(session.get("status")).lower()
1031
1037
  if status in TERMINAL_STATUSES:
1032
1038
  raise ValueError("terminal_session_inactive")
1033
1039
  runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
1034
- if runtime is None:
1040
+ if runtime is None and _normalize_string(session.get("kind")).lower() == "terminal":
1035
1041
  raise ValueError("terminal_runtime_inactive")
1036
- runtime.write_input(normalized_data)
1042
+ if runtime is not None:
1043
+ runtime.write_input(normalized_data)
1037
1044
 
1038
1045
  entry = {
1039
1046
  "input_id": generate_id("tin"),
@@ -1081,9 +1088,10 @@ class BashExecService:
1081
1088
  return True
1082
1089
 
1083
1090
  def issue_terminal_attach_token(self, quest_root: Path, bash_id: str, *, ttl_seconds: int = 60) -> dict[str, Any]:
1084
- runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
1085
- if runtime is None:
1086
- raise ValueError("terminal_runtime_inactive")
1091
+ session = self.reconcile_session(quest_root, bash_id)
1092
+ status = _normalize_string(session.get("status")).lower()
1093
+ if status in TERMINAL_STATUSES:
1094
+ raise ValueError("terminal_session_inactive")
1087
1095
  token = self._terminal_runtime_manager.issue_attach_token(
1088
1096
  quest_root,
1089
1097
  bash_id,
@@ -293,6 +293,20 @@ class QQRelayChannel(BaseChannel):
293
293
  def matches_profile(item: dict[str, Any], profile_id: str) -> bool:
294
294
  item_profile_id = str(item.get("profile_id") or "").strip()
295
295
  return item_profile_id == profile_id or (not item_profile_id and len(profiles) == 1)
296
+
297
+ def count_profile_records(path: Path, profile_id: str) -> int:
298
+ total = 0
299
+ for raw in read_jsonl(path):
300
+ if not isinstance(raw, dict):
301
+ continue
302
+ record_profile_id = str(raw.get("profile_id") or "").strip()
303
+ if not record_profile_id:
304
+ parsed = parse_conversation_id(str(raw.get("conversation_id") or "").strip())
305
+ record_profile_id = str((parsed or {}).get("profile_id") or "").strip()
306
+ if record_profile_id == profile_id or (not record_profile_id and len(profiles) == 1):
307
+ total += 1
308
+ return total
309
+
296
310
  profile_snapshots = []
297
311
  for profile in profiles:
298
312
  profile_id = str(profile.get("profile_id") or "").strip()
@@ -342,6 +356,9 @@ class QQRelayChannel(BaseChannel):
342
356
  "discovered_targets": profile_targets,
343
357
  "recent_conversations": profile_recent_conversations,
344
358
  "bindings": profile_bindings,
359
+ "inbox_count": count_profile_records(self.inbox_path, profile_id),
360
+ "outbox_count": count_profile_records(self.outbox_path, profile_id),
361
+ "ignored_count": count_profile_records(self.ignored_path, profile_id),
345
362
  "target_count": len(profile_targets),
346
363
  "binding_count": len(profile_bindings),
347
364
  "last_error": gateway_state.get("last_error") if isinstance(gateway_state, dict) else None,
@@ -282,6 +282,19 @@ class GenericRelayChannel(BaseChannel):
282
282
  item_profile_id = str(item.get("profile_id") or "").strip()
283
283
  return item_profile_id == profile_id or (not item_profile_id and len(profiles) == 1)
284
284
 
285
+ def count_profile_records(path: Path, profile_id: str) -> int:
286
+ total = 0
287
+ for raw in read_jsonl(path):
288
+ if not isinstance(raw, dict):
289
+ continue
290
+ record_profile_id = str(raw.get("profile_id") or "").strip()
291
+ if not record_profile_id:
292
+ parsed = parse_conversation_id(str(raw.get("conversation_id") or "").strip())
293
+ record_profile_id = str((parsed or {}).get("profile_id") or "").strip()
294
+ if record_profile_id == profile_id or (not record_profile_id and len(profiles) == 1):
295
+ total += 1
296
+ return total
297
+
285
298
  profile_snapshots = []
286
299
  for profile in profiles:
287
300
  profile_id = str(profile.get("profile_id") or "").strip()
@@ -322,6 +335,9 @@ class GenericRelayChannel(BaseChannel):
322
335
  "discovered_targets": profile_targets,
323
336
  "recent_conversations": profile_recent_conversations,
324
337
  "bindings": profile_bindings,
338
+ "inbox_count": count_profile_records(self.inbox_path, profile_id),
339
+ "outbox_count": count_profile_records(self.outbox_path, profile_id),
340
+ "ignored_count": count_profile_records(self.ignored_path, profile_id),
325
341
  "target_count": len(profile_targets),
326
342
  "binding_count": len(profile_bindings),
327
343
  "last_error": profile_runtime_state.get("last_error") if isinstance(profile_runtime_state, dict) else None,
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  CONFIG_NAMES = ("config", "runners", "connectors", "plugins", "mcp_servers")
7
7
  REQUIRED_CONFIG_NAMES = ("config", "runners", "connectors")
8
8
  OPTIONAL_CONFIG_NAMES = ("plugins", "mcp_servers")
9
+ SYSTEM_CONNECTOR_NAMES = ("qq", "telegram", "discord", "slack", "feishu", "whatsapp", "lingzhu")
9
10
 
10
11
 
11
12
  @dataclass(frozen=True)
@@ -20,6 +21,10 @@ def config_filename(name: str) -> str:
20
21
  return f"{name}.yaml"
21
22
 
22
23
 
24
+ def default_system_enabled_connectors() -> dict[str, bool]:
25
+ return {name: name == "qq" for name in SYSTEM_CONNECTOR_NAMES}
26
+
27
+
23
28
  def default_config(home: Path) -> dict:
24
29
  return {
25
30
  "home": str(home),
@@ -65,6 +70,7 @@ def default_config(home: Path) -> dict:
65
70
  "auto_ack": True,
66
71
  "milestone_push": True,
67
72
  "direct_chat_enabled": True,
73
+ "system_enabled": default_system_enabled_connectors(),
68
74
  },
69
75
  "cloud": {
70
76
  "enabled": False,
@@ -37,6 +37,7 @@ from .models import (
37
37
  OPTIONAL_CONFIG_NAMES,
38
38
  REQUIRED_CONFIG_NAMES,
39
39
  ConfigFileInfo,
40
+ SYSTEM_CONNECTOR_NAMES,
40
41
  config_filename,
41
42
  default_payload,
42
43
  )
@@ -95,6 +96,33 @@ class ConfigManager:
95
96
  def load_runners_config(self) -> dict:
96
97
  return apply_runners_runtime_overrides(self.load_named_normalized("runners"))
97
98
 
99
+ def load_runtime_config(self) -> dict:
100
+ return self.load_named_normalized("config")
101
+
102
+ def system_connector_gates(self) -> dict[str, bool]:
103
+ config = self.load_runtime_config()
104
+ connectors = config.get("connectors") if isinstance(config.get("connectors"), dict) else {}
105
+ system_enabled = connectors.get("system_enabled") if isinstance(connectors.get("system_enabled"), dict) else {}
106
+ return {
107
+ name: self._coerce_bool(system_enabled.get(name), default=name == "qq")
108
+ for name in SYSTEM_CONNECTOR_NAMES
109
+ }
110
+
111
+ def system_enabled_connector_names(self) -> list[str]:
112
+ gates = self.system_connector_gates()
113
+ return [name for name in SYSTEM_CONNECTOR_NAMES if gates.get(name, False)]
114
+
115
+ def is_connector_system_enabled(self, name: str) -> bool:
116
+ normalized = str(name or "").strip().lower()
117
+ if not normalized:
118
+ return False
119
+ if normalized == "local":
120
+ return True
121
+ gates = self.system_connector_gates()
122
+ if normalized in gates:
123
+ return gates[normalized]
124
+ return True
125
+
98
126
  def load_named_text(self, name: str, create_optional: bool = False) -> str:
99
127
  path = self.path_for(name)
100
128
  if create_optional and name in OPTIONAL_CONFIG_NAMES and not path.exists():
@@ -1047,6 +1075,12 @@ Use **Test** when the file exposes runtime dependencies.
1047
1075
  checked_at = utc_now()
1048
1076
  binary = str(config.get("binary") or "codex").strip() or "codex"
1049
1077
  resolved_binary = resolve_runner_binary(binary, runner_name="codex")
1078
+ raw_reasoning_effort = config.get("model_reasoning_effort")
1079
+ reasoning_effort = (
1080
+ str(raw_reasoning_effort).strip()
1081
+ if raw_reasoning_effort is not None and str(raw_reasoning_effort).strip()
1082
+ else ("xhigh" if raw_reasoning_effort is None else None)
1083
+ )
1050
1084
  details: dict[str, object] = {
1051
1085
  "binary": binary,
1052
1086
  "resolved_binary": resolved_binary,
@@ -1054,7 +1088,7 @@ Use **Test** when the file exposes runtime dependencies.
1054
1088
  "model": str(config.get("model") or "gpt-5.4"),
1055
1089
  "approval_policy": str(config.get("approval_policy") or "on-request"),
1056
1090
  "sandbox_mode": str(config.get("sandbox_mode") or "workspace-write"),
1057
- "reasoning_effort": str(config.get("model_reasoning_effort") or "xhigh"),
1091
+ "reasoning_effort": reasoning_effort,
1058
1092
  "checked_at": checked_at,
1059
1093
  }
1060
1094
  if not resolved_binary:
@@ -1087,7 +1121,6 @@ Use **Test** when the file exposes runtime dependencies.
1087
1121
  approval_policy = str(config.get("approval_policy") or "on-request").strip()
1088
1122
  if approval_policy:
1089
1123
  command.extend(["-c", f'approval_policy="{approval_policy}"'])
1090
- reasoning_effort = str(config.get("model_reasoning_effort") or "xhigh").strip()
1091
1124
  if reasoning_effort:
1092
1125
  command.extend(["-c", f'model_reasoning_effort="{reasoning_effort}"'])
1093
1126
  sandbox_mode = str(config.get("sandbox_mode") or "workspace-write").strip()
@@ -1378,6 +1411,8 @@ Use **Test** when the file exposes runtime dependencies.
1378
1411
  normalized = self._deep_merge(defaults, payload)
1379
1412
  bootstrap = normalized.get("bootstrap") if isinstance(normalized.get("bootstrap"), dict) else {}
1380
1413
  raw_bootstrap = payload.get("bootstrap") if isinstance(payload.get("bootstrap"), dict) else {}
1414
+ connectors = normalized.get("connectors") if isinstance(normalized.get("connectors"), dict) else {}
1415
+ raw_connectors = payload.get("connectors") if isinstance(payload.get("connectors"), dict) else {}
1381
1416
  default_locale = str(defaults.get("default_locale") or "").strip()
1382
1417
  current_locale = str(normalized.get("default_locale") or "").strip()
1383
1418
  locale_source = str(raw_bootstrap.get("locale_source") or "").strip().lower()
@@ -1401,6 +1436,25 @@ Use **Test** when the file exposes runtime dependencies.
1401
1436
  bootstrap["locale_initialized_at"] = bootstrap.get("locale_initialized_at")
1402
1437
  bootstrap["locale_initialized_browser_locale"] = bootstrap.get("locale_initialized_browser_locale")
1403
1438
  normalized["bootstrap"] = bootstrap
1439
+ raw_system_enabled = raw_connectors.get("system_enabled") if isinstance(raw_connectors.get("system_enabled"), dict) else {}
1440
+ default_system_enabled = (
1441
+ defaults.get("connectors", {}).get("system_enabled")
1442
+ if isinstance(defaults.get("connectors"), dict)
1443
+ else {}
1444
+ )
1445
+ current_system_enabled = (
1446
+ connectors.get("system_enabled")
1447
+ if isinstance(connectors.get("system_enabled"), dict)
1448
+ else {}
1449
+ )
1450
+ connectors["system_enabled"] = {
1451
+ name: self._coerce_bool(
1452
+ raw_system_enabled.get(name, current_system_enabled.get(name)),
1453
+ default=bool(default_system_enabled.get(name, False)),
1454
+ )
1455
+ for name in SYSTEM_CONNECTOR_NAMES
1456
+ }
1457
+ normalized["connectors"] = connectors
1404
1458
  return normalized
1405
1459
 
1406
1460
  @staticmethod
@@ -1413,6 +1467,20 @@ Use **Test** when the file exposes runtime dependencies.
1413
1467
  return False
1414
1468
  return abs(initial - 1.0) < 1e-9 and abs(multiplier - 2.0) < 1e-9 and abs(max_backoff - 8.0) < 1e-9
1415
1469
 
1470
+ @staticmethod
1471
+ def _coerce_bool(value: object, *, default: bool = False) -> bool:
1472
+ if value is None:
1473
+ return default
1474
+ if isinstance(value, bool):
1475
+ return value
1476
+ if isinstance(value, str):
1477
+ normalized = value.strip().lower()
1478
+ if normalized in {"1", "true", "yes", "on", "y"}:
1479
+ return True
1480
+ if normalized in {"0", "false", "no", "off", "n", ""}:
1481
+ return False
1482
+ return bool(value)
1483
+
1416
1484
  def _normalize_plugins_payload(self, payload: dict) -> dict:
1417
1485
  normalized = deepcopy(payload)
1418
1486
  if "load_paths" not in normalized and isinstance(normalized.get("search_paths"), list):
@@ -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",
@@ -248,12 +248,15 @@ npm --prefix src/ui run build</pre>
248
248
  else []
249
249
  )
250
250
  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
- }
251
+ if force_connector_rebind_raw is None:
252
+ force_connector_rebind = True
253
+ else:
254
+ force_connector_rebind = bool(force_connector_rebind_raw) and str(force_connector_rebind_raw).strip().lower() not in {
255
+ "0",
256
+ "false",
257
+ "no",
258
+ "off",
259
+ }
257
260
  requested_baseline_ref = body.get("requested_baseline_ref")
258
261
  startup_contract = body.get("startup_contract")
259
262
  auto_start = body.get("auto_start") is True
@@ -645,8 +648,6 @@ npm --prefix src/ui run build</pre>
645
648
  session = self.app.bash_exec_service.get_session(quest_root, session_id)
646
649
  except FileNotFoundError:
647
650
  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
651
  if str(session.get("status") or "").lower() in {"completed", "failed", "terminated"}:
651
652
  return 409, {"ok": False, "message": "terminal_session_inactive", "session": session}
652
653
  try:
@@ -747,6 +748,12 @@ npm --prefix src/ui run build</pre>
747
748
  def git_branches(self, quest_id: str) -> dict:
748
749
  quest_root = self._fresh_quest_service()._quest_root(quest_id)
749
750
  payload = list_branch_canvas(quest_root, quest_id=quest_id)
751
+ research_state = self.app.quest_service.read_research_state(quest_root)
752
+ active_workspace_branch = str(research_state.get("current_workspace_branch") or "").strip() or None
753
+ research_head_branch = str(research_state.get("research_head_branch") or "").strip() or None
754
+ payload["active_workspace_ref"] = active_workspace_branch
755
+ payload["research_head_ref"] = research_head_branch
756
+ payload["workspace_mode"] = str(research_state.get("workspace_mode") or "quest").strip() or "quest"
750
757
  try:
751
758
  branch_summary = self.app.artifact_service.list_research_branches(quest_root)
752
759
  except Exception:
@@ -761,6 +768,8 @@ npm --prefix src/ui run build</pre>
761
768
  if not ref:
762
769
  continue
763
770
  summary = branch_summary_by_name.get(ref)
771
+ node["active_workspace"] = ref == active_workspace_branch
772
+ node["research_head"] = ref == research_head_branch
764
773
  if not isinstance(summary, dict):
765
774
  continue
766
775
  node["branch_no"] = summary.get("branch_no")
@@ -818,6 +827,126 @@ npm --prefix src/ui run build</pre>
818
827
  quest_root = self._fresh_quest_service()._quest_root(quest_id)
819
828
  return diff_file_between_refs(quest_root, base=base, head=head, path=file_path)
820
829
 
830
+ def file_change_diff(self, quest_id: str, path: str) -> dict:
831
+ query = self.parse_query(path)
832
+ run_id = ((query.get("run_id") or [""])[0] or "").strip()
833
+ event_id = ((query.get("event_id") or [""])[0] or "").strip() or None
834
+ raw_path = ((query.get("path") or [""])[0] or "").strip()
835
+ if not run_id or not raw_path:
836
+ return self._file_change_diff_unavailable(
837
+ raw_path=raw_path,
838
+ run_id=run_id or None,
839
+ event_id=event_id,
840
+ message="`run_id` and `path` are required.",
841
+ ok=False,
842
+ )
843
+
844
+ quest_root = self._fresh_quest_service()._quest_root(quest_id)
845
+ run_artifact = read_json(quest_root / ".ds" / "runs" / run_id / "artifact.json", default={})
846
+ if not isinstance(run_artifact, dict):
847
+ return self._file_change_diff_unavailable(
848
+ raw_path=raw_path,
849
+ run_id=run_id,
850
+ event_id=event_id,
851
+ message="Historical patch unavailable. Run artifact metadata is missing.",
852
+ ok=False,
853
+ )
854
+
855
+ record = run_artifact.get("record") if isinstance(run_artifact.get("record"), dict) else {}
856
+ checkpoint = run_artifact.get("checkpoint") if isinstance(run_artifact.get("checkpoint"), dict) else {}
857
+ base = str(record.get("head_commit") or "").strip()
858
+ head = str(checkpoint.get("head") or "").strip()
859
+ branch = str(record.get("branch") or "").strip() or None
860
+ workspace_root = self._file_change_workspace_root(run_artifact, record)
861
+ relative_path = None
862
+ display_path = None
863
+
864
+ if not base or not head:
865
+ relative_path = self._relative_file_change_path(None, raw_path, workspace_root)
866
+ display_path = self._display_file_change_path(quest_root, raw_path, relative_path=relative_path)
867
+ return self._file_change_diff_unavailable(
868
+ raw_path=raw_path,
869
+ run_id=run_id,
870
+ event_id=event_id,
871
+ base=base,
872
+ head=head,
873
+ branch=branch,
874
+ relative_path=relative_path,
875
+ display_path=display_path,
876
+ message="Historical patch unavailable. Run artifact metadata is missing the recorded commit range.",
877
+ ok=True,
878
+ )
879
+
880
+ repo_root = self._resolve_git_repo_root_for_file_change(raw_path, workspace_root)
881
+ relative_path = self._relative_file_change_path(repo_root, raw_path, workspace_root)
882
+ display_path = self._display_file_change_path(quest_root, raw_path, relative_path=relative_path)
883
+
884
+ if repo_root is None or not relative_path:
885
+ return self._file_change_diff_unavailable(
886
+ raw_path=raw_path,
887
+ run_id=run_id,
888
+ event_id=event_id,
889
+ base=base,
890
+ head=head,
891
+ branch=branch,
892
+ relative_path=relative_path,
893
+ display_path=display_path,
894
+ message="Historical patch unavailable. DeepScientist could not map this file to a git worktree.",
895
+ ok=True,
896
+ )
897
+
898
+ try:
899
+ diff = diff_file_between_refs(repo_root, base=base, head=head, path=relative_path)
900
+ except Exception:
901
+ return self._file_change_diff_unavailable(
902
+ raw_path=raw_path,
903
+ run_id=run_id,
904
+ event_id=event_id,
905
+ base=base,
906
+ head=head,
907
+ branch=branch,
908
+ relative_path=relative_path,
909
+ display_path=display_path,
910
+ message=(
911
+ "Historical patch unavailable. The saved run checkpoint does not match the git repository "
912
+ "that currently owns this file."
913
+ ),
914
+ ok=True,
915
+ )
916
+
917
+ if (
918
+ not diff.get("binary")
919
+ and not diff.get("lines")
920
+ and int(diff.get("added") or 0) == 0
921
+ and int(diff.get("removed") or 0) == 0
922
+ ):
923
+ return self._file_change_diff_unavailable(
924
+ raw_path=raw_path,
925
+ run_id=run_id,
926
+ event_id=event_id,
927
+ base=base,
928
+ head=head,
929
+ branch=branch,
930
+ relative_path=relative_path,
931
+ display_path=display_path,
932
+ message=(
933
+ "Historical patch unavailable. This event only preserved file-level metadata or the edit "
934
+ "did not land in the run's final checkpoint."
935
+ ),
936
+ ok=True,
937
+ )
938
+
939
+ return {
940
+ **diff,
941
+ "available": True,
942
+ "source": "run_range",
943
+ "display_path": display_path or relative_path,
944
+ "run_id": run_id,
945
+ "event_id": event_id,
946
+ "branch": branch,
947
+ "message": None,
948
+ }
949
+
821
950
  def git_commit_file(self, quest_id: str, path: str) -> dict:
822
951
  query = self.parse_query(path)
823
952
  sha = ((query.get("sha") or [""])[0] or "").strip()
@@ -1207,6 +1336,16 @@ npm --prefix src/ui run build</pre>
1207
1336
  "ok": False,
1208
1337
  "message": str(exc),
1209
1338
  }
1339
+ raw_reasoning_effort = (
1340
+ body.get("model_reasoning_effort")
1341
+ if "model_reasoning_effort" in body
1342
+ else runner_cfg.get("model_reasoning_effort")
1343
+ )
1344
+ reasoning_effort = (
1345
+ str(raw_reasoning_effort).strip()
1346
+ if raw_reasoning_effort is not None and str(raw_reasoning_effort).strip()
1347
+ else ("xhigh" if raw_reasoning_effort is None else None)
1348
+ )
1210
1349
  request = RunRequest(
1211
1350
  quest_id=quest_id,
1212
1351
  quest_root=quest_root,
@@ -1218,8 +1357,7 @@ npm --prefix src/ui run build</pre>
1218
1357
  approval_policy=runner_cfg.get("approval_policy", "on-request"),
1219
1358
  sandbox_mode=runner_cfg.get("sandbox_mode", "workspace-write"),
1220
1359
  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"),
1360
+ reasoning_effort=reasoning_effort,
1223
1361
  )
1224
1362
  result = runner.run(request)
1225
1363
  if result.output_text:
@@ -1355,6 +1493,8 @@ npm --prefix src/ui run build</pre>
1355
1493
  result = self.app.config_manager.save_named_payload(name, body["structured"])
1356
1494
  else:
1357
1495
  result = self.app.config_manager.save_named_text(name, body.get("content", ""))
1496
+ if result.get("ok") and name == "config":
1497
+ result["runtime_reload"] = self.app.reload_runtime_config()
1358
1498
  if result.get("ok") and name == "connectors":
1359
1499
  result["runtime_reload"] = self.app.reload_connectors_config()
1360
1500
  if result.get("ok") and name == "runners":
@@ -1411,6 +1551,136 @@ npm --prefix src/ui run build</pre>
1411
1551
  return {}
1412
1552
  return json.loads(raw.decode("utf-8"))
1413
1553
 
1554
+ @staticmethod
1555
+ def _file_change_workspace_root(run_artifact: dict, record: dict) -> Path | None:
1556
+ workspace_root = str(run_artifact.get("workspace_root") or record.get("workspace_root") or "").strip()
1557
+ if not workspace_root:
1558
+ return None
1559
+ return Path(workspace_root).expanduser()
1560
+
1561
+ @staticmethod
1562
+ def _file_change_path_candidates(raw_path: str, workspace_root: Path | None) -> list[Path]:
1563
+ text = str(raw_path or "").strip()
1564
+ if not text:
1565
+ return []
1566
+ raw_candidate = Path(text).expanduser()
1567
+ candidates: list[Path] = []
1568
+ if raw_candidate.is_absolute():
1569
+ candidates.append(raw_candidate)
1570
+ elif workspace_root is not None:
1571
+ candidates.append(workspace_root / raw_candidate)
1572
+ else:
1573
+ candidates.append(raw_candidate)
1574
+ if workspace_root is not None:
1575
+ candidates.append(workspace_root)
1576
+
1577
+ resolved: list[Path] = []
1578
+ seen: set[str] = set()
1579
+ for candidate in candidates:
1580
+ for variant in (candidate, candidate.resolve(strict=False)):
1581
+ key = str(variant)
1582
+ if key in seen:
1583
+ continue
1584
+ seen.add(key)
1585
+ resolved.append(variant)
1586
+ return resolved
1587
+
1588
+ @staticmethod
1589
+ def _git_probe_root(candidate: Path) -> Path:
1590
+ if candidate.exists():
1591
+ return candidate if candidate.is_dir() else candidate.parent
1592
+ for parent in candidate.parents:
1593
+ if parent.exists():
1594
+ return parent
1595
+ return candidate.parent
1596
+
1597
+ def _resolve_git_repo_root_for_file_change(self, raw_path: str, workspace_root: Path | None) -> Path | None:
1598
+ for candidate in self._file_change_path_candidates(raw_path, workspace_root):
1599
+ probe_root = self._git_probe_root(candidate)
1600
+ if not str(probe_root).strip():
1601
+ continue
1602
+ result = run_command(["git", "rev-parse", "--show-toplevel"], cwd=probe_root, check=False)
1603
+ if result.returncode != 0:
1604
+ continue
1605
+ top_level = result.stdout.strip()
1606
+ if top_level:
1607
+ return Path(top_level).resolve()
1608
+ return None
1609
+
1610
+ def _relative_file_change_path(self, repo_root: Path | None, raw_path: str, workspace_root: Path | None) -> str | None:
1611
+ if repo_root is None:
1612
+ if Path(str(raw_path or "").strip()).is_absolute():
1613
+ return None
1614
+ normalized = str(raw_path or "").strip().replace("\\", "/").lstrip("/")
1615
+ return normalized or None
1616
+
1617
+ repo_root_resolved = repo_root.resolve()
1618
+ for candidate in self._file_change_path_candidates(raw_path, workspace_root):
1619
+ try:
1620
+ return candidate.relative_to(repo_root_resolved).as_posix()
1621
+ except ValueError:
1622
+ continue
1623
+ if Path(str(raw_path or "").strip()).is_absolute():
1624
+ return None
1625
+ normalized = str(raw_path or "").strip().replace("\\", "/").lstrip("/")
1626
+ return normalized or None
1627
+
1628
+ @staticmethod
1629
+ def _display_file_change_path(quest_root: Path, raw_path: str, *, relative_path: str | None = None) -> str:
1630
+ quest_root_resolved = quest_root.resolve()
1631
+ for candidate in [Path(str(raw_path or "").strip()).expanduser()]:
1632
+ for variant in (candidate, candidate.resolve(strict=False)):
1633
+ try:
1634
+ relative = variant.relative_to(quest_root_resolved)
1635
+ except ValueError:
1636
+ continue
1637
+ parts = relative.parts
1638
+ if len(parts) >= 3 and parts[0] == ".ds" and parts[1] == "worktrees":
1639
+ branch_root = parts[2]
1640
+ remainder = Path(*parts[3:]).as_posix() if len(parts) > 3 else ""
1641
+ return f"{branch_root}/{remainder}" if remainder else branch_root
1642
+ return relative.as_posix()
1643
+ if relative_path:
1644
+ return relative_path
1645
+ text = str(raw_path or "").strip()
1646
+ return Path(text).name or text
1647
+
1648
+ @staticmethod
1649
+ def _file_change_diff_unavailable(
1650
+ *,
1651
+ raw_path: str,
1652
+ run_id: str | None,
1653
+ event_id: str | None,
1654
+ message: str,
1655
+ ok: bool,
1656
+ base: str = "",
1657
+ head: str = "",
1658
+ branch: str | None = None,
1659
+ relative_path: str | None = None,
1660
+ display_path: str | None = None,
1661
+ ) -> dict:
1662
+ normalized_path = relative_path or str(raw_path or "").strip()
1663
+ return {
1664
+ "ok": ok,
1665
+ "available": False,
1666
+ "source": "unavailable",
1667
+ "run_id": run_id,
1668
+ "event_id": event_id,
1669
+ "branch": branch,
1670
+ "display_path": display_path or normalized_path,
1671
+ "base": base,
1672
+ "head": head,
1673
+ "path": normalized_path,
1674
+ "old_path": None,
1675
+ "status": "modified",
1676
+ "binary": False,
1677
+ "added": 0,
1678
+ "removed": 0,
1679
+ "lines": [],
1680
+ "truncated": False,
1681
+ "message": message,
1682
+ }
1683
+
1414
1684
  @staticmethod
1415
1685
  def _markdown_title(path: Path) -> str:
1416
1686
  content = read_text(path)
@@ -60,6 +60,7 @@ ROUTES: list[tuple[str, re.Pattern[str], str]] = [
60
60
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/commit$"), "git_commit"),
61
61
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/diff-file$"), "git_diff_file"),
62
62
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/commit-file$"), "git_commit_file"),
63
+ ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/operations/file-change-diff$"), "file_change_diff"),
63
64
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/runs$"), "runs"),
64
65
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/memory$"), "quest_memory"),
65
66
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/documents$"), "documents"),