@researai/deepscientist 1.5.7 → 1.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +8 -4
  3. package/bin/ds.js +224 -9
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/07_MEMORY_AND_MCP.md +40 -3
  6. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  7. package/docs/zh/00_QUICK_START.md +2 -2
  8. package/docs/zh/07_MEMORY_AND_MCP.md +40 -3
  9. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  10. package/install.sh +34 -0
  11. package/package.json +2 -2
  12. package/pyproject.toml +2 -2
  13. package/src/deepscientist/__init__.py +1 -1
  14. package/src/deepscientist/acp/envelope.py +1 -0
  15. package/src/deepscientist/artifact/metrics.py +814 -83
  16. package/src/deepscientist/artifact/schemas.py +1 -0
  17. package/src/deepscientist/artifact/service.py +2001 -229
  18. package/src/deepscientist/bash_exec/monitor.py +1 -1
  19. package/src/deepscientist/bash_exec/service.py +17 -9
  20. package/src/deepscientist/channels/qq.py +17 -0
  21. package/src/deepscientist/channels/relay.py +16 -0
  22. package/src/deepscientist/config/models.py +6 -0
  23. package/src/deepscientist/config/service.py +70 -2
  24. package/src/deepscientist/daemon/api/handlers.py +414 -14
  25. package/src/deepscientist/daemon/api/router.py +4 -0
  26. package/src/deepscientist/daemon/app.py +292 -21
  27. package/src/deepscientist/gitops/diff.py +6 -10
  28. package/src/deepscientist/mcp/server.py +191 -40
  29. package/src/deepscientist/prompts/builder.py +65 -19
  30. package/src/deepscientist/quest/node_traces.py +129 -2
  31. package/src/deepscientist/quest/service.py +140 -34
  32. package/src/deepscientist/quest/stage_views.py +175 -33
  33. package/src/deepscientist/registries/baseline.py +56 -4
  34. package/src/deepscientist/runners/codex.py +1 -1
  35. package/src/prompts/connectors/qq.md +1 -1
  36. package/src/prompts/contracts/shared_interaction.md +14 -0
  37. package/src/prompts/system.md +113 -32
  38. package/src/skills/analysis-campaign/SKILL.md +10 -14
  39. package/src/skills/baseline/SKILL.md +51 -38
  40. package/src/skills/baseline/references/baseline-plan-template.md +2 -0
  41. package/src/skills/decision/SKILL.md +12 -8
  42. package/src/skills/experiment/SKILL.md +28 -16
  43. package/src/skills/experiment/references/main-experiment-plan-template.md +2 -0
  44. package/src/skills/figure-polish/SKILL.md +1 -0
  45. package/src/skills/finalize/SKILL.md +3 -8
  46. package/src/skills/idea/SKILL.md +18 -8
  47. package/src/skills/idea/references/literature-survey-template.md +24 -0
  48. package/src/skills/idea/references/related-work-playbook.md +4 -0
  49. package/src/skills/idea/references/selection-gate.md +9 -0
  50. package/src/skills/intake-audit/SKILL.md +2 -8
  51. package/src/skills/rebuttal/SKILL.md +2 -8
  52. package/src/skills/review/SKILL.md +2 -8
  53. package/src/skills/scout/SKILL.md +2 -8
  54. package/src/skills/write/SKILL.md +53 -17
  55. package/src/skills/write/templates/DEEPSCIENTIST_NOTES.md +21 -0
  56. package/src/skills/write/templates/README.md +408 -0
  57. package/src/skills/write/templates/UPSTREAM_LICENSE.txt +21 -0
  58. package/src/skills/write/templates/aaai2026/README.md +534 -0
  59. package/src/skills/write/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  60. package/src/skills/write/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  61. package/src/skills/write/templates/aaai2026/aaai2026.bib +111 -0
  62. package/src/skills/write/templates/aaai2026/aaai2026.bst +1493 -0
  63. package/src/skills/write/templates/aaai2026/aaai2026.sty +315 -0
  64. package/src/skills/write/templates/acl/README.md +50 -0
  65. package/src/skills/write/templates/acl/acl.sty +312 -0
  66. package/src/skills/write/templates/acl/acl_latex.tex +377 -0
  67. package/src/skills/write/templates/acl/acl_lualatex.tex +101 -0
  68. package/src/skills/write/templates/acl/acl_natbib.bst +1940 -0
  69. package/src/skills/write/templates/acl/anthology.bib.txt +26 -0
  70. package/src/skills/write/templates/acl/custom.bib +70 -0
  71. package/src/skills/write/templates/acl/formatting.md +326 -0
  72. package/src/skills/write/templates/asplos2027/main.tex +459 -0
  73. package/src/skills/write/templates/asplos2027/references.bib +135 -0
  74. package/src/skills/write/templates/colm2025/README.md +3 -0
  75. package/src/skills/write/templates/colm2025/colm2025_conference.bib +11 -0
  76. package/src/skills/write/templates/colm2025/colm2025_conference.bst +1440 -0
  77. package/src/skills/write/templates/colm2025/colm2025_conference.sty +218 -0
  78. package/src/skills/write/templates/colm2025/colm2025_conference.tex +305 -0
  79. package/src/skills/write/templates/colm2025/fancyhdr.sty +485 -0
  80. package/src/skills/write/templates/colm2025/math_commands.tex +508 -0
  81. package/src/skills/write/templates/colm2025/natbib.sty +1246 -0
  82. package/src/skills/write/templates/iclr2026/fancyhdr.sty +485 -0
  83. package/src/skills/write/templates/iclr2026/iclr2026_conference.bib +24 -0
  84. package/src/skills/write/templates/iclr2026/iclr2026_conference.bst +1440 -0
  85. package/src/skills/write/templates/iclr2026/iclr2026_conference.sty +246 -0
  86. package/src/skills/write/templates/iclr2026/iclr2026_conference.tex +414 -0
  87. package/src/skills/write/templates/iclr2026/math_commands.tex +508 -0
  88. package/src/skills/write/templates/iclr2026/natbib.sty +1246 -0
  89. package/src/skills/write/templates/icml2026/algorithm.sty +79 -0
  90. package/src/skills/write/templates/icml2026/algorithmic.sty +201 -0
  91. package/src/skills/write/templates/icml2026/example_paper.bib +75 -0
  92. package/src/skills/write/templates/icml2026/example_paper.tex +662 -0
  93. package/src/skills/write/templates/icml2026/fancyhdr.sty +864 -0
  94. package/src/skills/write/templates/icml2026/icml2026.bst +1443 -0
  95. package/src/skills/write/templates/icml2026/icml2026.sty +767 -0
  96. package/src/skills/write/templates/neurips2025/Makefile +36 -0
  97. package/src/skills/write/templates/neurips2025/extra_pkgs.tex +53 -0
  98. package/src/skills/write/templates/neurips2025/main.tex +38 -0
  99. package/src/skills/write/templates/neurips2025/neurips.sty +382 -0
  100. package/src/skills/write/templates/nsdi2027/main.tex +426 -0
  101. package/src/skills/write/templates/nsdi2027/references.bib +151 -0
  102. package/src/skills/write/templates/nsdi2027/usenix-2020-09.sty +83 -0
  103. package/src/skills/write/templates/osdi2026/main.tex +429 -0
  104. package/src/skills/write/templates/osdi2026/references.bib +150 -0
  105. package/src/skills/write/templates/osdi2026/usenix-2020-09.sty +83 -0
  106. package/src/skills/write/templates/sosp2026/main.tex +532 -0
  107. package/src/skills/write/templates/sosp2026/references.bib +148 -0
  108. package/src/tui/package.json +1 -1
  109. package/src/ui/dist/assets/{AiManusChatView-BS3V4ZOk.js → AiManusChatView-BKZ103sn.js} +110 -14
  110. package/src/ui/dist/assets/{AnalysisPlugin-DLPXQsmr.js → AnalysisPlugin-mTTzGAlK.js} +1 -1
  111. package/src/ui/dist/assets/{AutoFigurePlugin-C-Fr9knQ.js → AutoFigurePlugin-C_wWw4AP.js} +5 -5
  112. package/src/ui/dist/assets/{CliPlugin-Dd8AHzFg.js → CliPlugin-BH58n3GY.js} +9 -9
  113. package/src/ui/dist/assets/{CodeEditorPlugin-Dg-RepTl.js → CodeEditorPlugin-BKGRUH7e.js} +8 -8
  114. package/src/ui/dist/assets/{CodeViewerPlugin-D2J_3nyt.js → CodeViewerPlugin-BMADwFWJ.js} +5 -5
  115. package/src/ui/dist/assets/{DocViewerPlugin-ChRLLKNb.js → DocViewerPlugin-ZOnTIHLN.js} +3 -3
  116. package/src/ui/dist/assets/{GitDiffViewerPlugin-DgHfcved.js → GitDiffViewerPlugin-CQ7h1Djm.js} +830 -86
  117. package/src/ui/dist/assets/{ImageViewerPlugin-C89GZMBy.js → ImageViewerPlugin-GVS5MsnC.js} +5 -5
  118. package/src/ui/dist/assets/{LabCopilotPanel-BUfIwUcb.js → LabCopilotPanel-BZNv1JML.js} +10 -10
  119. package/src/ui/dist/assets/{LabPlugin-zvUmQUMq.js → LabPlugin-TWcJsdQA.js} +1 -1
  120. package/src/ui/dist/assets/{LatexPlugin-C1SSNuWp.js → LatexPlugin-DIjHiR2x.js} +7 -7
  121. package/src/ui/dist/assets/{MarkdownViewerPlugin-D2Mf5tU5.js → MarkdownViewerPlugin-D3ooGAH0.js} +4 -4
  122. package/src/ui/dist/assets/{MarketplacePlugin-CF4LgiS2.js → MarketplacePlugin-DfVfE9hN.js} +3 -3
  123. package/src/ui/dist/assets/{NotebookEditor-BM7Bgwlv.js → NotebookEditor-DDl0_Mc0.js} +1 -1
  124. package/src/ui/dist/assets/{index-Be0NAmh8.js → NotebookEditor-s8JhzuX1.js} +12 -155
  125. package/src/ui/dist/assets/{PdfLoader-Bc5qfD-Z.js → PdfLoader-C2Sf6SJM.js} +1 -1
  126. package/src/ui/dist/assets/{PdfMarkdownPlugin-sh1-IRcp.js → PdfMarkdownPlugin-CXFLoIsa.js} +3 -3
  127. package/src/ui/dist/assets/{PdfViewerPlugin-C_a7CpWG.js → PdfViewerPlugin-BYTmz2fK.js} +10 -10
  128. package/src/ui/dist/assets/{SearchPlugin-L4z3HcLf.js → SearchPlugin-CjWBI1O9.js} +1 -1
  129. package/src/ui/dist/assets/{Stepper-Dk4aQ3fN.js → Stepper-B0Dd8CxK.js} +1 -1
  130. package/src/ui/dist/assets/{TextViewerPlugin-BsNtlKVo.js → TextViewerPlugin-DdOBU3-S.js} +4 -4
  131. package/src/ui/dist/assets/{VNCViewer-BpeDcZ5_.js → VNCViewer-B8HGgLwQ.js} +9 -9
  132. package/src/ui/dist/assets/{bibtex-C4QI-bbj.js → bibtex-CKaefIN2.js} +1 -1
  133. package/src/ui/dist/assets/{code-DuMINRsg.js → code-BWAY76JP.js} +1 -1
  134. package/src/ui/dist/assets/{file-content-C3N-432K.js → file-content-C1NwU5oQ.js} +1 -1
  135. package/src/ui/dist/assets/{file-diff-panel-CffQ4ZMg.js → file-diff-panel-CywslwB9.js} +1 -1
  136. package/src/ui/dist/assets/{file-socket-CRH59PCO.js → file-socket-B4kzuOBQ.js} +1 -1
  137. package/src/ui/dist/assets/{file-utils-vYGtW2mI.js → file-utils-H2fjA46S.js} +1 -1
  138. package/src/ui/dist/assets/{image-DBVGaooo.js → image-D-NZM-6P.js} +1 -1
  139. package/src/ui/dist/assets/{index-B1P6hQRJ.js → index-7Chr1g9c.js} +3734 -1862
  140. package/src/ui/dist/assets/{index-DjSFDmgB.js → index-BdM1Gqfr.js} +2 -2
  141. package/src/ui/dist/assets/{index-BpjYH9Vg.js → index-CDxNdQdz.js} +1 -1
  142. package/src/ui/dist/assets/{index-Do9N28uB.css → index-DGIYDuTv.css} +163 -34
  143. package/src/ui/dist/assets/index-DHZJ_0TI.js +159 -0
  144. package/src/ui/dist/assets/{message-square-BsPDBhiY.js → message-square-BzjLiXir.js} +1 -1
  145. package/src/ui/dist/assets/{monaco-BTkdPojV.js → monaco-Cb2uKKe6.js} +1 -1
  146. package/src/ui/dist/assets/{popover-cWjCk-vc.js → popover-Bg72DGgT.js} +1 -1
  147. package/src/ui/dist/assets/{project-sync-CXn530xb.js → project-sync-Ce_0BglY.js} +1 -1
  148. package/src/ui/dist/assets/{sigma-04Jr12jg.js → sigma-DPaACDrh.js} +1 -1
  149. package/src/ui/dist/assets/{tooltip-BdVDl0G5.js → tooltip-C_mA6R0w.js} +1 -1
  150. package/src/ui/dist/assets/{trash-CB_GlQyC.js → trash-BvTgE5__.js} +1 -1
  151. package/src/ui/dist/assets/{useCliAccess-BL932NwS.js → useCliAccess-CgPeMOwP.js} +1 -1
  152. package/src/ui/dist/assets/{useFileDiffOverlay-B2WK7Tvq.js → useFileDiffOverlay-xPhz7P5B.js} +1 -1
  153. package/src/ui/dist/assets/{wrap-text-YC68g12z.js → wrap-text-C3Un3YQr.js} +1 -1
  154. package/src/ui/dist/assets/{zoom-out-C0RJvFiJ.js → zoom-out-BgxLa0Ri.js} +1 -1
  155. package/src/ui/dist/index.html +5 -2
  156. /package/src/ui/dist/assets/{index-CccQYZjX.css → NotebookEditor-CccQYZjX.css} +0 -0
@@ -46,8 +46,10 @@ def _infer_stage_from_skill(skill_id: object) -> str | None:
46
46
  return "baseline"
47
47
  if normalized in {"idea", "scout+idea"}:
48
48
  return "idea"
49
- if normalized in {"experiment", "analysis-campaign", "analysis"}:
49
+ if normalized == "experiment":
50
50
  return "experiment"
51
+ if normalized in {"analysis-campaign", "analysis", "analysis_slice"}:
52
+ return "analysis"
51
53
  if normalized in {"write", "finalize"}:
52
54
  return "writing"
53
55
  if normalized == "decision":
@@ -103,6 +105,56 @@ def _load_artifact_record(path: Path) -> dict[str, Any] | None:
103
105
  return None
104
106
 
105
107
 
108
+ def _normalize_paths_map(value: object) -> dict[str, str | None]:
109
+ if not isinstance(value, dict):
110
+ return {}
111
+ normalized: dict[str, str | None] = {}
112
+ for raw_key, raw_value in value.items():
113
+ key = str(raw_key or "").strip()
114
+ if not key:
115
+ continue
116
+ if raw_value is None:
117
+ normalized[key] = None
118
+ continue
119
+ text = str(raw_value).strip()
120
+ normalized[key] = text or None
121
+ return normalized
122
+
123
+
124
+ def _normalize_string_list(value: object) -> list[str]:
125
+ if not isinstance(value, list):
126
+ return []
127
+ items: list[str] = []
128
+ seen: set[str] = set()
129
+ for raw in value:
130
+ text = str(raw or "").strip()
131
+ if not text or text in seen:
132
+ continue
133
+ seen.add(text)
134
+ items.append(text)
135
+ return items
136
+
137
+
138
+ def _run_artifact_context(quest_root: Path, run_id: str | None) -> dict[str, Any] | None:
139
+ normalized = str(run_id or "").strip()
140
+ if not normalized:
141
+ return None
142
+ path = quest_root / ".ds" / "runs" / normalized / "artifact.json"
143
+ payload = read_json(path, {})
144
+ if not isinstance(payload, dict) or not payload:
145
+ return None
146
+ record = dict(payload.get("record") or {}) if isinstance(payload.get("record"), dict) else {}
147
+ checkpoint = dict(payload.get("checkpoint") or {}) if isinstance(payload.get("checkpoint"), dict) else {}
148
+ return {
149
+ "path": str(path),
150
+ "record": record,
151
+ "checkpoint": checkpoint,
152
+ "head_commit": str(checkpoint.get("head") or record.get("head_commit") or "").strip() or None,
153
+ "paths_map": _normalize_paths_map(record.get("paths")),
154
+ "changed_files": _normalize_string_list(record.get("files_changed")),
155
+ }
156
+
157
+
106
158
  def _build_run_contexts(quest_root: Path, *, default_branch: str) -> dict[str, dict[str, Any]]:
107
159
  contexts: dict[str, dict[str, Any]] = {}
108
160
  artifact_roots = [quest_root / "artifacts"]
@@ -145,6 +197,7 @@ def _build_run_contexts(quest_root: Path, *, default_branch: str) -> dict[str, d
145
197
  current["worktree_rel_path"] = current.get("worktree_rel_path") or record.get("worktree_rel_path")
146
198
  current["summary"] = current.get("summary") or summary
147
199
  current["updated_at"] = current.get("updated_at") or updated_at
200
+ current["artifact_path"] = current.get("artifact_path") or str(artifact_path)
148
201
  contexts[run_id] = current
149
202
  return contexts
150
203
 
@@ -160,6 +213,7 @@ def _resolve_entry_context(
160
213
  raw_event_type = str(entry.get("raw_event_type") or entry.get("kind") or "").strip()
161
214
  run_context = run_contexts.get(run_id or "")
162
215
  artifact_context: dict[str, Any] | None = None
216
+ artifact_path: str | None = None
163
217
  for raw_path in entry.get("paths") or []:
164
218
  try:
165
219
  path = Path(str(raw_path))
@@ -169,14 +223,21 @@ def _resolve_entry_context(
169
223
  continue
170
224
  artifact_context = _load_artifact_record(path)
171
225
  if artifact_context:
226
+ artifact_path = str(path)
172
227
  break
228
+ if run_id is None and artifact_context:
229
+ run_id = str(artifact_context.get("run_id") or "").strip() or None
230
+ run_artifact = _run_artifact_context(quest_root, run_id)
173
231
  branch_name = _normalize_branch_name(
174
- (artifact_context or {}).get("branch") or (run_context or {}).get("branch_name"),
232
+ (artifact_context or {}).get("branch")
233
+ or (((run_artifact or {}).get("record") or {}) if isinstance((run_artifact or {}).get("record"), dict) else {}).get("branch")
234
+ or (run_context or {}).get("branch_name"),
175
235
  fallback=default_branch,
176
236
  )
177
237
  stage_key = (
178
238
  _infer_stage_from_skill(entry.get("skill_id"))
179
239
  or _infer_stage_from_artifact(artifact_context or {})
240
+ or _infer_stage_from_artifact((((run_artifact or {}).get("record") or {}) if run_artifact else {}))
180
241
  or (run_context or {}).get("stage_key")
181
242
  or _infer_stage_from_event_type(raw_event_type)
182
243
  or "general"
@@ -186,9 +247,15 @@ def _resolve_entry_context(
186
247
  "branch_name": branch_name,
187
248
  "stage_key": stage_key,
188
249
  "worktree_rel_path": (artifact_context or {}).get("worktree_rel_path")
250
+ or (((run_artifact or {}).get("record") or {}) if run_artifact else {}).get("worktree_rel_path")
189
251
  or (run_context or {}).get("worktree_rel_path"),
252
+ "artifact_context": artifact_context,
253
+ "artifact_path": artifact_path or (run_context or {}).get("artifact_path"),
254
+ "run_artifact": run_artifact,
190
255
  "trace_confidence": "artifact"
191
256
  if artifact_context
257
+ else "run_artifact"
258
+ if run_artifact
192
259
  else "run_context"
193
260
  if run_context
194
261
  else "default_branch",
@@ -196,6 +263,25 @@ def _resolve_entry_context(
196
263
 
197
264
 
198
265
  def _build_action(entry: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
266
+ artifact_context = (
267
+ dict(context.get("artifact_context") or {})
268
+ if isinstance(context.get("artifact_context"), dict)
269
+ else {}
270
+ )
271
+ run_artifact = (
272
+ dict(context.get("run_artifact") or {})
273
+ if isinstance(context.get("run_artifact"), dict)
274
+ else {}
275
+ )
276
+ run_record = dict(run_artifact.get("record") or {}) if isinstance(run_artifact.get("record"), dict) else {}
277
+ paths_map = _normalize_paths_map(artifact_context.get("paths")) or _normalize_paths_map(run_record.get("paths"))
278
+ changed_files = _normalize_string_list(artifact_context.get("files_changed")) or _normalize_string_list(
279
+ artifact_context.get("changed_files")
280
+ ) or _normalize_string_list(run_record.get("files_changed")) or _normalize_string_list(run_artifact.get("changed_files"))
281
+ details_json = dict(artifact_context.get("details") or {}) if isinstance(artifact_context.get("details"), dict) else {}
282
+ checkpoint_json = dict(run_artifact.get("checkpoint") or {}) if isinstance(run_artifact.get("checkpoint"), dict) else {}
283
+ metadata = dict(entry.get("metadata") or {}) if isinstance(entry.get("metadata"), dict) else {}
284
+ payload_json = artifact_context or metadata or None
199
285
  return {
200
286
  "action_id": entry.get("id"),
201
287
  "kind": entry.get("kind"),
@@ -217,6 +303,22 @@ def _build_action(entry: dict[str, Any], context: dict[str, Any]) -> dict[str, A
217
303
  "reason": entry.get("reason"),
218
304
  "raw_event_type": entry.get("raw_event_type"),
219
305
  "paths": [str(item) for item in (entry.get("paths") or []) if item],
306
+ "paths_map": paths_map or None,
307
+ "artifact_id": artifact_context.get("artifact_id"),
308
+ "artifact_kind": artifact_context.get("kind"),
309
+ "artifact_path": context.get("artifact_path"),
310
+ "head_commit": (
311
+ str(
312
+ artifact_context.get("head_commit")
313
+ or run_artifact.get("head_commit")
314
+ or ""
315
+ ).strip()
316
+ or None
317
+ ),
318
+ "payload_json": payload_json,
319
+ "details_json": details_json or (metadata or None),
320
+ "checkpoint_json": checkpoint_json or None,
321
+ "changed_files": changed_files or None,
220
322
  "trace_confidence": context.get("trace_confidence"),
221
323
  }
222
324
 
@@ -277,6 +379,24 @@ def _build_trace_item(
277
379
  ) or None
278
380
  run_ids = sorted({str(item.get("run_id") or "").strip() for item in ordered_actions if item.get("run_id")})
279
381
  skill_ids = sorted({str(item.get("skill_id") or "").strip() for item in ordered_actions if item.get("skill_id")})
382
+ primary_action = next(
383
+ (
384
+ action
385
+ for action in reversed(ordered_actions)
386
+ if action.get("artifact_id")
387
+ or action.get("head_commit")
388
+ or action.get("payload_json")
389
+ or action.get("details_json")
390
+ ),
391
+ ordered_actions[-1] if ordered_actions else {},
392
+ )
393
+ merged_paths_map: dict[str, str | None] = {}
394
+ for action in ordered_actions:
395
+ if isinstance(action.get("paths_map"), dict):
396
+ merged_paths_map.update(action.get("paths_map") or {})
397
+ merged_changed_files = _normalize_string_list(
398
+ [item for action in ordered_actions for item in (action.get("changed_files") or [])]
399
+ )
280
400
  return {
281
401
  "selection_type": selection_type,
282
402
  "selection_ref": selection_ref,
@@ -291,6 +411,13 @@ def _build_trace_item(
291
411
  "counts": _build_counts(ordered_actions),
292
412
  "run_ids": run_ids,
293
413
  "skill_ids": skill_ids,
414
+ "artifact_id": primary_action.get("artifact_id"),
415
+ "artifact_kind": primary_action.get("artifact_kind"),
416
+ "head_commit": primary_action.get("head_commit"),
417
+ "payload_json": primary_action.get("payload_json"),
418
+ "details_json": primary_action.get("details_json"),
419
+ "paths_map": merged_paths_map or None,
420
+ "changed_files": merged_changed_files or None,
294
421
  "actions": ordered_actions,
295
422
  }
296
423
 
@@ -17,11 +17,12 @@ try:
17
17
  except ImportError: # pragma: no cover
18
18
  fcntl = None
19
19
 
20
- from ..artifact.metrics import build_metrics_timeline, normalize_metrics_summary
20
+ from ..artifact.metrics import build_metrics_timeline, extract_latest_metric
21
21
  from ..config import ConfigManager
22
22
  from ..connector_runtime import conversation_identity_key, normalize_conversation_id
23
23
  from ..gitops import current_branch, export_git_graph, head_commit, init_repo
24
24
  from ..home import repo_root
25
+ from ..registries import BaselineRegistry
25
26
  from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
26
27
  from ..skills import SkillInstaller
27
28
  from ..web_search import extract_web_search_payload
@@ -48,6 +49,7 @@ class QuestService:
48
49
  self.home = home
49
50
  self.quests_root = home / "quests"
50
51
  self.skill_installer = skill_installer
52
+ self.baseline_registry = BaselineRegistry(home)
51
53
  self._file_cache_lock = threading.Lock()
52
54
  self._file_cache: dict[str, dict[str, Any]] = {}
53
55
  self._jsonl_cache_lock = threading.Lock()
@@ -116,6 +118,10 @@ class QuestService:
116
118
  def _research_state_path(quest_root: Path) -> Path:
117
119
  return quest_root / ".ds" / "research_state.json"
118
120
 
121
+ @staticmethod
122
+ def _lab_canvas_state_path(quest_root: Path) -> Path:
123
+ return quest_root / ".ds" / "lab_canvas_state.json"
124
+
119
125
  def _default_research_state(self, quest_root: Path) -> dict[str, Any]:
120
126
  return {
121
127
  "version": 1,
@@ -129,12 +135,27 @@ class QuestService:
129
135
  "active_analysis_campaign_id": None,
130
136
  "analysis_parent_branch": None,
131
137
  "analysis_parent_worktree_root": None,
138
+ "paper_parent_branch": None,
139
+ "paper_parent_worktree_root": None,
140
+ "paper_parent_run_id": None,
132
141
  "next_pending_slice_id": None,
133
142
  "workspace_mode": "quest",
134
143
  "last_flow_type": None,
135
144
  "updated_at": utc_now(),
136
145
  }
137
146
 
147
+ def _default_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
148
+ return {
149
+ "version": 1,
150
+ "layout_json": {
151
+ "branch": {},
152
+ "event": {},
153
+ "stage": {},
154
+ "preferences": {},
155
+ },
156
+ "updated_at": utc_now(),
157
+ }
158
+
138
159
  def read_research_state(self, quest_root: Path) -> dict[str, Any]:
139
160
  self._initialize_runtime_files(quest_root)
140
161
  defaults = self._default_research_state(quest_root)
@@ -151,6 +172,9 @@ class QuestService:
151
172
  parent_root = str(merged.get("analysis_parent_worktree_root") or "").strip()
152
173
  if parent_root and not Path(parent_root).exists():
153
174
  merged["analysis_parent_worktree_root"] = None
175
+ paper_parent_root = str(merged.get("paper_parent_worktree_root") or "").strip()
176
+ if paper_parent_root and not Path(paper_parent_root).exists():
177
+ merged["paper_parent_worktree_root"] = None
154
178
  return merged
155
179
 
156
180
  def write_research_state(self, quest_root: Path, payload: dict[str, Any]) -> dict[str, Any]:
@@ -166,6 +190,39 @@ class QuestService:
166
190
  current[key] = str(value) if isinstance(value, Path) else value
167
191
  return self.write_research_state(quest_root, current)
168
192
 
193
+ def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
194
+ self._initialize_runtime_files(quest_root)
195
+ defaults = self._default_lab_canvas_state(quest_root)
196
+ payload = self._read_cached_json(self._lab_canvas_state_path(quest_root), defaults)
197
+ if not isinstance(payload, dict):
198
+ payload = defaults
199
+ merged = {**defaults, **payload}
200
+ layout_json = dict(merged.get("layout_json") or {}) if isinstance(merged.get("layout_json"), dict) else {}
201
+ for key in ("branch", "event", "stage", "preferences"):
202
+ if not isinstance(layout_json.get(key), dict):
203
+ layout_json[key] = {}
204
+ merged["layout_json"] = layout_json
205
+ return merged
206
+
207
+ def update_lab_canvas_state(
208
+ self,
209
+ quest_root: Path,
210
+ *,
211
+ layout_json: dict[str, Any] | None = None,
212
+ ) -> dict[str, Any]:
213
+ current = self.read_lab_canvas_state(quest_root)
214
+ normalized_layout = dict(layout_json or {}) if isinstance(layout_json, dict) else {}
215
+ for key in ("branch", "event", "stage", "preferences"):
216
+ if not isinstance(normalized_layout.get(key), dict):
217
+ normalized_layout[key] = {}
218
+ payload = {
219
+ **current,
220
+ "layout_json": normalized_layout,
221
+ "updated_at": utc_now(),
222
+ }
223
+ write_json(self._lab_canvas_state_path(quest_root), payload)
224
+ return payload
225
+
169
226
  def workspace_roots(self, quest_root: Path) -> list[Path]:
170
227
  roots: list[Path] = [quest_root]
171
228
  state = self.read_research_state(quest_root)
@@ -206,9 +263,37 @@ class QuestService:
206
263
  def _artifact_roots(self, quest_root: Path) -> list[Path]:
207
264
  return [root for root in self.workspace_roots(quest_root) if (root / "artifacts").exists()]
208
265
 
266
+ @staticmethod
267
+ def _artifact_item_identity(path: Path, payload: dict[str, Any], *, kind: str) -> str:
268
+ normalized_kind = str(kind or payload.get("kind") or path.parent.name or "artifact").strip() or "artifact"
269
+ artifact_id = str(payload.get("artifact_id") or payload.get("id") or "").strip()
270
+ if artifact_id:
271
+ return f"{normalized_kind}:artifact:{artifact_id}"
272
+ branch_name = str(payload.get("branch") or "").strip()
273
+ run_id = str(payload.get("run_id") or "").strip()
274
+ if normalized_kind == "runs" and run_id and branch_name:
275
+ return f"{normalized_kind}:branch_run:{branch_name}:{run_id}"
276
+ if normalized_kind == "runs" and run_id:
277
+ return f"{normalized_kind}:run:{run_id}"
278
+ idea_id = str(payload.get("idea_id") or "").strip()
279
+ if normalized_kind == "ideas" and idea_id and branch_name:
280
+ return f"{normalized_kind}:branch_idea:{branch_name}:{idea_id}"
281
+ if normalized_kind == "ideas" and idea_id:
282
+ return f"{normalized_kind}:idea:{idea_id}"
283
+ return f"path:{path.resolve()}"
284
+
285
+ @staticmethod
286
+ def _artifact_item_rank(payload: dict[str, Any], *, path: Path, mtime_ns: int) -> tuple[str, str, int, int, str]:
287
+ return (
288
+ str(payload.get("updated_at") or ""),
289
+ str(payload.get("created_at") or ""),
290
+ len(payload),
291
+ mtime_ns,
292
+ str(path),
293
+ )
294
+
209
295
  def _collect_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
210
- artifacts: list[dict[str, Any]] = []
211
- seen_paths: set[str] = set()
296
+ artifacts_by_identity: dict[str, dict[str, Any]] = {}
212
297
  for root in self._artifact_roots(quest_root):
213
298
  artifacts_root = root / "artifacts"
214
299
  if not artifacts_root.exists():
@@ -217,19 +302,37 @@ class QuestService:
217
302
  if not folder.is_dir():
218
303
  continue
219
304
  for path in sorted(folder.glob("*.json")):
220
- resolved_key = str(path.resolve())
221
- if resolved_key in seen_paths:
222
- continue
223
- seen_paths.add(resolved_key)
224
305
  item = self._read_cached_json(path, {})
225
- artifacts.append(
226
- {
227
- "kind": folder.name,
228
- "path": str(path),
229
- "payload": item,
230
- "workspace_root": str(root),
231
- }
232
- )
306
+ payload = item if isinstance(item, dict) else {}
307
+ try:
308
+ mtime_ns = path.stat().st_mtime_ns
309
+ except OSError:
310
+ mtime_ns = 0
311
+ artifact = {
312
+ "kind": folder.name,
313
+ "path": str(path),
314
+ "payload": item,
315
+ "workspace_root": str(root),
316
+ }
317
+ identity = self._artifact_item_identity(path, payload, kind=folder.name)
318
+ existing = artifacts_by_identity.get(identity)
319
+ existing_payload = existing.get("payload") if isinstance((existing or {}).get("payload"), dict) else {}
320
+ existing_path = Path(str((existing or {}).get("path") or path))
321
+ try:
322
+ existing_mtime_ns = existing_path.stat().st_mtime_ns if existing else 0
323
+ except OSError:
324
+ existing_mtime_ns = 0
325
+ if existing is None or self._artifact_item_rank(
326
+ payload,
327
+ path=path,
328
+ mtime_ns=mtime_ns,
329
+ ) >= self._artifact_item_rank(
330
+ existing_payload,
331
+ path=existing_path,
332
+ mtime_ns=existing_mtime_ns,
333
+ ):
334
+ artifacts_by_identity[identity] = artifact
335
+ artifacts = list(artifacts_by_identity.values())
233
336
  artifacts.sort(
234
337
  key=lambda item: str(
235
338
  ((item.get("payload") or {}).get("updated_at"))
@@ -253,6 +356,9 @@ class QuestService:
253
356
  continue
254
357
  seen_paths.add(key)
255
358
  payload = self._read_cached_yaml(path, {})
359
+ baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
360
+ if baseline_id and self.baseline_registry.is_deleted(baseline_id):
361
+ continue
256
362
  if isinstance(payload, dict) and payload:
257
363
  attachments.append(payload)
258
364
  if not attachments:
@@ -267,22 +373,7 @@ class QuestService:
267
373
 
268
374
  @staticmethod
269
375
  def _latest_metric_from_payload(payload: dict[str, Any]) -> dict[str, Any] | None:
270
- metrics_summary = normalize_metrics_summary(payload.get("metrics_summary"))
271
- if not metrics_summary:
272
- return None
273
- progress_eval = payload.get("progress_eval") if isinstance(payload.get("progress_eval"), dict) else {}
274
- comparisons = payload.get("baseline_comparisons") if isinstance(payload.get("baseline_comparisons"), dict) else {}
275
- primary_metric_id = (
276
- str(progress_eval.get("primary_metric_id") or comparisons.get("primary_metric_id") or "").strip()
277
- or next(iter(metrics_summary.keys()))
278
- )
279
- result = {
280
- "key": primary_metric_id,
281
- "value": metrics_summary.get(primary_metric_id),
282
- }
283
- if progress_eval.get("delta_vs_baseline") is not None:
284
- result["delta_vs_baseline"] = progress_eval.get("delta_vs_baseline")
285
- return result
376
+ return extract_latest_metric(payload)
286
377
 
287
378
  @staticmethod
288
379
  def _parse_numeric_quest_id(value: str | None) -> int | None:
@@ -980,6 +1071,9 @@ class QuestService:
980
1071
  "active_analysis_campaign_id": research_state.get("active_analysis_campaign_id"),
981
1072
  "analysis_parent_branch": research_state.get("analysis_parent_branch"),
982
1073
  "analysis_parent_worktree_root": research_state.get("analysis_parent_worktree_root"),
1074
+ "paper_parent_branch": research_state.get("paper_parent_branch"),
1075
+ "paper_parent_worktree_root": research_state.get("paper_parent_worktree_root"),
1076
+ "paper_parent_run_id": research_state.get("paper_parent_run_id"),
983
1077
  "next_pending_slice_id": research_state.get("next_pending_slice_id"),
984
1078
  "workspace_mode": research_state.get("workspace_mode") or "quest",
985
1079
  "active_baseline_id": active_baseline_id,
@@ -1981,7 +2075,11 @@ class QuestService:
1981
2075
  },
1982
2076
  }
1983
2077
 
1984
- resolution_root = quest_root if document_id.startswith("questpath::") else workspace_root
2078
+ resolution_root = (
2079
+ quest_root
2080
+ if document_id.startswith(("questpath::", "memory::"))
2081
+ else workspace_root
2082
+ )
1985
2083
  path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
1986
2084
  renderer_hint, mime_type = self._renderer_hint_for(path)
1987
2085
  is_text = self._is_text_document(path, mime_type, renderer_hint)
@@ -2162,11 +2260,16 @@ class QuestService:
2162
2260
  asset_name = f"{safe_stem}-{generate_id('asset').split('-', 1)[1]}{asset_suffix}"
2163
2261
  asset_relative_dir = self._markdown_asset_directory(base_relative)
2164
2262
  asset_relative = (asset_relative_dir / asset_name).as_posix()
2165
- asset_root = quest_root if document_id.startswith("questpath::") else workspace_root
2263
+ asset_root = (
2264
+ quest_root
2265
+ if document_id.startswith(("questpath::", "memory::"))
2266
+ else workspace_root
2267
+ )
2166
2268
  asset_path = resolve_within(asset_root, asset_relative)
2167
2269
  ensure_dir(asset_path.parent)
2168
2270
  asset_path.write_bytes(content)
2169
- asset_document_id = f"{'questpath' if document_id.startswith('questpath::') else 'path'}::{asset_relative}"
2271
+ asset_document_scope = "questpath" if document_id.startswith(("questpath::", "memory::")) else "path"
2272
+ asset_document_id = f"{asset_document_scope}::{asset_relative}"
2170
2273
  relative_markdown_path = self._relative_path_from_base(base_relative, asset_relative)
2171
2274
  return {
2172
2275
  "ok": True,
@@ -2368,6 +2471,9 @@ class QuestService:
2368
2471
  research_state_path = self._research_state_path(quest_root)
2369
2472
  if not research_state_path.exists():
2370
2473
  write_json(research_state_path, self._default_research_state(quest_root))
2474
+ lab_canvas_state_path = self._lab_canvas_state_path(quest_root)
2475
+ if not lab_canvas_state_path.exists():
2476
+ write_json(lab_canvas_state_path, self._default_lab_canvas_state(quest_root))
2371
2477
  agent_status_path = self._agent_status_path(quest_root)
2372
2478
  if not agent_status_path.exists():
2373
2479
  write_json(agent_status_path, self._default_agent_status(quest_root))