@researai/deepscientist 1.5.15 → 1.5.16

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 (193) hide show
  1. package/README.md +336 -98
  2. package/bin/ds.js +691 -91
  3. package/docs/en/00_QUICK_START.md +36 -15
  4. package/docs/en/01_SETTINGS_REFERENCE.md +33 -0
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  6. package/docs/en/05_TUI_GUIDE.md +6 -0
  7. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  8. package/docs/en/09_DOCTOR.md +11 -5
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  10. package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
  11. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  12. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  13. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  14. package/docs/en/README.md +18 -0
  15. package/docs/zh/00_QUICK_START.md +36 -15
  16. package/docs/zh/01_SETTINGS_REFERENCE.md +33 -0
  17. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  18. package/docs/zh/05_TUI_GUIDE.md +6 -0
  19. package/docs/zh/09_DOCTOR.md +11 -5
  20. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  21. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
  22. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  23. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  24. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  25. package/docs/zh/README.md +18 -0
  26. package/package.json +1 -1
  27. package/pyproject.toml +1 -1
  28. package/src/deepscientist/__init__.py +1 -1
  29. package/src/deepscientist/acp/envelope.py +6 -0
  30. package/src/deepscientist/artifact/service.py +647 -22
  31. package/src/deepscientist/bash_exec/service.py +234 -9
  32. package/src/deepscientist/cli.py +115 -19
  33. package/src/deepscientist/codex_cli_compat.py +232 -0
  34. package/src/deepscientist/config/models.py +2 -1
  35. package/src/deepscientist/config/service.py +31 -9
  36. package/src/deepscientist/daemon/api/handlers.py +125 -6
  37. package/src/deepscientist/daemon/api/router.py +4 -0
  38. package/src/deepscientist/daemon/app.py +715 -98
  39. package/src/deepscientist/gitops/__init__.py +10 -1
  40. package/src/deepscientist/gitops/diff.py +129 -0
  41. package/src/deepscientist/gitops/service.py +4 -1
  42. package/src/deepscientist/mcp/server.py +39 -0
  43. package/src/deepscientist/prompts/builder.py +255 -32
  44. package/src/deepscientist/quest/layout.py +15 -2
  45. package/src/deepscientist/quest/service.py +295 -43
  46. package/src/deepscientist/quest/stage_views.py +6 -1
  47. package/src/deepscientist/runners/codex.py +86 -31
  48. package/src/deepscientist/skills/__init__.py +2 -2
  49. package/src/deepscientist/skills/installer.py +196 -5
  50. package/src/deepscientist/skills/registry.py +66 -0
  51. package/src/prompts/connectors/qq.md +18 -8
  52. package/src/prompts/connectors/weixin.md +16 -6
  53. package/src/prompts/contracts/shared_interaction.md +12 -1
  54. package/src/prompts/system.md +10 -5
  55. package/src/prompts/system_copilot.md +43 -0
  56. package/src/skills/analysis-campaign/SKILL.md +1 -0
  57. package/src/skills/baseline/SKILL.md +8 -0
  58. package/src/skills/decision/SKILL.md +8 -0
  59. package/src/skills/experiment/SKILL.md +8 -0
  60. package/src/skills/figure-polish/SKILL.md +1 -0
  61. package/src/skills/finalize/SKILL.md +1 -0
  62. package/src/skills/idea/SKILL.md +1 -0
  63. package/src/skills/intake-audit/SKILL.md +8 -0
  64. package/src/skills/mentor/SKILL.md +217 -0
  65. package/src/skills/mentor/references/correction-rules.md +210 -0
  66. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  67. package/src/skills/mentor/references/persona-profile.md +138 -0
  68. package/src/skills/mentor/references/taste-profile.md +128 -0
  69. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  70. package/src/skills/mentor/references/work-profile.md +289 -0
  71. package/src/skills/mentor/references/workflow-profile.md +240 -0
  72. package/src/skills/optimize/SKILL.md +1 -0
  73. package/src/skills/rebuttal/SKILL.md +1 -0
  74. package/src/skills/review/SKILL.md +1 -0
  75. package/src/skills/scout/SKILL.md +8 -0
  76. package/src/skills/write/SKILL.md +1 -0
  77. package/src/tui/dist/app/AppContainer.js +19 -11
  78. package/src/tui/dist/index.js +4 -1
  79. package/src/tui/dist/lib/api.js +33 -3
  80. package/src/tui/package.json +1 -1
  81. package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +204 -0
  82. package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
  83. package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
  84. package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
  85. package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
  86. package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
  87. package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
  88. package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
  89. package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
  90. package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
  91. package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
  92. package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
  93. package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
  94. package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
  95. package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -0
  96. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  97. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  98. package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +81 -0
  99. package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
  100. package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
  101. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  102. package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
  103. package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
  104. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  105. package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
  106. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  107. package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
  108. package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
  109. package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
  110. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  111. package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
  112. package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
  113. package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
  114. package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
  115. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  116. package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
  117. package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
  118. package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
  119. package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
  120. package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
  121. package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
  122. package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
  123. package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
  124. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  125. package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +6 -0
  126. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  127. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  128. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  129. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  130. package/src/ui/dist/assets/popover-DL6h35vr.js +1 -0
  131. package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
  132. package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
  133. package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
  134. package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
  135. package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
  136. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  137. package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
  138. package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
  139. package/src/ui/dist/index.html +5 -2
  140. package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
  141. package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
  142. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
  143. package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
  144. package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
  145. package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
  146. package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
  147. package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
  148. package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
  149. package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
  150. package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
  151. package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
  152. package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
  153. package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
  154. package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
  155. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  156. package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
  157. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  158. package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
  159. package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
  160. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  161. package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
  162. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  163. package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
  164. package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
  165. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  166. package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
  167. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  168. package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
  169. package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
  170. package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
  171. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  172. package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
  173. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  174. package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
  175. package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
  176. package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
  177. package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
  178. package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
  179. package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
  180. package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
  181. package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
  182. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  183. package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
  184. package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
  185. package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
  186. package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
  187. package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
  188. package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
  189. package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
  190. package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
  191. package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
  192. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  193. package/src/ui/dist/assets/zoom-out-BgVMmOW4.js +0 -34
@@ -52,11 +52,17 @@ def initial_quest_yaml(
52
52
  startup_contract: dict | None = None,
53
53
  ) -> dict:
54
54
  timestamp = utc_now()
55
+ workspace_mode = (
56
+ str((startup_contract or {}).get("workspace_mode") or "").strip().lower()
57
+ if isinstance(startup_contract, dict)
58
+ else ""
59
+ )
60
+ initial_status_value = "idle" if workspace_mode == "copilot" else "active"
55
61
  return {
56
62
  "quest_id": quest_id,
57
63
  "title": title or goal,
58
64
  "quest_root": str(quest_root.resolve()),
59
- "status": "active",
65
+ "status": initial_status_value,
60
66
  "active_anchor": "baseline",
61
67
  "baseline_gate": "pending",
62
68
  "confirmed_baseline_ref": None,
@@ -100,7 +106,14 @@ def initial_plan() -> str:
100
106
  )
101
107
 
102
108
 
103
- def initial_status() -> str:
109
+ def initial_status(startup_contract: dict | None = None) -> str:
110
+ workspace_mode = (
111
+ str((startup_contract or {}).get("workspace_mode") or "").strip().lower()
112
+ if isinstance(startup_contract, dict)
113
+ else ""
114
+ )
115
+ if workspace_mode == "copilot":
116
+ return "# Status\n\nReady for your first instruction.\n"
104
117
  return "# Status\n\nQuest created. Waiting for baseline setup or reuse.\n"
105
118
 
106
119
 
@@ -24,7 +24,7 @@ from ..artifact.metrics import build_baseline_compare_payload, build_metrics_tim
24
24
  from ..config import ConfigManager
25
25
  from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
26
26
  from ..file_lock import advisory_file_lock
27
- from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas
27
+ from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas, list_commit_canvas
28
28
  from ..home import repo_root
29
29
  from ..registries import BaselineRegistry
30
30
  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
@@ -172,6 +172,127 @@ def _iter_jsonl_records_safely(
172
172
  yield payload
173
173
 
174
174
 
175
+ def _parse_jsonl_record_line_safely(
176
+ raw_line: bytes,
177
+ *,
178
+ oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
179
+ ) -> dict[str, Any] | None:
180
+ raw = bytes(raw_line).strip()
181
+ if not raw:
182
+ return None
183
+ line_bytes = len(raw)
184
+ if line_bytes > oversized_line_bytes:
185
+ return _oversized_event_placeholder(
186
+ prefix=raw[:_OVERSIZED_EVENT_PREFIX_BYTES],
187
+ line_bytes=line_bytes,
188
+ )
189
+ try:
190
+ payload = json.loads(raw)
191
+ except json.JSONDecodeError:
192
+ return None
193
+ return payload if isinstance(payload, dict) else None
194
+
195
+
196
+ def _tail_jsonl_records_safely(
197
+ path: Path,
198
+ *,
199
+ limit: int,
200
+ oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
201
+ ) -> tuple[list[tuple[int, dict[str, Any]]], int]:
202
+ normalized_limit = max(int(limit or 0), 0)
203
+ if normalized_limit <= 0 or not path.exists():
204
+ return [], 0
205
+ total = _count_jsonl_lines_fast(path)
206
+ if total <= 0:
207
+ return [], 0
208
+
209
+ raw_tail = _read_jsonl_tail_lines_fast(path, normalized_limit)
210
+ if not raw_tail:
211
+ return [], total
212
+
213
+ cursor_start = max(total - len(raw_tail) + 1, 1)
214
+ parsed: list[tuple[int, dict[str, Any]]] = []
215
+ for cursor, raw_line in enumerate(raw_tail, start=cursor_start):
216
+ payload = _parse_jsonl_record_line_safely(
217
+ raw_line,
218
+ oversized_line_bytes=oversized_line_bytes,
219
+ )
220
+ if isinstance(payload, dict):
221
+ parsed.append((cursor, payload))
222
+ return parsed, total
223
+
224
+
225
+ def _count_jsonl_lines_fast(path: Path, *, chunk_size: int = 1024 * 1024) -> int:
226
+ if not path.exists():
227
+ return 0
228
+ total = 0
229
+ last_byte = b""
230
+ with path.open("rb") as handle:
231
+ while True:
232
+ chunk = handle.read(chunk_size)
233
+ if not chunk:
234
+ break
235
+ total += chunk.count(b"\n")
236
+ last_byte = chunk[-1:]
237
+ if total == 0 and last_byte:
238
+ return 1
239
+ if last_byte not in {b"", b"\n"}:
240
+ total += 1
241
+ return total
242
+
243
+
244
+ def _read_jsonl_tail_lines_fast(path: Path, limit: int, *, chunk_size: int = 1024 * 1024) -> list[bytes]:
245
+ normalized_limit = max(int(limit or 0), 0)
246
+ if normalized_limit <= 0 or not path.exists():
247
+ return []
248
+
249
+ size = path.stat().st_size
250
+ if size <= 0:
251
+ return []
252
+
253
+ lines: deque[bytes] = deque()
254
+ remainder = b""
255
+ with path.open("rb") as handle:
256
+ position = size
257
+ while position > 0 and len(lines) < normalized_limit:
258
+ read_size = min(chunk_size, position)
259
+ position -= read_size
260
+ handle.seek(position)
261
+ chunk = handle.read(read_size)
262
+ payload = chunk + remainder
263
+ parts = payload.split(b"\n")
264
+ remainder = parts[0]
265
+ for raw_line in reversed(parts[1:]):
266
+ stripped = raw_line.rstrip(b"\r")
267
+ if not stripped.strip():
268
+ continue
269
+ lines.appendleft(stripped)
270
+ if len(lines) >= normalized_limit:
271
+ break
272
+ if len(lines) < normalized_limit and remainder.strip():
273
+ lines.appendleft(remainder.rstrip(b"\r"))
274
+ return list(lines)[-normalized_limit:]
275
+
276
+
277
+ def _iter_jsonl_records_from_offset_safely(
278
+ path: Path,
279
+ *,
280
+ start_offset: int,
281
+ oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
282
+ ):
283
+ if not path.exists():
284
+ return
285
+ with path.open("rb") as handle:
286
+ handle.seek(max(int(start_offset or 0), 0))
287
+ for raw_line in handle:
288
+ payload = _parse_jsonl_record_line_safely(
289
+ raw_line,
290
+ oversized_line_bytes=oversized_line_bytes,
291
+ )
292
+ if isinstance(payload, dict):
293
+ yield payload
294
+
295
+
175
296
  class QuestService:
176
297
  def __init__(self, home: Path, skill_installer: SkillInstaller | None = None) -> None:
177
298
  self.home = home
@@ -182,6 +303,7 @@ class QuestService:
182
303
  self._file_cache: dict[str, dict[str, Any]] = {}
183
304
  self._jsonl_cache_lock = threading.Lock()
184
305
  self._jsonl_cache: dict[str, dict[str, Any]] = {}
306
+ self._jsonl_tail_cache: dict[str, dict[str, Any]] = {}
185
307
  self._snapshot_cache_lock = threading.Lock()
186
308
  self._snapshot_cache: dict[str, dict[str, Any]] = {}
187
309
  self._codex_history_cache_lock = threading.Lock()
@@ -288,6 +410,13 @@ class QuestService:
288
410
  return quest_root / ".ds" / "lab_canvas_state.json"
289
411
 
290
412
  def _default_research_state(self, quest_root: Path) -> dict[str, Any]:
413
+ quest_yaml = self.read_quest_yaml(quest_root)
414
+ startup_contract = (
415
+ dict(quest_yaml.get("startup_contract") or {})
416
+ if isinstance(quest_yaml.get("startup_contract"), dict)
417
+ else {}
418
+ )
419
+ workspace_mode = str(startup_contract.get("workspace_mode") or "").strip().lower() or "quest"
291
420
  return {
292
421
  "version": 1,
293
422
  "active_idea_id": None,
@@ -304,7 +433,7 @@ class QuestService:
304
433
  "paper_parent_worktree_root": None,
305
434
  "paper_parent_run_id": None,
306
435
  "next_pending_slice_id": None,
307
- "workspace_mode": "quest",
436
+ "workspace_mode": workspace_mode,
308
437
  "last_flow_type": None,
309
438
  "updated_at": utc_now(),
310
439
  }
@@ -354,7 +483,7 @@ class QuestService:
354
483
  continue
355
484
  current[key] = str(value) if isinstance(value, Path) else value
356
485
  payload = self.write_research_state(quest_root, current)
357
- self.schedule_projection_refresh(quest_root, kinds=("details", "canvas"))
486
+ self.schedule_projection_refresh(quest_root, kinds=("details", "canvas", "git_canvas"))
358
487
  return payload
359
488
 
360
489
  def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
@@ -971,6 +1100,8 @@ class QuestService:
971
1100
  return self._details_projection_state(quest_root)
972
1101
  if kind == "canvas":
973
1102
  return self._canvas_projection_state(quest_root)
1103
+ if kind == "git_canvas":
1104
+ return self._canvas_projection_state(quest_root)
974
1105
  raise ValueError(f"Unsupported projection kind `{kind}`.")
975
1106
 
976
1107
  def _projection_source_signature(self, quest_root: Path, kind: str) -> str:
@@ -1434,6 +1565,17 @@ class QuestService:
1434
1565
  update_progress(2, "Computing branch canvas")
1435
1566
  return list_branch_canvas(quest_root, quest_id=quest_root.name)
1436
1567
 
1568
+ def _build_git_canvas_projection_payload(
1569
+ self,
1570
+ quest_root: Path,
1571
+ *,
1572
+ source_signature: str,
1573
+ update_progress: Any,
1574
+ ) -> dict[str, Any]:
1575
+ update_progress(1, "Scanning commit history")
1576
+ update_progress(2, "Computing commit canvas")
1577
+ return list_commit_canvas(quest_root, quest_id=quest_root.name)
1578
+
1437
1579
  def _build_projection_payload(
1438
1580
  self,
1439
1581
  quest_root: Path,
@@ -1454,6 +1596,12 @@ class QuestService:
1454
1596
  source_signature=source_signature,
1455
1597
  update_progress=update_progress,
1456
1598
  )
1599
+ if kind == "git_canvas":
1600
+ return self._build_git_canvas_projection_payload(
1601
+ quest_root,
1602
+ source_signature=source_signature,
1603
+ update_progress=update_progress,
1604
+ )
1457
1605
  raise ValueError(f"Unsupported projection kind `{kind}`.")
1458
1606
 
1459
1607
  def _placeholder_workflow_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
@@ -1486,6 +1634,17 @@ class QuestService:
1486
1634
  },
1487
1635
  }
1488
1636
 
1637
+ def _placeholder_git_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
1638
+ research_state = self.read_research_state(quest_root)
1639
+ return {
1640
+ "quest_id": quest_id,
1641
+ "workspace_mode": str(research_state.get("workspace_mode") or "copilot").strip() or "copilot",
1642
+ "head": head_commit(quest_root),
1643
+ "current_ref": current_branch(quest_root),
1644
+ "nodes": [],
1645
+ "edges": [],
1646
+ }
1647
+
1489
1648
  def _projected_payload(self, quest_id: str, kind: str) -> dict[str, Any]:
1490
1649
  quest_root = self._quest_root(quest_id)
1491
1650
  source_signature = self._projection_source_signature(quest_root, kind)
@@ -1510,11 +1669,12 @@ class QuestService:
1510
1669
  else None
1511
1670
  )
1512
1671
  if payload is None:
1513
- payload = (
1514
- self._placeholder_workflow_payload(quest_id, quest_root)
1515
- if kind == "details"
1516
- else self._placeholder_canvas_payload(quest_id, quest_root)
1517
- )
1672
+ if kind == "details":
1673
+ payload = self._placeholder_workflow_payload(quest_id, quest_root)
1674
+ elif kind == "git_canvas":
1675
+ payload = self._placeholder_git_canvas_payload(quest_id, quest_root)
1676
+ else:
1677
+ payload = self._placeholder_canvas_payload(quest_id, quest_root)
1518
1678
  payload["projection_status"] = status
1519
1679
  return payload
1520
1680
 
@@ -1535,8 +1695,8 @@ class QuestService:
1535
1695
  ) -> None:
1536
1696
  resolved_kinds = [
1537
1697
  str(kind).strip()
1538
- for kind in (kinds or ("details", "canvas"))
1539
- if str(kind).strip() in {"details", "canvas"}
1698
+ for kind in (kinds or ("details", "canvas", "git_canvas"))
1699
+ if str(kind).strip() in {"details", "canvas", "git_canvas"}
1540
1700
  ]
1541
1701
  if not resolved_kinds:
1542
1702
  return
@@ -1563,6 +1723,9 @@ class QuestService:
1563
1723
  def git_branch_canvas(self, quest_id: str) -> dict[str, Any]:
1564
1724
  return self._projected_payload(quest_id, "canvas")
1565
1725
 
1726
+ def git_commit_canvas(self, quest_id: str) -> dict[str, Any]:
1727
+ return self._projected_payload(quest_id, "git_canvas")
1728
+
1566
1729
  def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1567
1730
  attachments: list[dict[str, Any]] = []
1568
1731
  seen_paths: set[str] = set()
@@ -2602,7 +2765,7 @@ class QuestService:
2602
2765
  )
2603
2766
  write_text(quest_root / "brief.md", initial_brief(goal))
2604
2767
  write_text(quest_root / "plan.md", initial_plan())
2605
- write_text(quest_root / "status.md", initial_status())
2768
+ write_text(quest_root / "status.md", initial_status(startup_contract))
2606
2769
  write_text(quest_root / "SUMMARY.md", initial_summary())
2607
2770
  write_text(quest_root / ".gitignore", gitignore())
2608
2771
  self._write_active_user_requirements(
@@ -2790,6 +2953,7 @@ class QuestService:
2790
2953
  "research_head_worktree_root": research_state.get("research_head_worktree_root"),
2791
2954
  "current_workspace_branch": research_state.get("current_workspace_branch"),
2792
2955
  "current_workspace_root": research_state.get("current_workspace_root"),
2956
+ "workspace_mode": research_state.get("workspace_mode") or "quest",
2793
2957
  "active_idea_id": research_state.get("active_idea_id"),
2794
2958
  "active_baseline_id": active_baseline_id,
2795
2959
  "active_baseline_variant_id": active_baseline_variant_id,
@@ -2876,8 +3040,8 @@ class QuestService:
2876
3040
  }
2877
3041
  return items
2878
3042
 
2879
- @staticmethod
2880
3043
  def _read_jsonl_cursor_slice(
3044
+ self,
2881
3045
  path: Path,
2882
3046
  *,
2883
3047
  after: int = 0,
@@ -2886,7 +3050,10 @@ class QuestService:
2886
3050
  tail: bool = False,
2887
3051
  ) -> tuple[list[tuple[int, dict[str, Any]]], int, bool]:
2888
3052
  normalized_limit = max(int(limit or 0), 0)
3053
+ cache_key = self._cache_key_for_path(path)
2889
3054
  if not path.exists():
3055
+ with self._jsonl_cache_lock:
3056
+ self._jsonl_tail_cache.pop(cache_key, None)
2890
3057
  return [], 0, False
2891
3058
  if normalized_limit <= 0:
2892
3059
  total = sum(1 for _ in _iter_jsonl_records_safely(path))
@@ -2905,11 +3072,71 @@ class QuestService:
2905
3072
  return list(window), total, has_more
2906
3073
 
2907
3074
  if tail:
2908
- window = deque(maxlen=normalized_limit)
2909
- total = 0
2910
- for payload in _iter_jsonl_records_safely(path):
2911
- total += 1
2912
- window.append((total, payload))
3075
+ state = self._path_state(path)
3076
+ cached_tail: dict[str, Any] | None = None
3077
+ with self._jsonl_cache_lock:
3078
+ candidate = self._jsonl_tail_cache.get(cache_key)
3079
+ if isinstance(candidate, dict):
3080
+ cached_tail = dict(candidate)
3081
+
3082
+ if cached_tail and cached_tail.get("state") == state:
3083
+ cached_limit = int(cached_tail.get("limit") or 0)
3084
+ cached_records = list(cached_tail.get("records") or [])
3085
+ cached_total = int(cached_tail.get("total") or 0)
3086
+ if cached_limit >= normalized_limit and cached_records:
3087
+ window = cached_records[-normalized_limit:]
3088
+ has_more = cached_total > len(window)
3089
+ return window, cached_total, has_more
3090
+
3091
+ if (
3092
+ cached_tail
3093
+ and state is not None
3094
+ and cached_tail.get("state")
3095
+ and tuple(cached_tail.get("state"))[0] == state[0]
3096
+ and state[2] >= tuple(cached_tail.get("state"))[2]
3097
+ ):
3098
+ cached_state = tuple(cached_tail.get("state"))
3099
+ cached_limit = int(cached_tail.get("limit") or 0)
3100
+ cached_total = int(cached_tail.get("total") or 0)
3101
+ max_limit = max(normalized_limit, cached_limit)
3102
+ window = deque(
3103
+ list(cached_tail.get("records") or []),
3104
+ maxlen=max_limit,
3105
+ )
3106
+ appended_records = list(
3107
+ _iter_jsonl_records_from_offset_safely(
3108
+ path,
3109
+ start_offset=int(cached_state[2]),
3110
+ )
3111
+ )
3112
+ if appended_records:
3113
+ next_cursor = cached_total + 1
3114
+ for payload in appended_records:
3115
+ window.append((next_cursor, payload))
3116
+ next_cursor += 1
3117
+ total = cached_total + len(appended_records)
3118
+ else:
3119
+ total = cached_total
3120
+ stored_records = list(window)
3121
+ with self._jsonl_cache_lock:
3122
+ self._jsonl_tail_cache[cache_key] = {
3123
+ "state": state,
3124
+ "limit": max_limit,
3125
+ "total": total,
3126
+ "records": stored_records,
3127
+ }
3128
+ selected = stored_records[-normalized_limit:]
3129
+ has_more = total > len(selected)
3130
+ return selected, total, has_more
3131
+
3132
+ window, total = _tail_jsonl_records_safely(path, limit=normalized_limit)
3133
+ with self._jsonl_cache_lock:
3134
+ self._jsonl_tail_cache[cache_key] = {
3135
+ "state": state,
3136
+ "limit": normalized_limit,
3137
+ "total": total,
3138
+ "records": list(window),
3139
+ }
2913
3140
  has_more = total > len(window)
2914
3141
  return list(window), total, has_more
2915
3142
 
@@ -2945,6 +3172,14 @@ class QuestService:
2945
3172
  except FileNotFoundError:
2946
3173
  return str(path.absolute())
2947
3174
 
3175
+ def jsonl_tail_cache_entry(self, path: Path) -> dict[str, Any] | None:
3176
+ cache_key = self._cache_key_for_path(path)
3177
+ with self._jsonl_cache_lock:
3178
+ candidate = self._jsonl_tail_cache.get(cache_key)
3179
+ if isinstance(candidate, dict):
3180
+ return dict(candidate)
3181
+ return None
3182
+
2948
3183
  def _read_cached_path(
2949
3184
  self,
2950
3185
  path: Path,
@@ -3610,10 +3845,11 @@ class QuestService:
3610
3845
  normalized_anchor = str(active_anchor).strip()
3611
3846
  if not normalized_anchor:
3612
3847
  raise ValueError("`active_anchor` cannot be empty.")
3613
- from ..prompts.builder import STANDARD_SKILLS
3848
+ from ..prompts.builder import current_standard_skills
3614
3849
 
3615
- if normalized_anchor not in STANDARD_SKILLS:
3616
- allowed = ", ".join(STANDARD_SKILLS)
3850
+ available_stage_skills = current_standard_skills(repo_root())
3851
+ if normalized_anchor not in available_stage_skills:
3852
+ allowed = ", ".join(available_stage_skills)
3617
3853
  raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
3618
3854
  if quest_data.get("active_anchor") != normalized_anchor:
3619
3855
  quest_data["active_anchor"] = normalized_anchor
@@ -3670,10 +3906,11 @@ class QuestService:
3670
3906
  normalized_anchor = str(active_anchor or "").strip()
3671
3907
  if not normalized_anchor:
3672
3908
  raise ValueError("`active_anchor` cannot be empty.")
3673
- from ..prompts.builder import STANDARD_SKILLS
3909
+ from ..prompts.builder import current_standard_skills
3674
3910
 
3675
- if normalized_anchor not in STANDARD_SKILLS:
3676
- allowed = ", ".join(STANDARD_SKILLS)
3911
+ available_stage_skills = current_standard_skills(repo_root())
3912
+ if normalized_anchor not in available_stage_skills:
3913
+ allowed = ", ".join(available_stage_skills)
3677
3914
  raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
3678
3915
  if quest_data.get("active_anchor") != normalized_anchor:
3679
3916
  quest_data["active_anchor"] = normalized_anchor
@@ -4285,23 +4522,7 @@ class QuestService:
4285
4522
  },
4286
4523
  }
4287
4524
 
4288
- resolution_root = (
4289
- quest_root
4290
- if document_id.startswith(("questpath::", "memory::"))
4291
- else workspace_root
4292
- )
4293
- try:
4294
- path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
4295
- except FileNotFoundError:
4296
- legacy_relative = None
4297
- if document_id.startswith("path::"):
4298
- legacy_relative = document_id.split("::", 1)[1].lstrip("/")
4299
- if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
4300
- path, writable, scope, source_kind = self._resolve_document(
4301
- quest_root, f"questpath::{legacy_relative}"
4302
- )
4303
- else:
4304
- raise
4525
+ path, writable, scope, source_kind = self.resolve_document(quest_id, document_id)
4305
4526
  renderer_hint, mime_type = self._renderer_hint_for(path)
4306
4527
  is_text = self._is_text_document(path, mime_type, renderer_hint)
4307
4528
  content = read_text(path) if is_text else ""
@@ -4329,6 +4550,24 @@ class QuestService:
4329
4550
  },
4330
4551
  }
4331
4552
 
4553
+ def resolve_document(self, quest_id: str, document_id: str) -> tuple[Path, bool, str, str]:
4554
+ quest_root = self._quest_root(quest_id)
4555
+ workspace_root = self.active_workspace_root(quest_root)
4556
+ resolution_root = self._document_resolution_root(
4557
+ quest_root=quest_root,
4558
+ workspace_root=workspace_root,
4559
+ document_id=document_id,
4560
+ )
4561
+ try:
4562
+ return self._resolve_document(resolution_root, document_id)
4563
+ except FileNotFoundError:
4564
+ legacy_relative = None
4565
+ if document_id.startswith("path::"):
4566
+ legacy_relative = document_id.split("::", 1)[1].lstrip("/")
4567
+ if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
4568
+ return self._resolve_document(quest_root, f"questpath::{legacy_relative}")
4569
+ raise
4570
+
4332
4571
  def save_document(self, quest_id: str, document_id: str, content: str, previous_revision: str | None = None) -> dict:
4333
4572
  current = self.open_document(quest_id, document_id)
4334
4573
  if not current.get("writable", False):
@@ -4828,10 +5067,11 @@ class QuestService:
4828
5067
  if continuation_anchor is not _UNSET:
4829
5068
  normalized_anchor = str(continuation_anchor or "").strip() or None
4830
5069
  if normalized_anchor is not None:
4831
- from ..prompts.builder import STANDARD_SKILLS
5070
+ from ..prompts.builder import current_standard_skills
4832
5071
 
4833
- if normalized_anchor not in STANDARD_SKILLS:
4834
- allowed = ", ".join(STANDARD_SKILLS)
5072
+ available_stage_skills = current_standard_skills(repo_root())
5073
+ if normalized_anchor not in available_stage_skills:
5074
+ allowed = ", ".join(available_stage_skills)
4835
5075
  raise ValueError(
4836
5076
  f"Unsupported continuation anchor `{normalized_anchor}`. Allowed values: {allowed}."
4837
5077
  )
@@ -5132,6 +5372,7 @@ class QuestService:
5132
5372
  connector_hints: dict[str, Any] | None = None,
5133
5373
  created_at: str | None = None,
5134
5374
  counts_as_visible: bool = True,
5375
+ deliver_to_bound_conversations: bool | None = None,
5135
5376
  ) -> dict[str, Any]:
5136
5377
  timestamp = created_at or utc_now()
5137
5378
  payload = {
@@ -5148,6 +5389,11 @@ class QuestService:
5148
5389
  "reply_mode": reply_mode,
5149
5390
  "surface_actions": [dict(item) for item in (surface_actions or []) if isinstance(item, dict)],
5150
5391
  "connector_hints": dict(connector_hints) if isinstance(connector_hints, dict) else {},
5392
+ "deliver_to_bound_conversations": (
5393
+ bool(deliver_to_bound_conversations)
5394
+ if deliver_to_bound_conversations is not None
5395
+ else None
5396
+ ),
5151
5397
  "created_at": timestamp,
5152
5398
  }
5153
5399
  append_jsonl(self._interaction_journal_path(quest_root), payload)
@@ -5397,6 +5643,12 @@ class QuestService:
5397
5643
  "queued_message_count_after_delivery": len(queue_payload.get("pending") or []),
5398
5644
  }
5399
5645
 
5646
+ @staticmethod
5647
+ def _document_resolution_root(quest_root: Path, workspace_root: Path, document_id: str) -> Path:
5648
+ if document_id.startswith(("questpath::", "memory::")):
5649
+ return quest_root
5650
+ return workspace_root
5651
+
5400
5652
  @staticmethod
5401
5653
  def _resolve_document(quest_root: Path, document_id: str) -> tuple[Path, bool, str, str]:
5402
5654
  if document_id.startswith("memory::"):
@@ -234,10 +234,11 @@ class QuestStageViewBuilder:
234
234
 
235
235
  def build(self) -> dict[str, Any]:
236
236
  selection_type = str(self.selection.get("selection_type") or "").strip()
237
+ explicit_stage_key = str(self.selection.get("stage_key") or "").strip()
237
238
  self.stage_key = self._resolve_effective_stage_key()
238
239
  if selection_type == "idea_candidate":
239
240
  return self._build_idea_candidate()
240
- if selection_type == "branch_node" and self.stage_key not in {"experiment", "analysis", "paper"}:
241
+ if selection_type == "branch_node" and not explicit_stage_key:
241
242
  return self._build_branch()
242
243
  if self.stage_key == "baseline":
243
244
  return self._build_baseline()
@@ -1288,11 +1289,15 @@ class QuestStageViewBuilder:
1288
1289
  for item in self.artifacts
1289
1290
  if self._branch_matches(self._payload(item), allow_parent=True, include_unscoped=False)
1290
1291
  ]
1292
+ latest_branch_payload = self._payload(branch_items[-1] if branch_items else {})
1291
1293
  note = (
1292
1294
  str(
1293
1295
  latest_experiment_payload.get("summary")
1294
1296
  or latest_idea_payload.get("summary")
1295
1297
  or latest_idea_payload.get("reason")
1298
+ or latest_branch_payload.get("summary")
1299
+ or latest_branch_payload.get("message")
1300
+ or latest_branch_payload.get("reason")
1296
1301
  or self.trace.get("summary")
1297
1302
  or self.selection.get("summary")
1298
1303
  or ""