@researai/deepscientist 1.5.9 → 1.5.12

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 (165) hide show
  1. package/README.md +112 -99
  2. package/assets/branding/connector-qq.png +0 -0
  3. package/assets/branding/connector-rokid.png +0 -0
  4. package/assets/branding/connector-weixin.png +0 -0
  5. package/assets/branding/projects.png +0 -0
  6. package/bin/ds.js +519 -63
  7. package/docs/assets/branding/projects.png +0 -0
  8. package/docs/en/00_QUICK_START.md +338 -68
  9. package/docs/en/01_SETTINGS_REFERENCE.md +14 -0
  10. package/docs/en/02_START_RESEARCH_GUIDE.md +180 -4
  11. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  12. package/docs/en/09_DOCTOR.md +66 -5
  13. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  14. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  15. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +446 -0
  16. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  17. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  18. package/docs/en/15_CODEX_PROVIDER_SETUP.md +284 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +83 -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 +345 -72
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +14 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +181 -3
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +68 -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 +442 -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/15_CODEX_PROVIDER_SETUP.md +285 -0
  38. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  39. package/docs/zh/README.md +129 -0
  40. package/install.sh +0 -34
  41. package/package.json +2 -2
  42. package/pyproject.toml +1 -1
  43. package/src/deepscientist/__init__.py +1 -1
  44. package/src/deepscientist/annotations.py +343 -0
  45. package/src/deepscientist/artifact/arxiv.py +484 -37
  46. package/src/deepscientist/artifact/service.py +574 -108
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/monitor.py +7 -5
  49. package/src/deepscientist/bash_exec/service.py +93 -21
  50. package/src/deepscientist/bridges/builtins.py +2 -0
  51. package/src/deepscientist/bridges/connectors.py +447 -0
  52. package/src/deepscientist/channels/__init__.py +2 -0
  53. package/src/deepscientist/channels/builtins.py +3 -1
  54. package/src/deepscientist/channels/local.py +3 -3
  55. package/src/deepscientist/channels/qq.py +8 -8
  56. package/src/deepscientist/channels/qq_gateway.py +1 -1
  57. package/src/deepscientist/channels/relay.py +14 -8
  58. package/src/deepscientist/channels/weixin.py +59 -0
  59. package/src/deepscientist/channels/weixin_ilink.py +388 -0
  60. package/src/deepscientist/config/models.py +23 -2
  61. package/src/deepscientist/config/service.py +539 -67
  62. package/src/deepscientist/connector/__init__.py +4 -0
  63. package/src/deepscientist/connector/connector_profiles.py +481 -0
  64. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  65. package/src/deepscientist/connector/qq_profiles.py +206 -0
  66. package/src/deepscientist/connector/weixin_support.py +663 -0
  67. package/src/deepscientist/connector_profiles.py +1 -374
  68. package/src/deepscientist/connector_runtime.py +2 -0
  69. package/src/deepscientist/daemon/api/handlers.py +165 -5
  70. package/src/deepscientist/daemon/api/router.py +13 -1
  71. package/src/deepscientist/daemon/app.py +1444 -67
  72. package/src/deepscientist/doctor.py +4 -5
  73. package/src/deepscientist/gitops/diff.py +120 -29
  74. package/src/deepscientist/lingzhu_support.py +1 -182
  75. package/src/deepscientist/mcp/server.py +135 -7
  76. package/src/deepscientist/prompts/builder.py +128 -11
  77. package/src/deepscientist/qq_profiles.py +1 -196
  78. package/src/deepscientist/quest/node_traces.py +23 -0
  79. package/src/deepscientist/quest/service.py +359 -74
  80. package/src/deepscientist/quest/stage_views.py +71 -5
  81. package/src/deepscientist/runners/codex.py +170 -19
  82. package/src/deepscientist/runners/runtime_overrides.py +6 -0
  83. package/src/deepscientist/shared.py +33 -14
  84. package/src/deepscientist/weixin_support.py +1 -0
  85. package/src/prompts/connectors/lingzhu.md +3 -1
  86. package/src/prompts/connectors/qq.md +2 -1
  87. package/src/prompts/connectors/weixin.md +231 -0
  88. package/src/prompts/contracts/shared_interaction.md +4 -1
  89. package/src/prompts/system.md +61 -9
  90. package/src/skills/analysis-campaign/SKILL.md +46 -6
  91. package/src/skills/analysis-campaign/references/campaign-plan-template.md +21 -8
  92. package/src/skills/baseline/SKILL.md +1 -1
  93. package/src/skills/decision/SKILL.md +1 -1
  94. package/src/skills/experiment/SKILL.md +1 -1
  95. package/src/skills/finalize/SKILL.md +1 -1
  96. package/src/skills/idea/SKILL.md +1 -1
  97. package/src/skills/intake-audit/SKILL.md +1 -1
  98. package/src/skills/rebuttal/SKILL.md +74 -1
  99. package/src/skills/rebuttal/references/response-letter-template.md +55 -11
  100. package/src/skills/review/SKILL.md +118 -1
  101. package/src/skills/review/references/experiment-todo-template.md +23 -0
  102. package/src/skills/review/references/review-report-template.md +16 -0
  103. package/src/skills/review/references/revision-log-template.md +4 -0
  104. package/src/skills/scout/SKILL.md +1 -1
  105. package/src/skills/write/SKILL.md +168 -7
  106. package/src/skills/write/references/paper-experiment-matrix-template.md +131 -0
  107. package/src/tui/package.json +1 -1
  108. package/src/ui/dist/assets/{AiManusChatView-BKZ103sn.js → AiManusChatView-CnJcXynW.js} +156 -48
  109. package/src/ui/dist/assets/{AnalysisPlugin-mTTzGAlK.js → AnalysisPlugin-DeyzPEhV.js} +1 -1
  110. package/src/ui/dist/assets/{CliPlugin-BH58n3GY.js → CliPlugin-CB1YODQn.js} +164 -9
  111. package/src/ui/dist/assets/{CodeEditorPlugin-BKGRUH7e.js → CodeEditorPlugin-B-xicq1e.js} +8 -8
  112. package/src/ui/dist/assets/{CodeViewerPlugin-BMADwFWJ.js → CodeViewerPlugin-DT54ysXa.js} +5 -5
  113. package/src/ui/dist/assets/{DocViewerPlugin-ZOnTIHLN.js → DocViewerPlugin-DQtKT-VD.js} +3 -3
  114. package/src/ui/dist/assets/{GitDiffViewerPlugin-CQ7h1Djm.js → GitDiffViewerPlugin-hqHbCfnv.js} +20 -21
  115. package/src/ui/dist/assets/{ImageViewerPlugin-GVS5MsnC.js → ImageViewerPlugin-OcVo33jV.js} +5 -5
  116. package/src/ui/dist/assets/{LabCopilotPanel-BZNv1JML.js → LabCopilotPanel-DdGwhEUV.js} +11 -11
  117. package/src/ui/dist/assets/{LabPlugin-TWcJsdQA.js → LabPlugin-Ciz1gDaX.js} +2 -1
  118. package/src/ui/dist/assets/{LatexPlugin-DIjHiR2x.js → LatexPlugin-BhmjNQRC.js} +37 -11
  119. package/src/ui/dist/assets/{MarkdownViewerPlugin-D3ooGAH0.js → MarkdownViewerPlugin-BzdVH9Bx.js} +4 -4
  120. package/src/ui/dist/assets/{MarketplacePlugin-DfVfE9hN.js → MarketplacePlugin-DmyHspXt.js} +3 -3
  121. package/src/ui/dist/assets/{NotebookEditor-DDl0_Mc0.js → NotebookEditor-BMXKrDRk.js} +1 -1
  122. package/src/ui/dist/assets/{NotebookEditor-s8JhzuX1.js → NotebookEditor-BTVYRGkm.js} +12 -12
  123. package/src/ui/dist/assets/{PdfLoader-C2Sf6SJM.js → PdfLoader-CvcjJHXv.js} +14 -7
  124. package/src/ui/dist/assets/{PdfMarkdownPlugin-CXFLoIsa.js → PdfMarkdownPlugin-DW2ej8Vk.js} +73 -6
  125. package/src/ui/dist/assets/{PdfViewerPlugin-BYTmz2fK.js → PdfViewerPlugin-CmlDxbhU.js} +103 -34
  126. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  127. package/src/ui/dist/assets/{SearchPlugin-CjWBI1O9.js → SearchPlugin-DAjQZPSv.js} +1 -1
  128. package/src/ui/dist/assets/{TextViewerPlugin-DdOBU3-S.js → TextViewerPlugin-C-nVAZb_.js} +5 -4
  129. package/src/ui/dist/assets/{VNCViewer-B8HGgLwQ.js → VNCViewer-D7-dIYon.js} +10 -10
  130. package/src/ui/dist/assets/bot-C_G4WtNI.js +21 -0
  131. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  132. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  133. package/src/ui/dist/assets/{code-BWAY76JP.js → code-Cd7WfiWq.js} +1 -1
  134. package/src/ui/dist/assets/{file-content-C1NwU5oQ.js → file-content-B57zsL9y.js} +1 -1
  135. package/src/ui/dist/assets/{file-diff-panel-CywslwB9.js → file-diff-panel-DVoheLFq.js} +1 -1
  136. package/src/ui/dist/assets/{file-socket-B4kzuOBQ.js → file-socket-B5kXFxZP.js} +1 -1
  137. package/src/ui/dist/assets/{image-D-NZM-6P.js → image-LLOjkMHF.js} +1 -1
  138. package/src/ui/dist/assets/{index-DGIYDuTv.css → index-BQG-1s2o.css} +40 -13
  139. package/src/ui/dist/assets/{index-DHZJ_0TI.js → index-C3r2iGrp.js} +12 -12
  140. package/src/ui/dist/assets/{index-7Chr1g9c.js → index-CLQauncb.js} +15050 -9561
  141. package/src/ui/dist/assets/index-Dxa2eYMY.js +25 -0
  142. package/src/ui/dist/assets/{index-BdM1Gqfr.js → index-hOUOWbW2.js} +2 -2
  143. package/src/ui/dist/assets/{monaco-Cb2uKKe6.js → monaco-BGGAEii3.js} +1 -1
  144. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DlEr1_y5.js} +16 -1
  145. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  146. package/src/ui/dist/assets/{popover-Bg72DGgT.js → popover-CWJbJuYY.js} +1 -1
  147. package/src/ui/dist/assets/{project-sync-Ce_0BglY.js → project-sync-CRJiucYO.js} +18 -77
  148. package/src/ui/dist/assets/select-CoHB7pvH.js +1690 -0
  149. package/src/ui/dist/assets/{sigma-DPaACDrh.js → sigma-D5aJWR8J.js} +1 -1
  150. package/src/ui/dist/assets/{index-CDxNdQdz.js → square-check-big-DUK_mnkS.js} +2 -13
  151. package/src/ui/dist/assets/{trash-BvTgE5__.js → trash-ChU3SEE3.js} +1 -1
  152. package/src/ui/dist/assets/{useCliAccess-CgPeMOwP.js → useCliAccess-BrJBV3tY.js} +1 -1
  153. package/src/ui/dist/assets/{useFileDiffOverlay-xPhz7P5B.js → useFileDiffOverlay-C2OQaVWc.js} +1 -1
  154. package/src/ui/dist/assets/{wrap-text-C3Un3YQr.js → wrap-text-C7Qqh-om.js} +1 -1
  155. package/src/ui/dist/assets/{zoom-out-BgxLa0Ri.js → zoom-out-rtX0FKya.js} +1 -1
  156. package/src/ui/dist/index.html +2 -2
  157. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  158. package/src/ui/dist/assets/AutoFigurePlugin-C_wWw4AP.js +0 -8149
  159. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  160. package/src/ui/dist/assets/Stepper-B0Dd8CxK.js +0 -158
  161. package/src/ui/dist/assets/bibtex-CKaefIN2.js +0 -189
  162. package/src/ui/dist/assets/file-utils-H2fjA46S.js +0 -109
  163. package/src/ui/dist/assets/message-square-BzjLiXir.js +0 -16
  164. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  165. package/src/ui/dist/assets/tooltip-C_mA6R0w.js +0 -108
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import copy
4
+ from collections import deque
4
5
  from contextlib import contextmanager
6
+ from datetime import UTC, datetime, timedelta
5
7
  import hashlib
6
8
  import subprocess
7
9
  import json
@@ -19,11 +21,11 @@ except ImportError: # pragma: no cover
19
21
 
20
22
  from ..artifact.metrics import build_metrics_timeline, extract_latest_metric
21
23
  from ..config import ConfigManager
22
- from ..connector_runtime import conversation_identity_key, normalize_conversation_id
24
+ from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
23
25
  from ..gitops import current_branch, export_git_graph, head_commit, init_repo
24
26
  from ..home import repo_root
25
27
  from ..registries import BaselineRegistry
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
28
+ from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
27
29
  from ..skills import SkillInstaller
28
30
  from ..web_search import extract_web_search_payload
29
31
  from .layout import (
@@ -42,6 +44,126 @@ _UNSET = object()
42
44
  _NUMERIC_QUEST_ID_PATTERN = re.compile(r"^\d{1,10}$")
43
45
  _MAX_NUMERIC_QUEST_ID_VALUE = 9_999_999_999
44
46
  _NUMERIC_QUEST_ID_PAD_WIDTH = 3
47
+ _CRASH_AUTO_RESUME_WINDOW = timedelta(hours=24)
48
+ _JSONL_CACHE_MAX_BYTES = 4 * 1024 * 1024
49
+ _CODEX_HISTORY_TAIL_LIMIT = 400
50
+ _JSONL_STREAM_CHUNK_BYTES = 64 * 1024
51
+ _EVENTS_OVERSIZED_LINE_BYTES = 8 * 1024 * 1024
52
+ _OVERSIZED_EVENT_PREFIX_BYTES = 4096
53
+ _EVENT_TYPE_BYTES_RE = re.compile(rb'"(?:type|event_type)"\s*:\s*"([^"]+)"')
54
+ _EVENT_TOOL_NAME_BYTES_RE = re.compile(rb'"tool_name"\s*:\s*"([^"]+)"')
55
+ _EVENT_RUN_ID_BYTES_RE = re.compile(rb'"run_id"\s*:\s*"([^"]+)"')
56
+
57
+
58
+ def _oversized_event_placeholder(*, prefix: bytes, line_bytes: int) -> dict[str, Any]:
59
+ def _extract(pattern: re.Pattern[bytes]) -> str | None:
60
+ match = pattern.search(prefix)
61
+ if match is None:
62
+ return None
63
+ try:
64
+ return match.group(1).decode("utf-8", errors="ignore").strip() or None
65
+ except Exception:
66
+ return None
67
+
68
+ event_type = _extract(_EVENT_TYPE_BYTES_RE) or "runner.tool_result"
69
+ tool_name = _extract(_EVENT_TOOL_NAME_BYTES_RE)
70
+ run_id = _extract(_EVENT_RUN_ID_BYTES_RE)
71
+ summary = f"Omitted oversized quest event payload ({line_bytes} bytes) while reading event history."
72
+ payload: dict[str, Any] = {
73
+ "type": event_type,
74
+ "status": "omitted",
75
+ "summary": summary,
76
+ "oversized_event": True,
77
+ "oversized_bytes": line_bytes,
78
+ }
79
+ if tool_name:
80
+ payload["tool_name"] = tool_name
81
+ if run_id:
82
+ payload["run_id"] = run_id
83
+ return payload
84
+
85
+
86
+ def _iter_jsonl_records_safely(
87
+ path: Path,
88
+ *,
89
+ oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
90
+ ):
91
+ if not path.exists():
92
+ return
93
+ with path.open("rb") as handle:
94
+ buffer = bytearray()
95
+ prefix = bytearray()
96
+ current_bytes = 0
97
+ oversized = False
98
+ while True:
99
+ chunk = handle.read(_JSONL_STREAM_CHUNK_BYTES)
100
+ if not chunk:
101
+ break
102
+ start = 0
103
+ while start <= len(chunk):
104
+ newline_index = chunk.find(b"\n", start)
105
+ has_newline = newline_index >= 0
106
+ segment = chunk[start:newline_index] if has_newline else chunk[start:]
107
+
108
+ if oversized:
109
+ current_bytes += len(segment)
110
+ if has_newline:
111
+ yield _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
112
+ prefix = bytearray()
113
+ current_bytes = 0
114
+ oversized = False
115
+ start = newline_index + 1
116
+ continue
117
+ break
118
+
119
+ next_bytes = current_bytes + len(segment)
120
+ if next_bytes > oversized_line_bytes:
121
+ combined_prefix = bytes(buffer)
122
+ remaining = max(0, _OVERSIZED_EVENT_PREFIX_BYTES - len(combined_prefix))
123
+ if remaining:
124
+ combined_prefix += segment[:remaining]
125
+ prefix = bytearray(combined_prefix)
126
+ buffer.clear()
127
+ current_bytes = next_bytes
128
+ oversized = True
129
+ if has_newline:
130
+ yield _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
131
+ prefix = bytearray()
132
+ current_bytes = 0
133
+ oversized = False
134
+ start = newline_index + 1
135
+ continue
136
+ break
137
+
138
+ buffer.extend(segment)
139
+ current_bytes = next_bytes
140
+ if has_newline:
141
+ raw = bytes(buffer).strip()
142
+ buffer.clear()
143
+ line_bytes = current_bytes
144
+ current_bytes = 0
145
+ if raw:
146
+ try:
147
+ payload = json.loads(raw)
148
+ except json.JSONDecodeError:
149
+ payload = None
150
+ if isinstance(payload, dict):
151
+ yield payload
152
+ start = newline_index + 1
153
+ continue
154
+ break
155
+
156
+ if oversized:
157
+ yield _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
158
+ elif buffer:
159
+ raw = bytes(buffer).strip()
160
+ if raw:
161
+ try:
162
+ payload = json.loads(raw)
163
+ except json.JSONDecodeError:
164
+ payload = None
165
+ if isinstance(payload, dict):
166
+ yield payload
45
167
 
46
168
 
47
169
  class QuestService:
@@ -64,6 +186,35 @@ class QuestService:
64
186
  def _quest_root(self, quest_id: str) -> Path:
65
187
  return self.quests_root / quest_id
66
188
 
189
+ def _normalized_binding_sources(self, sources: list[Any] | None) -> list[str]:
190
+ local_present = False
191
+ external_source: str | None = None
192
+ for raw in sources or []:
193
+ normalized = self._normalize_binding_source(raw)
194
+ if not normalized:
195
+ continue
196
+ if normalized == "local:default":
197
+ local_present = True
198
+ continue
199
+ parsed = parse_conversation_id(normalized)
200
+ connector = str((parsed or {}).get("connector") or "").strip().lower()
201
+ if connector == "local":
202
+ local_present = True
203
+ continue
204
+ external_source = normalized
205
+ if external_source:
206
+ return ["local:default", external_source]
207
+ if local_present:
208
+ return ["local:default"]
209
+ return ["local:default"]
210
+
211
+ def _binding_sources_payload(self, quest_root: Path) -> dict[str, list[str]]:
212
+ bindings_path = quest_root / ".ds" / "bindings.json"
213
+ payload = read_json(bindings_path, {"sources": ["local:default"]})
214
+ raw_sources = payload.get("sources") if isinstance(payload, dict) else ["local:default"]
215
+ sources = self._normalized_binding_sources(raw_sources if isinstance(raw_sources, list) else ["local:default"])
216
+ return {"sources": sources}
217
+
67
218
  def preferred_locale(self, quest_root: Path | None = None) -> str:
68
219
  if quest_root is not None:
69
220
  try:
@@ -738,7 +889,7 @@ class QuestService:
738
889
  "last_artifact_interact_at": runtime_state.get("last_artifact_interact_at"),
739
890
  "last_delivered_batch_id": runtime_state.get("last_delivered_batch_id"),
740
891
  "last_delivered_at": runtime_state.get("last_delivered_at"),
741
- "bound_conversations": (self._read_cached_json(quest_root / ".ds" / "bindings.json", {}).get("sources") or ["local:default"]),
892
+ "bound_conversations": self._binding_sources_payload(quest_root).get("sources") or ["local:default"],
742
893
  "created_at": quest_yaml.get("created_at"),
743
894
  "updated_at": quest_yaml.get("updated_at"),
744
895
  "branch": research_state.get("current_workspace_branch") or research_state.get("research_head_branch"),
@@ -779,21 +930,15 @@ class QuestService:
779
930
  getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)),
780
931
  stat.st_size,
781
932
  )
933
+ if stat.st_size > _JSONL_CACHE_MAX_BYTES:
934
+ with self._jsonl_cache_lock:
935
+ self._jsonl_cache.pop(cache_key, None)
936
+ return read_jsonl(path)
782
937
  with self._jsonl_cache_lock:
783
938
  cached = self._jsonl_cache.get(cache_key)
784
939
  if cached and cached.get("state") == state:
785
940
  return cached.get("records") or []
786
- items: list[dict[str, Any]] = []
787
- for line in path.read_text(encoding="utf-8").splitlines():
788
- line = line.strip()
789
- if not line:
790
- continue
791
- try:
792
- payload = json.loads(line)
793
- except json.JSONDecodeError:
794
- continue
795
- if isinstance(payload, dict):
796
- items.append(payload)
941
+ items = read_jsonl(path)
797
942
  with self._jsonl_cache_lock:
798
943
  self._jsonl_cache[cache_key] = {
799
944
  "state": state,
@@ -801,6 +946,57 @@ class QuestService:
801
946
  }
802
947
  return items
803
948
 
949
+ @staticmethod
950
+ def _read_jsonl_cursor_slice(
951
+ path: Path,
952
+ *,
953
+ after: int = 0,
954
+ before: int | None = None,
955
+ limit: int = 200,
956
+ tail: bool = False,
957
+ ) -> tuple[list[tuple[int, dict[str, Any]]], int, bool]:
958
+ normalized_limit = max(int(limit or 0), 0)
959
+ if not path.exists():
960
+ return [], 0, False
961
+ if normalized_limit <= 0:
962
+ total = sum(1 for _ in _iter_jsonl_records_safely(path))
963
+ return [], total, False
964
+
965
+ if before is not None:
966
+ stop_cursor = max(int(before) - 1, 0)
967
+ window: deque[tuple[int, dict[str, Any]]] = deque(maxlen=normalized_limit)
968
+ total = 0
969
+ for payload in _iter_jsonl_records_safely(path):
970
+ total += 1
971
+ if total >= before:
972
+ break
973
+ window.append((total, payload))
974
+ has_more = bool(window and window[0][0] > 1)
975
+ return list(window), total, has_more
976
+
977
+ if tail:
978
+ window = deque(maxlen=normalized_limit)
979
+ total = 0
980
+ for payload in _iter_jsonl_records_safely(path):
981
+ total += 1
982
+ window.append((total, payload))
983
+ has_more = total > len(window)
984
+ return list(window), total, has_more
985
+
986
+ collected: list[tuple[int, dict[str, Any]]] = []
987
+ total = 0
988
+ saw_more = False
989
+ normalized_after = max(int(after or 0), 0)
990
+ for payload in _iter_jsonl_records_safely(path):
991
+ total += 1
992
+ if total <= normalized_after:
993
+ continue
994
+ if len(collected) < normalized_limit:
995
+ collected.append((total, payload))
996
+ continue
997
+ saw_more = True
998
+ return collected, total, saw_more
999
+
804
1000
  @staticmethod
805
1001
  def _path_state(path: Path) -> tuple[int, int, int] | None:
806
1002
  if not path.exists():
@@ -1093,7 +1289,7 @@ class QuestService:
1093
1289
  "last_artifact_interact_at": runtime_state.get("last_artifact_interact_at"),
1094
1290
  "last_delivered_batch_id": runtime_state.get("last_delivered_batch_id"),
1095
1291
  "last_delivered_at": runtime_state.get("last_delivered_at"),
1096
- "bound_conversations": (self._read_cached_json(quest_root / ".ds" / "bindings.json", {}).get("sources") or ["local:default"]),
1292
+ "bound_conversations": self._binding_sources_payload(quest_root).get("sources") or ["local:default"],
1097
1293
  "created_at": quest_yaml.get("created_at"),
1098
1294
  "updated_at": quest_yaml.get("updated_at"),
1099
1295
  "branch": current_branch(workspace_root),
@@ -1362,61 +1558,30 @@ class QuestService:
1362
1558
  def bind_source(self, quest_id: str, source: str) -> dict:
1363
1559
  quest_root = self._quest_root(quest_id)
1364
1560
  bindings_path = quest_root / ".ds" / "bindings.json"
1365
- bindings = read_json(bindings_path, {"sources": []})
1561
+ bindings = self._binding_sources_payload(quest_root)
1366
1562
  normalized_source = self._normalize_binding_source(source)
1367
- normalized_key = conversation_identity_key(normalized_source)
1368
- changed = False
1369
- replaced = False
1370
- sources: list[str] = []
1371
- for item in list(bindings.get("sources") or []):
1372
- existing = self._normalize_binding_source(str(item))
1373
- if conversation_identity_key(existing) == normalized_key:
1374
- if not replaced:
1375
- sources.append(normalized_source)
1376
- replaced = True
1377
- if existing != normalized_source:
1378
- changed = True
1379
- else:
1380
- changed = True
1381
- continue
1382
- sources.append(existing)
1383
- if existing != item:
1384
- changed = True
1385
- if not replaced:
1386
- sources.append(normalized_source)
1387
- changed = True
1563
+ next_sources = self._normalized_binding_sources([*(bindings.get("sources") or []), normalized_source])
1564
+ changed = list(bindings.get("sources") or []) != next_sources
1388
1565
  if changed:
1389
- bindings["sources"] = sources
1566
+ bindings["sources"] = next_sources
1390
1567
  write_json(bindings_path, bindings)
1391
1568
  return bindings
1392
1569
 
1393
1570
  def binding_sources(self, quest_id: str) -> list[str]:
1394
1571
  quest_root = self._quest_root(quest_id)
1395
- bindings_path = quest_root / ".ds" / "bindings.json"
1396
- bindings = read_json(bindings_path, {"sources": ["local:default"]})
1397
- sources = [self._normalize_binding_source(item) for item in (bindings.get("sources") or [])]
1398
- return [item for item in sources if item]
1572
+ return list(self._binding_sources_payload(quest_root).get("sources") or ["local:default"])
1399
1573
 
1400
1574
  def set_binding_sources(self, quest_id: str, sources: list[str]) -> dict:
1401
1575
  quest_root = self._quest_root(quest_id)
1402
1576
  bindings_path = quest_root / ".ds" / "bindings.json"
1403
- normalized_sources = [self._normalize_binding_source(item) for item in sources]
1404
- ordered: list[str] = []
1405
- seen: set[str] = set()
1406
- for item in normalized_sources:
1407
- key = conversation_identity_key(item)
1408
- if not item or key in seen:
1409
- continue
1410
- seen.add(key)
1411
- ordered.append(item)
1412
- payload = {"sources": ordered}
1577
+ payload = {"sources": self._normalized_binding_sources(sources)}
1413
1578
  write_json(bindings_path, payload)
1414
1579
  return payload
1415
1580
 
1416
1581
  def unbind_source(self, quest_id: str, source: str) -> dict:
1417
1582
  quest_root = self._quest_root(quest_id)
1418
1583
  bindings_path = quest_root / ".ds" / "bindings.json"
1419
- bindings = read_json(bindings_path, {"sources": []})
1584
+ bindings = self._binding_sources_payload(quest_root)
1420
1585
  normalized_source = self._normalize_binding_source(source)
1421
1586
  normalized_key = conversation_identity_key(normalized_source)
1422
1587
  changed = False
@@ -1429,8 +1594,11 @@ class QuestService:
1429
1594
  sources.append(existing)
1430
1595
  if existing != item:
1431
1596
  changed = True
1597
+ normalized_sources = self._normalized_binding_sources(sources)
1598
+ if normalized_sources != list(bindings.get("sources") or []):
1599
+ changed = True
1432
1600
  if changed:
1433
- bindings["sources"] = sources
1601
+ bindings["sources"] = normalized_sources
1434
1602
  write_json(bindings_path, bindings)
1435
1603
  return bindings
1436
1604
 
@@ -1591,6 +1759,12 @@ class QuestService:
1591
1759
  if not active_run_id and status != "running":
1592
1760
  continue
1593
1761
  previous_status = status or "running"
1762
+ last_transition_at = self._runtime_recovery_timestamp(runtime_state, quest_data)
1763
+ recoverable = self._runtime_recovery_eligible(
1764
+ previous_status=previous_status,
1765
+ active_run_id=active_run_id or None,
1766
+ last_transition_at=last_transition_at,
1767
+ )
1594
1768
  self.update_runtime_state(
1595
1769
  quest_root=quest_root,
1596
1770
  status="stopped",
@@ -1601,6 +1775,8 @@ class QuestService:
1601
1775
  f"Recovered quest from stale runtime state; previous status `{previous_status}`"
1602
1776
  + (f", abandoned run `{active_run_id}`." if active_run_id else ".")
1603
1777
  )
1778
+ if recoverable:
1779
+ summary = f"{summary} Auto-resume is eligible within the 24-hour recovery window."
1604
1780
  append_jsonl(
1605
1781
  quest_root / ".ds" / "events.jsonl",
1606
1782
  {
@@ -1609,6 +1785,8 @@ class QuestService:
1609
1785
  "quest_id": quest_root.name,
1610
1786
  "previous_status": previous_status,
1611
1787
  "abandoned_run_id": active_run_id or None,
1788
+ "last_transition_at": last_transition_at,
1789
+ "recoverable": recoverable,
1612
1790
  "status": "stopped",
1613
1791
  "summary": summary,
1614
1792
  "created_at": utc_now(),
@@ -1619,11 +1797,53 @@ class QuestService:
1619
1797
  "quest_id": quest_root.name,
1620
1798
  "previous_status": previous_status,
1621
1799
  "abandoned_run_id": active_run_id or None,
1800
+ "last_transition_at": last_transition_at,
1801
+ "recoverable": recoverable,
1622
1802
  "status": "stopped",
1623
1803
  }
1624
1804
  )
1625
1805
  return reconciled
1626
1806
 
1807
+ @staticmethod
1808
+ def _parse_runtime_timestamp(value: Any) -> datetime | None:
1809
+ normalized = str(value or "").strip()
1810
+ if not normalized:
1811
+ return None
1812
+ candidate = normalized.replace("Z", "+00:00")
1813
+ try:
1814
+ parsed = datetime.fromisoformat(candidate)
1815
+ except ValueError:
1816
+ return None
1817
+ if parsed.tzinfo is None:
1818
+ parsed = parsed.replace(tzinfo=UTC)
1819
+ return parsed.astimezone(UTC)
1820
+
1821
+ def _runtime_recovery_timestamp(self, runtime_state: dict[str, Any], quest_data: dict[str, Any]) -> str | None:
1822
+ for candidate in (
1823
+ runtime_state.get("last_transition_at"),
1824
+ quest_data.get("updated_at"),
1825
+ quest_data.get("created_at"),
1826
+ ):
1827
+ parsed = self._parse_runtime_timestamp(candidate)
1828
+ if parsed is None:
1829
+ continue
1830
+ return parsed.isoformat()
1831
+ return None
1832
+
1833
+ def _runtime_recovery_eligible(
1834
+ self,
1835
+ *,
1836
+ previous_status: str,
1837
+ active_run_id: str | None,
1838
+ last_transition_at: str | None,
1839
+ ) -> bool:
1840
+ if previous_status != "running" and not str(active_run_id or "").strip():
1841
+ return False
1842
+ parsed = self._parse_runtime_timestamp(last_transition_at)
1843
+ if parsed is None:
1844
+ return False
1845
+ return datetime.now(UTC) - parsed <= _CRASH_AUTO_RESUME_WINDOW
1846
+
1627
1847
  def history(self, quest_id: str, limit: int = 100) -> list[dict]:
1628
1848
  return self._read_cached_jsonl(self._quest_root(quest_id) / ".ds" / "conversations" / "main.jsonl")[-limit:]
1629
1849
 
@@ -1729,40 +1949,37 @@ class QuestService:
1729
1949
  limit: int = 200,
1730
1950
  tail: bool = False,
1731
1951
  ) -> dict:
1732
- records = self._read_cached_jsonl(self._quest_root(quest_id) / ".ds" / "events.jsonl")
1952
+ event_path = self._quest_root(quest_id) / ".ds" / "events.jsonl"
1733
1953
  normalized_limit = max(limit, 0)
1734
1954
  direction = "after"
1735
1955
  if before is not None:
1736
1956
  direction = "before"
1737
- end = max(int(before) - 1, 0)
1738
- start = max(end - normalized_limit, 0)
1739
- sliced = records[start:end]
1740
1957
  elif tail and normalized_limit > 0:
1741
1958
  direction = "tail"
1742
- start = max(len(records) - normalized_limit, 0)
1743
- sliced = records[start : start + normalized_limit]
1744
- else:
1745
- start = max(after, 0)
1746
- sliced = records[start : start + normalized_limit]
1959
+ sliced_records, total_records, has_more = self._read_jsonl_cursor_slice(
1960
+ event_path,
1961
+ after=after,
1962
+ before=before,
1963
+ limit=normalized_limit,
1964
+ tail=tail,
1965
+ )
1747
1966
  enriched = []
1748
- for index, item in enumerate(sliced, start=start + 1):
1967
+ for cursor, item in sliced_records:
1749
1968
  enriched.append(
1750
1969
  {
1751
- "cursor": index,
1752
- "event_id": item.get("event_id") or f"evt-{quest_id}-{index}",
1970
+ "cursor": cursor,
1971
+ "event_id": item.get("event_id") or f"evt-{quest_id}-{cursor}",
1753
1972
  **item,
1754
1973
  }
1755
1974
  )
1756
1975
  if before is not None:
1757
- next_cursor = start + len(sliced)
1976
+ next_cursor = enriched[-1]["cursor"] if enriched else max(min(int(before or 0) - 1, total_records), 0)
1977
+ elif tail:
1978
+ next_cursor = total_records
1758
1979
  else:
1759
- next_cursor = len(records) if tail else start + len(sliced)
1980
+ next_cursor = enriched[-1]["cursor"] if enriched else max(int(after or 0), 0)
1760
1981
  oldest_cursor = enriched[0]["cursor"] if enriched else None
1761
1982
  newest_cursor = enriched[-1]["cursor"] if enriched else None
1762
- if before is not None:
1763
- has_more = start > 0
1764
- else:
1765
- has_more = start > 0 if tail else next_cursor < len(records)
1766
1983
  return {
1767
1984
  "quest_id": quest_id,
1768
1985
  "cursor": next_cursor,
@@ -1824,6 +2041,12 @@ class QuestService:
1824
2041
  resolved_selection = dict(selection or {})
1825
2042
  selection_ref = str(resolved_selection.get("selection_ref") or "").strip()
1826
2043
  selection_type = str(resolved_selection.get("selection_type") or "stage_node").strip() or None
2044
+ if (
2045
+ selection_type == "branch_node"
2046
+ and selection_ref
2047
+ and not str(resolved_selection.get("branch_name") or "").strip()
2048
+ ):
2049
+ resolved_selection["branch_name"] = selection_ref
1827
2050
  trace = None
1828
2051
  if selection_ref:
1829
2052
  try:
@@ -2080,7 +2303,18 @@ class QuestService:
2080
2303
  if document_id.startswith(("questpath::", "memory::"))
2081
2304
  else workspace_root
2082
2305
  )
2083
- path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
2306
+ try:
2307
+ path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
2308
+ except FileNotFoundError:
2309
+ legacy_relative = None
2310
+ if document_id.startswith("path::"):
2311
+ legacy_relative = document_id.split("::", 1)[1].lstrip("/")
2312
+ if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
2313
+ path, writable, scope, source_kind = self._resolve_document(
2314
+ quest_root, f"questpath::{legacy_relative}"
2315
+ )
2316
+ else:
2317
+ raise
2084
2318
  renderer_hint, mime_type = self._renderer_hint_for(path)
2085
2319
  is_text = self._is_text_document(path, mime_type, renderer_hint)
2086
2320
  content = read_text(path) if is_text else ""
@@ -3549,7 +3783,35 @@ def _tool_name(event: dict, item: dict) -> str:
3549
3783
  return "tool"
3550
3784
 
3551
3785
 
3786
+ def _structured_text(value: object) -> str:
3787
+ if value is None:
3788
+ return ""
3789
+ if isinstance(value, str):
3790
+ return value.strip()
3791
+ try:
3792
+ return json.dumps(value, ensure_ascii=False, indent=2)
3793
+ except TypeError:
3794
+ return str(value)
3795
+
3796
+
3797
+ def _is_bash_exec_item(event: dict, item: dict) -> bool:
3798
+ server = str(item.get("server") or event.get("server") or "").strip()
3799
+ tool = str(item.get("tool") or event.get("tool") or "").strip()
3800
+ return server == "bash_exec" and tool == "bash_exec"
3801
+
3802
+
3552
3803
  def _tool_args(event: dict, item: dict) -> str:
3804
+ if _is_bash_exec_item(event, item):
3805
+ for value in (
3806
+ item.get("arguments"),
3807
+ event.get("arguments"),
3808
+ item.get("input"),
3809
+ event.get("input"),
3810
+ ):
3811
+ text = _structured_text(value)
3812
+ if text:
3813
+ return text
3814
+ return ""
3553
3815
  for value in (
3554
3816
  item.get("command"),
3555
3817
  item.get("query"),
@@ -3569,6 +3831,21 @@ def _tool_args(event: dict, item: dict) -> str:
3569
3831
 
3570
3832
 
3571
3833
  def _tool_output(event: dict, item: dict) -> str:
3834
+ if _is_bash_exec_item(event, item):
3835
+ for value in (
3836
+ item.get("result"),
3837
+ item.get("output"),
3838
+ item.get("content"),
3839
+ event.get("result"),
3840
+ event.get("output"),
3841
+ event.get("content"),
3842
+ item.get("aggregated_output"),
3843
+ event.get("aggregated_output"),
3844
+ ):
3845
+ text = _structured_text(value)
3846
+ if text:
3847
+ return text
3848
+ return ""
3572
3849
  for value in (
3573
3850
  item.get("aggregated_output"),
3574
3851
  item.get("changes"),
@@ -3611,17 +3888,25 @@ def _mcp_tool_metadata(*, quest_id: str, run_id: str, server: str, tool: str, it
3611
3888
  for key in ("command", "workdir", "mode", "timeout_seconds", "comment"):
3612
3889
  if key in arguments:
3613
3890
  metadata[key] = arguments.get(key)
3891
+ if server == "bash_exec" and tool == "bash_exec" and isinstance(arguments.get("id"), str):
3892
+ metadata["bash_id"] = arguments.get("id")
3614
3893
  result_payload = _mcp_result_payload(item)
3615
3894
  if server == "bash_exec" and tool == "bash_exec":
3616
3895
  for key in (
3617
3896
  "bash_id",
3618
3897
  "status",
3898
+ "command",
3899
+ "workdir",
3900
+ "cwd",
3901
+ "kind",
3902
+ "comment",
3619
3903
  "started_at",
3620
3904
  "finished_at",
3621
3905
  "exit_code",
3622
3906
  "stop_reason",
3623
3907
  "last_progress",
3624
3908
  "log_path",
3909
+ "watchdog_after_seconds",
3625
3910
  ):
3626
3911
  if key in result_payload:
3627
3912
  metadata[key] = result_payload.get(key)
@@ -3636,7 +3921,7 @@ def _parse_codex_history(history_root: Path, *, quest_id: str, run_id: str, skil
3636
3921
  entries: list[dict] = []
3637
3922
  known_tool_names: dict[str, str] = {}
3638
3923
 
3639
- for raw in read_jsonl(history_path):
3924
+ for raw in read_jsonl_tail(history_path, _CODEX_HISTORY_TAIL_LIMIT):
3640
3925
  timestamp = raw.get("timestamp")
3641
3926
  event = raw.get("event")
3642
3927
  if not isinstance(event, dict):