@researai/deepscientist 1.5.9 → 1.5.11

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 (140) hide show
  1. package/README.md +107 -94
  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 +168 -9
  7. package/docs/assets/branding/projects.png +0 -0
  8. package/docs/en/00_QUICK_START.md +308 -70
  9. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  10. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  11. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  12. package/docs/en/09_DOCTOR.md +41 -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 +427 -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/99_ACKNOWLEDGEMENTS.md +4 -1
  19. package/docs/en/README.md +79 -0
  20. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  21. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  23. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  24. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  25. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  26. package/docs/zh/00_QUICK_START.md +315 -74
  27. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  28. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  29. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  30. package/docs/zh/09_DOCTOR.md +41 -5
  31. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  32. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  33. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  34. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  35. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  36. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  37. package/docs/zh/README.md +126 -0
  38. package/install.sh +0 -34
  39. package/package.json +2 -2
  40. package/pyproject.toml +1 -1
  41. package/src/deepscientist/__init__.py +1 -1
  42. package/src/deepscientist/annotations.py +343 -0
  43. package/src/deepscientist/artifact/arxiv.py +484 -37
  44. package/src/deepscientist/artifact/service.py +574 -108
  45. package/src/deepscientist/arxiv_library.py +275 -0
  46. package/src/deepscientist/bash_exec/service.py +9 -0
  47. package/src/deepscientist/bridges/builtins.py +2 -0
  48. package/src/deepscientist/bridges/connectors.py +447 -0
  49. package/src/deepscientist/channels/__init__.py +2 -0
  50. package/src/deepscientist/channels/builtins.py +3 -1
  51. package/src/deepscientist/channels/qq.py +1 -1
  52. package/src/deepscientist/channels/qq_gateway.py +1 -1
  53. package/src/deepscientist/channels/relay.py +7 -1
  54. package/src/deepscientist/channels/weixin.py +59 -0
  55. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  56. package/src/deepscientist/config/models.py +22 -2
  57. package/src/deepscientist/config/service.py +431 -60
  58. package/src/deepscientist/connector/__init__.py +4 -0
  59. package/src/deepscientist/connector/connector_profiles.py +481 -0
  60. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  61. package/src/deepscientist/connector/qq_profiles.py +206 -0
  62. package/src/deepscientist/connector/weixin_support.py +663 -0
  63. package/src/deepscientist/connector_profiles.py +1 -374
  64. package/src/deepscientist/connector_runtime.py +2 -0
  65. package/src/deepscientist/daemon/api/handlers.py +165 -5
  66. package/src/deepscientist/daemon/api/router.py +13 -1
  67. package/src/deepscientist/daemon/app.py +1130 -61
  68. package/src/deepscientist/doctor.py +5 -2
  69. package/src/deepscientist/gitops/diff.py +120 -29
  70. package/src/deepscientist/lingzhu_support.py +1 -182
  71. package/src/deepscientist/mcp/server.py +11 -4
  72. package/src/deepscientist/prompts/builder.py +15 -0
  73. package/src/deepscientist/qq_profiles.py +1 -196
  74. package/src/deepscientist/quest/node_traces.py +23 -0
  75. package/src/deepscientist/quest/service.py +112 -43
  76. package/src/deepscientist/quest/stage_views.py +71 -5
  77. package/src/deepscientist/runners/codex.py +55 -3
  78. package/src/deepscientist/weixin_support.py +1 -0
  79. package/src/prompts/connectors/lingzhu.md +3 -1
  80. package/src/prompts/connectors/weixin.md +230 -0
  81. package/src/prompts/system.md +2 -0
  82. package/src/tui/package.json +1 -1
  83. package/src/ui/dist/assets/{AiManusChatView-BKZ103sn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  84. package/src/ui/dist/assets/{AnalysisPlugin-mTTzGAlK.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  85. package/src/ui/dist/assets/{CliPlugin-BH58n3GY.js → CliPlugin-DrV8je02.js} +164 -9
  86. package/src/ui/dist/assets/{CodeEditorPlugin-BKGRUH7e.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  87. package/src/ui/dist/assets/{CodeViewerPlugin-BMADwFWJ.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  88. package/src/ui/dist/assets/{DocViewerPlugin-ZOnTIHLN.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  89. package/src/ui/dist/assets/{GitDiffViewerPlugin-CQ7h1Djm.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  90. package/src/ui/dist/assets/{ImageViewerPlugin-GVS5MsnC.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  91. package/src/ui/dist/assets/{LabCopilotPanel-BZNv1JML.js → LabCopilotPanel-1qSow1es.js} +11 -11
  92. package/src/ui/dist/assets/{LabPlugin-TWcJsdQA.js → LabPlugin-eQpPPCEp.js} +2 -1
  93. package/src/ui/dist/assets/{LatexPlugin-DIjHiR2x.js → LatexPlugin-BwRfi89Z.js} +7 -7
  94. package/src/ui/dist/assets/{MarkdownViewerPlugin-D3ooGAH0.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  95. package/src/ui/dist/assets/{MarketplacePlugin-DfVfE9hN.js → MarketplacePlugin-C2y_556i.js} +3 -3
  96. package/src/ui/dist/assets/{NotebookEditor-s8JhzuX1.js → NotebookEditor-BRzJbGsn.js} +12 -12
  97. package/src/ui/dist/assets/{NotebookEditor-DDl0_Mc0.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  98. package/src/ui/dist/assets/{PdfLoader-C2Sf6SJM.js → PdfLoader-DzRaTAlq.js} +14 -7
  99. package/src/ui/dist/assets/{PdfMarkdownPlugin-CXFLoIsa.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  100. package/src/ui/dist/assets/{PdfViewerPlugin-BYTmz2fK.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  101. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  102. package/src/ui/dist/assets/{SearchPlugin-CjWBI1O9.js → SearchPlugin-DHeIAMsx.js} +1 -1
  103. package/src/ui/dist/assets/{TextViewerPlugin-DdOBU3-S.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  104. package/src/ui/dist/assets/{VNCViewer-B8HGgLwQ.js → VNCViewer-CQsKVm3t.js} +10 -10
  105. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  106. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  107. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  108. package/src/ui/dist/assets/{code-BWAY76JP.js → code-XfbSR8K2.js} +1 -1
  109. package/src/ui/dist/assets/{file-content-C1NwU5oQ.js → file-content-BjxNaIfy.js} +1 -1
  110. package/src/ui/dist/assets/{file-diff-panel-CywslwB9.js → file-diff-panel-D_lLVQk0.js} +1 -1
  111. package/src/ui/dist/assets/{file-socket-B4kzuOBQ.js → file-socket-D9x_5vlY.js} +1 -1
  112. package/src/ui/dist/assets/{image-D-NZM-6P.js → image-BhWT33W1.js} +1 -1
  113. package/src/ui/dist/assets/{index-DHZJ_0TI.js → index--c4iXtuy.js} +12 -12
  114. package/src/ui/dist/assets/{index-BdM1Gqfr.js → index-BDxipwrC.js} +2 -2
  115. package/src/ui/dist/assets/{index-7Chr1g9c.js → index-DZTZ8mWP.js} +14221 -9523
  116. package/src/ui/dist/assets/{index-DGIYDuTv.css → index-Dqj-Mjb4.css} +2 -13
  117. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  118. package/src/ui/dist/assets/{monaco-Cb2uKKe6.js → monaco-K8izTGgo.js} +1 -1
  119. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  120. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  121. package/src/ui/dist/assets/{popover-Bg72DGgT.js → popover-yFK1J4fL.js} +1 -1
  122. package/src/ui/dist/assets/{project-sync-Ce_0BglY.js → project-sync-PENr2zcz.js} +1 -74
  123. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  124. package/src/ui/dist/assets/{sigma-DPaACDrh.js → sigma-DEuYJqTl.js} +1 -1
  125. package/src/ui/dist/assets/{index-CDxNdQdz.js → square-check-big-omoSUmcd.js} +2 -13
  126. package/src/ui/dist/assets/{trash-BvTgE5__.js → trash--F119N47.js} +1 -1
  127. package/src/ui/dist/assets/{useCliAccess-CgPeMOwP.js → useCliAccess-D31UR23I.js} +1 -1
  128. package/src/ui/dist/assets/{useFileDiffOverlay-xPhz7P5B.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  129. package/src/ui/dist/assets/{wrap-text-C3Un3YQr.js → wrap-text-CZ613PM5.js} +1 -1
  130. package/src/ui/dist/assets/{zoom-out-BgxLa0Ri.js → zoom-out-BgDLAv3z.js} +1 -1
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  133. package/src/ui/dist/assets/AutoFigurePlugin-C_wWw4AP.js +0 -8149
  134. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  135. package/src/ui/dist/assets/Stepper-B0Dd8CxK.js +0 -158
  136. package/src/ui/dist/assets/bibtex-CKaefIN2.js +0 -189
  137. package/src/ui/dist/assets/file-utils-H2fjA46S.js +0 -109
  138. package/src/ui/dist/assets/message-square-BzjLiXir.js +0 -16
  139. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  140. package/src/ui/dist/assets/tooltip-C_mA6R0w.js +0 -108
@@ -5,6 +5,7 @@ import shutil
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
+ from ..arxiv_library import ArxivLibraryService
8
9
  from ..bridges import register_builtin_connector_bridges
9
10
  from ..channels import get_channel_factory, register_builtin_channels
10
11
  from ..config import ConfigManager
@@ -38,7 +39,7 @@ from ..shared import (
38
39
  )
39
40
  from ..quest import QuestService
40
41
  from ..memory.frontmatter import dump_markdown_document, load_markdown_document
41
- from .arxiv import read_arxiv_content
42
+ from .arxiv import fetch_arxiv_metadata, read_arxiv_content
42
43
  from .guidance import build_guidance_for_record, guidance_summary
43
44
  from .metrics import (
44
45
  baseline_metric_lines,
@@ -96,16 +97,77 @@ class ArtifactService:
96
97
  self.home = home
97
98
  self.baselines = BaselineRegistry(home)
98
99
  self.quest_service = QuestService(home)
100
+ self.arxiv_library = ArxivLibraryService()
99
101
 
100
102
  @staticmethod
101
- def _notification_text(value: object, *, limit: int = 220) -> str | None:
102
- text = str(value or "").strip()
103
- if not text:
103
+ def _notification_text(value: object) -> str | None:
104
+ text = str(value or "")
105
+ if not text.strip():
104
106
  return None
105
- text = re.sub(r"\s+", " ", text)
106
- if len(text) <= limit:
107
- return text
108
- return text[: limit - 1].rstrip() + "…"
107
+ normalized_lines: list[str] = []
108
+ for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
109
+ cleaned = re.sub(r"[ \t]+", " ", raw_line).strip()
110
+ if not cleaned:
111
+ if normalized_lines and normalized_lines[-1] != "":
112
+ normalized_lines.append("")
113
+ continue
114
+ normalized_lines.append(cleaned)
115
+ while normalized_lines and normalized_lines[-1] == "":
116
+ normalized_lines.pop()
117
+ rendered = "\n".join(normalized_lines).strip()
118
+ return rendered or None
119
+
120
+ @classmethod
121
+ def _notification_block(cls, value: object) -> str | None:
122
+ if value is None:
123
+ return None
124
+ if isinstance(value, dict):
125
+ lines: list[str] = []
126
+ for key, item in value.items():
127
+ label = cls._format_route_label(key) or str(key).strip()
128
+ block = cls._notification_block(item)
129
+ if not label or not block:
130
+ continue
131
+ block_lines = block.splitlines()
132
+ if len(block_lines) == 1:
133
+ lines.append(f"- {label}: {block_lines[0]}")
134
+ continue
135
+ lines.append(f"- {label}:")
136
+ lines.extend(f" {line}" if line else "" for line in block_lines)
137
+ return "\n".join(lines).strip() or None
138
+ if isinstance(value, (list, tuple, set)):
139
+ lines = []
140
+ for item in value:
141
+ block = cls._notification_block(item)
142
+ if not block:
143
+ continue
144
+ block_lines = block.splitlines()
145
+ if not block_lines:
146
+ continue
147
+ lines.append(f"- {block_lines[0]}")
148
+ lines.extend(f" {line}" if line else "" for line in block_lines[1:])
149
+ return "\n".join(lines).strip() or None
150
+ return cls._notification_text(value)
151
+
152
+ @classmethod
153
+ def _append_notification_section(cls, lines: list[str], label: str, value: object) -> None:
154
+ block = cls._notification_block(value)
155
+ if not block:
156
+ return
157
+ lines.extend(["", f"{label}:", block])
158
+
159
+ @staticmethod
160
+ def _append_notification_file_section(lines: list[str], entries: list[tuple[str, str | None]]) -> None:
161
+ normalized = [
162
+ (label, str(path).strip())
163
+ for label, path in entries
164
+ if str(path or "").strip()
165
+ ]
166
+ if not normalized:
167
+ return
168
+ lines.extend(["", "Files:"])
169
+ for label, path in normalized:
170
+ lines.append(f"- {label}: `{path}`")
109
171
 
110
172
  def _normalize_evaluation_summary(self, payload: dict[str, Any] | None) -> dict[str, str] | None:
111
173
  if not isinstance(payload, dict):
@@ -180,22 +242,21 @@ class ArtifactService:
180
242
  ) -> str:
181
243
  lead = "is now active" if action == "create" else "was revised"
182
244
  lines = [f"Idea `{idea_id}` {lead} on branch `{branch_name}`."]
183
- if self._notification_text(title):
184
- lines.append(f"Title: {self._notification_text(title)}")
185
- if self._notification_text(problem):
186
- lines.append(f"Problem: {self._notification_text(problem)}")
187
- if self._notification_text(hypothesis):
188
- lines.append(f"Hypothesis: {self._notification_text(hypothesis)}")
189
- if self._notification_text(mechanism):
190
- lines.append(f"Mechanism: {self._notification_text(mechanism)}")
245
+ self._append_notification_section(lines, "Title", title)
246
+ self._append_notification_section(lines, "Problem", problem)
247
+ self._append_notification_section(lines, "Hypothesis", hypothesis)
248
+ self._append_notification_section(lines, "Mechanism", mechanism)
191
249
  if foundation_label:
192
- lines.append(f"Foundation: {foundation_label}")
250
+ self._append_notification_section(lines, "Foundation", foundation_label)
193
251
  if next_target:
194
- lines.append(f"Next route: {self._format_route_label(next_target) or next_target}")
195
- if idea_md_rel_path:
196
- lines.append(f"Idea doc: `{idea_md_rel_path}`")
197
- if draft_md_rel_path:
198
- lines.append(f"Draft: `{draft_md_rel_path}`")
252
+ self._append_notification_section(lines, "Next route", self._format_route_label(next_target) or next_target)
253
+ self._append_notification_file_section(
254
+ lines,
255
+ [
256
+ ("Idea doc", idea_md_rel_path),
257
+ ("Draft", draft_md_rel_path),
258
+ ],
259
+ )
199
260
  return "\n".join(lines)
200
261
 
201
262
  def _build_main_experiment_interaction_message(
@@ -217,6 +278,7 @@ class ArtifactService:
217
278
  result_json_rel_path: str | None,
218
279
  ) -> str:
219
280
  lines = [f"Main experiment `{run_id}` finished on branch `{branch_name}`."]
281
+ outcome_lines: list[str] = []
220
282
  if primary_metric_id and primary_value is not None:
221
283
  metric_text = f"{primary_metric_id}={self._format_metric_value(primary_value, decimals)}"
222
284
  if primary_baseline is not None and primary_delta is not None:
@@ -224,27 +286,31 @@ class ArtifactService:
224
286
  f", baseline={self._format_metric_value(primary_baseline, decimals)}, "
225
287
  f"delta={self._format_metric_value(primary_delta, decimals)}"
226
288
  )
227
- lines.append(f"Metric: {metric_text}")
228
- lines.append(f"Verdict: {self._format_route_label(verdict) or verdict}")
229
- takeaway = (
230
- self._notification_text((evaluation_summary or {}).get("takeaway"))
231
- or self._notification_text(conclusion)
232
- )
233
- if takeaway:
234
- lines.append(f"Takeaway: {takeaway}")
235
- claim_update = self._notification_text((evaluation_summary or {}).get("claim_update"))
236
- if claim_update:
237
- lines.append(f"Claim update: {claim_update}")
289
+ outcome_lines.append(f"- Metric: {metric_text}")
290
+ outcome_lines.append(f"- Verdict: {self._format_route_label(verdict) or verdict}")
238
291
  if self._notification_text(breakthrough_level):
239
- lines.append(f"Breakthrough level: {self._notification_text(breakthrough_level)}")
292
+ outcome_lines.append(f"- Breakthrough level: {self._notification_text(breakthrough_level)}")
240
293
  if recommended_next_route:
241
- lines.append(
242
- f"Recommended next route: {self._format_route_label(recommended_next_route) or recommended_next_route}"
294
+ outcome_lines.append(
295
+ f"- Recommended next route: {self._format_route_label(recommended_next_route) or recommended_next_route}"
243
296
  )
244
- if run_md_rel_path:
245
- lines.append(f"Run log: `{run_md_rel_path}`")
246
- if result_json_rel_path:
247
- lines.append(f"Result: `{result_json_rel_path}`")
297
+ if outcome_lines:
298
+ lines.extend(["", "Outcome:", *outcome_lines])
299
+ self._append_notification_section(
300
+ lines,
301
+ "Conclusion",
302
+ self._notification_text(conclusion) or (evaluation_summary or {}).get("takeaway"),
303
+ )
304
+ normalized_evaluation_summary = self._normalize_evaluation_summary(evaluation_summary)
305
+ if normalized_evaluation_summary:
306
+ lines.extend(["", "Evaluation summary:", *self._evaluation_summary_markdown_lines(normalized_evaluation_summary)])
307
+ self._append_notification_file_section(
308
+ lines,
309
+ [
310
+ ("Run log", run_md_rel_path),
311
+ ("Result", result_json_rel_path),
312
+ ],
313
+ )
248
314
  return "\n".join(lines)
249
315
 
250
316
  def _build_outline_interaction_message(
@@ -263,23 +329,24 @@ class ArtifactService:
263
329
  ) -> str:
264
330
  verb = "selected" if action == "select" else "revised"
265
331
  lines = [f"Paper outline `{outline_id}` was {verb} and promoted into the writing stage."]
266
- if self._notification_text(title):
267
- lines.append(f"Title: {self._notification_text(title)}")
268
- if self._notification_text(selected_reason):
269
- lines.append(f"Reason: {self._notification_text(selected_reason)}")
270
- if self._notification_text(story):
271
- lines.append(f"Story: {self._notification_text(story)}")
272
- if research_questions:
273
- lines.append(f"Research questions: {self._notification_text(research_questions)}")
274
- if experimental_designs:
275
- lines.append(f"Experimental designs: {self._notification_text(experimental_designs)}")
276
- lines.append("Next route: Continue writing on the paper branch, or launch outline-bound analysis if evidence is still missing.")
277
- if selected_outline_rel_path:
278
- lines.append(f"Selected outline: `{selected_outline_rel_path}`")
279
- if outline_selection_rel_path:
280
- lines.append(f"Selection note: `{outline_selection_rel_path}`")
281
- if revised_outline_rel_path:
282
- lines.append(f"Revision record: `{revised_outline_rel_path}`")
332
+ self._append_notification_section(lines, "Title", title)
333
+ self._append_notification_section(lines, "Reason", selected_reason)
334
+ self._append_notification_section(lines, "Story", story)
335
+ self._append_notification_section(lines, "Research questions", research_questions)
336
+ self._append_notification_section(lines, "Experimental designs", experimental_designs)
337
+ self._append_notification_section(
338
+ lines,
339
+ "Next route",
340
+ "Continue writing on the paper branch, or launch outline-bound analysis if evidence is still missing.",
341
+ )
342
+ self._append_notification_file_section(
343
+ lines,
344
+ [
345
+ ("Selected outline", selected_outline_rel_path),
346
+ ("Selection note", outline_selection_rel_path),
347
+ ("Revision record", revised_outline_rel_path),
348
+ ],
349
+ )
283
350
  return "\n".join(lines)
284
351
 
285
352
  def _build_analysis_campaign_interaction_message(
@@ -293,20 +360,20 @@ class ArtifactService:
293
360
  todo_manifest_rel_path: str | None,
294
361
  ) -> str:
295
362
  lines = [f"Analysis campaign `{campaign_id}` is ready from parent branch `{parent_branch}`."]
296
- if self._notification_text(goal):
297
- lines.append(f"Goal: {self._notification_text(goal)}")
363
+ self._append_notification_section(lines, "Goal", goal)
298
364
  if selected_outline_ref:
299
- lines.append(f"Selected outline: `{selected_outline_ref}`")
300
- lines.append(
301
- f"Next slice: `{first_slice.get('slice_id')}` on branch `{first_slice.get('branch')}`"
302
- )
365
+ self._append_notification_section(lines, "Selected outline", f"`{selected_outline_ref}`")
366
+ next_slice_lines = [
367
+ f"- Slice: `{first_slice.get('slice_id')}`",
368
+ f"- Branch: `{first_slice.get('branch')}`",
369
+ ]
303
370
  if self._notification_text(first_slice.get("title")):
304
- lines.append(f"Slice focus: {self._notification_text(first_slice.get('title'))}")
371
+ next_slice_lines.append(f"- Focus: {self._notification_text(first_slice.get('title'))}")
372
+ lines.extend(["", "Next slice:", *next_slice_lines])
305
373
  requirement = self._notification_text(first_slice.get("must_not_simplify") or first_slice.get("goal"))
306
374
  if requirement:
307
- lines.append(f"Core requirement: {requirement}")
308
- if todo_manifest_rel_path:
309
- lines.append(f"Todo manifest: `{todo_manifest_rel_path}`")
375
+ self._append_notification_section(lines, "Core requirement", requirement)
376
+ self._append_notification_file_section(lines, [("Todo manifest", todo_manifest_rel_path)])
310
377
  return "\n".join(lines)
311
378
 
312
379
  def _build_analysis_slice_interaction_message(
@@ -320,19 +387,22 @@ class ArtifactService:
320
387
  mirror_rel_path: str | None,
321
388
  ) -> str:
322
389
  lines = [f"Analysis slice `{slice_id}` from campaign `{campaign_id}` is complete."]
323
- takeaway = self._notification_text((evaluation_summary or {}).get("takeaway"))
324
- if takeaway:
325
- lines.append(f"Takeaway: {takeaway}")
326
- if self._notification_text(claim_impact):
327
- lines.append(f"Claim impact: {self._notification_text(claim_impact)}")
328
- lines.append(
329
- f"Next slice: `{next_slice.get('slice_id')}` on branch `{next_slice.get('branch')}`"
390
+ normalized_evaluation_summary = self._normalize_evaluation_summary(evaluation_summary)
391
+ if normalized_evaluation_summary:
392
+ lines.extend(["", "Evaluation summary:", *self._evaluation_summary_markdown_lines(normalized_evaluation_summary)])
393
+ self._append_notification_section(lines, "Claim impact", claim_impact)
394
+ lines.extend(
395
+ [
396
+ "",
397
+ "Next slice:",
398
+ f"- Slice: `{next_slice.get('slice_id')}`",
399
+ f"- Branch: `{next_slice.get('branch')}`",
400
+ ]
330
401
  )
331
402
  requirement = self._notification_text(next_slice.get("must_not_simplify") or next_slice.get("goal"))
332
403
  if requirement:
333
- lines.append(f"Core requirement: {requirement}")
334
- if mirror_rel_path:
335
- lines.append(f"Parent mirror: `{mirror_rel_path}`")
404
+ self._append_notification_section(lines, "Core requirement", requirement)
405
+ self._append_notification_file_section(lines, [("Parent mirror", mirror_rel_path)])
336
406
  return "\n".join(lines)
337
407
 
338
408
  def _build_analysis_complete_interaction_message(
@@ -345,33 +415,89 @@ class ArtifactService:
345
415
  writing_worktree_rel_path: str | None,
346
416
  ) -> str:
347
417
  lines = [f"Analysis campaign `{campaign_id}` is complete."]
348
- lines.append(f"Completed slices: {len(completed_slices)}")
349
- strongest_takeaway = next(
350
- (
351
- self._notification_text(
352
- ((item.get("evaluation_summary") or {}) if isinstance(item.get("evaluation_summary"), dict) else {}).get(
353
- "takeaway"
354
- )
355
- )
356
- for item in completed_slices
357
- if self._notification_text(
358
- ((item.get("evaluation_summary") or {}) if isinstance(item.get("evaluation_summary"), dict) else {}).get(
359
- "takeaway"
360
- )
361
- )
362
- ),
363
- None,
364
- )
365
- if strongest_takeaway:
366
- lines.append(f"Main takeaway: {strongest_takeaway}")
367
- if summary_rel_path:
368
- lines.append(f"Summary: `{summary_rel_path}`")
418
+ overview_lines = [f"- Completed slices: {len(completed_slices)}"]
369
419
  if writing_branch:
370
- lines.append(f"Next route: writing is active on branch `{writing_branch}`")
420
+ overview_lines.append(f"- Next route: writing is active on branch `{writing_branch}`")
371
421
  if writing_worktree_rel_path:
372
- lines.append(f"Writing workspace: `{writing_worktree_rel_path}`")
422
+ overview_lines.append(f"- Writing workspace: `{writing_worktree_rel_path}`")
373
423
  else:
374
- lines.append("Next route: make the next durable decision from the merged analysis evidence.")
424
+ overview_lines.append("- Next route: make the next durable decision from the merged analysis evidence.")
425
+ lines.extend(["", "Overview:", *overview_lines])
426
+ completed_slice_lines: list[str] = []
427
+ for item in completed_slices:
428
+ slice_id = str(item.get("slice_id") or "").strip() or "unknown"
429
+ title = self._notification_text(item.get("title"))
430
+ lead = f"- `{slice_id}`"
431
+ if title:
432
+ lead += f": {title}"
433
+ completed_slice_lines.append(lead)
434
+ takeaway = self._notification_text(
435
+ ((item.get("evaluation_summary") or {}) if isinstance(item.get("evaluation_summary"), dict) else {}).get(
436
+ "takeaway"
437
+ )
438
+ )
439
+ if takeaway:
440
+ completed_slice_lines.append(f" Takeaway: {takeaway}")
441
+ claim_impact = self._notification_text(item.get("claim_impact"))
442
+ if claim_impact:
443
+ completed_slice_lines.append(f" Claim impact: {claim_impact}")
444
+ if completed_slice_lines:
445
+ lines.extend(["", "Completed slices:", *completed_slice_lines])
446
+ self._append_notification_file_section(lines, [("Summary", summary_rel_path)])
447
+ return "\n".join(lines)
448
+
449
+ def _build_paper_bundle_interaction_message(
450
+ self,
451
+ *,
452
+ title: str | None,
453
+ summary: str | None,
454
+ paper_branch: str | None,
455
+ source_branch: str | None,
456
+ source_run_id: str | None,
457
+ selected_outline_ref: str | None,
458
+ manifest_rel_path: str | None,
459
+ draft_rel_path: str | None,
460
+ writing_plan_rel_path: str | None,
461
+ references_rel_path: str | None,
462
+ claim_evidence_map_rel_path: str | None,
463
+ compile_report_rel_path: str | None,
464
+ pdf_rel_path: str | None,
465
+ latex_root_rel_path: str | None,
466
+ baseline_inventory_rel_path: str | None,
467
+ open_source_manifest_rel_path: str | None,
468
+ ) -> str:
469
+ bundle_label = self._notification_text(title) or "paper"
470
+ lines = [f"Paper bundle `{bundle_label}` is ready on branch `{paper_branch or 'paper'}`."]
471
+ overview_lines: list[str] = []
472
+ if source_branch:
473
+ overview_lines.append(f"- Source branch: `{source_branch}`")
474
+ if source_run_id:
475
+ overview_lines.append(f"- Source run: `{source_run_id}`")
476
+ if selected_outline_ref:
477
+ overview_lines.append(f"- Selected outline: `{selected_outline_ref}`")
478
+ if overview_lines:
479
+ lines.extend(["", "Overview:", *overview_lines])
480
+ self._append_notification_section(lines, "Summary", summary)
481
+ self._append_notification_file_section(
482
+ lines,
483
+ [
484
+ ("Bundle manifest", manifest_rel_path),
485
+ ("Draft", draft_rel_path),
486
+ ("Writing plan", writing_plan_rel_path),
487
+ ("References", references_rel_path),
488
+ ("Claim-evidence map", claim_evidence_map_rel_path),
489
+ ("Compile report", compile_report_rel_path),
490
+ ("PDF", pdf_rel_path),
491
+ ("LaTeX root", latex_root_rel_path),
492
+ ("Baseline inventory", baseline_inventory_rel_path),
493
+ ("Open-source manifest", open_source_manifest_rel_path),
494
+ ],
495
+ )
496
+ self._append_notification_section(
497
+ lines,
498
+ "Next route",
499
+ "Finalize the paper package, review the bundle artifacts, and publish or close the quest when ready.",
500
+ )
375
501
  return "\n".join(lines)
376
502
 
377
503
  def _load_metric_contract_payload(self, quest_root: Path, metric_contract_json_rel_path: str | None) -> dict[str, Any] | None:
@@ -2893,8 +3019,296 @@ class ArtifactService:
2893
3019
  deduped.append(path)
2894
3020
  return deduped
2895
3021
 
2896
- def arxiv(self, paper_id: str, *, full_text: bool = False) -> dict[str, Any]:
2897
- return read_arxiv_content(paper_id, full_text=full_text)
3022
+ @staticmethod
3023
+ def _arxiv_content_from_item(item: dict[str, Any]) -> str:
3024
+ title = str(item.get("title") or item.get("display_name") or item.get("arxiv_id") or "arXiv paper").strip()
3025
+ authors = [str(author).strip() for author in (item.get("authors") or []) if str(author).strip()]
3026
+ categories = [str(category).strip() for category in (item.get("categories") or []) if str(category).strip()]
3027
+ abstract = str(item.get("abstract") or "").strip() or "Abstract unavailable."
3028
+ overview = str(item.get("overview") or "").strip()
3029
+ lines = [f"# {title}", "", f"- paper_id: {str(item.get('arxiv_id') or '').strip()}"]
3030
+ if item.get("metadata_source"):
3031
+ lines.append(f"- metadata_source: {item['metadata_source']}")
3032
+ if item.get("summary_source"):
3033
+ lines.append(f"- summary_source: {item['summary_source']}")
3034
+ if authors:
3035
+ lines.append(f"- authors: {', '.join(authors)}")
3036
+ if categories:
3037
+ lines.append(f"- categories: {', '.join(categories)}")
3038
+ if item.get("published_at"):
3039
+ lines.append(f"- published_at: {item['published_at']}")
3040
+ if item.get("version") is not None:
3041
+ lines.append(f"- version: v{item['version']}")
3042
+ if overview:
3043
+ lines.extend(["", "## Summary", "", overview])
3044
+ if abstract and abstract != overview:
3045
+ lines.extend(["", "## Abstract", "", abstract])
3046
+ else:
3047
+ lines.extend(["", "## Abstract", "", abstract])
3048
+ return "\n".join(lines).strip()
3049
+
3050
+ @staticmethod
3051
+ def _arxiv_item_needs_refresh(item: dict[str, Any] | None) -> bool:
3052
+ if not isinstance(item, dict):
3053
+ return False
3054
+ title = str(item.get("title") or "").strip()
3055
+ lowered_title = title.lower()
3056
+ authors = item.get("authors") or []
3057
+ categories = item.get("categories") or []
3058
+ published_at = str(item.get("published_at") or "").strip()
3059
+ metadata_source = str(item.get("metadata_source") or "").strip()
3060
+ bibtex = str(item.get("bibtex") or "").strip()
3061
+ overview = str(item.get("overview") or "").strip()
3062
+ overview_markdown = str(item.get("overview_markdown") or "").strip()
3063
+ overview_source = str(item.get("overview_source") or "").strip()
3064
+ return (
3065
+ not title
3066
+ or title.startswith("#")
3067
+ or lowered_title.startswith("research paper analysis")
3068
+ or lowered_title.startswith("## research paper analysis")
3069
+ or not authors
3070
+ or not categories
3071
+ or not published_at
3072
+ or not metadata_source
3073
+ or not bibtex
3074
+ or (not overview and not overview_markdown and not overview_source)
3075
+ )
3076
+
3077
+ def _refresh_arxiv_item_metadata(self, quest_root: Path, item: dict[str, Any]) -> dict[str, Any]:
3078
+ arxiv_id = str(item.get("arxiv_id") or "").strip()
3079
+ if not arxiv_id:
3080
+ return item
3081
+ metadata = fetch_arxiv_metadata(arxiv_id)
3082
+ if not metadata.get("ok"):
3083
+ return item
3084
+ summary = read_arxiv_content(arxiv_id, full_text=False)
3085
+ summary_source = summary.get("summary_source") if summary.get("ok") else None
3086
+ overview_source = summary.get("overview_source") if summary.get("ok") else None
3087
+ return self.arxiv_library.upsert_item(
3088
+ quest_root,
3089
+ {
3090
+ **item,
3091
+ "arxiv_id": metadata.get("paper_id") or arxiv_id,
3092
+ "title": metadata.get("title") or item.get("title") or arxiv_id,
3093
+ "display_name": metadata.get("title") or item.get("display_name") or arxiv_id,
3094
+ "authors": metadata.get("authors") or item.get("authors") or [],
3095
+ "categories": metadata.get("categories") or item.get("categories") or [],
3096
+ "abstract": metadata.get("abstract") or item.get("abstract") or "",
3097
+ "published_at": metadata.get("published_at") or item.get("published_at") or "",
3098
+ "version": metadata.get("version") if metadata.get("version") is not None else item.get("version"),
3099
+ "primary_class": metadata.get("primary_class") or item.get("primary_class") or "",
3100
+ "metadata_source": metadata.get("metadata_source") or item.get("metadata_source"),
3101
+ "metadata_status": "ready",
3102
+ "overview": summary.get("overview") or item.get("overview") or "",
3103
+ "overview_markdown": summary.get("overview_markdown") or item.get("overview_markdown") or "",
3104
+ "summary_source": summary_source or item.get("summary_source"),
3105
+ "overview_source": overview_source or summary_source or item.get("overview_source"),
3106
+ "bibtex": metadata.get("bibtex") or item.get("bibtex"),
3107
+ "abs_url": metadata.get("abs_url") or item.get("abs_url"),
3108
+ "pdf_url": metadata.get("pdf_url") or item.get("pdf_url"),
3109
+ },
3110
+ )
3111
+
3112
+ @staticmethod
3113
+ def _arxiv_file_payload(quest_root: Path, item: dict[str, Any]) -> dict[str, Any]:
3114
+ relative = str(item.get("path") or "").strip()
3115
+ if not relative:
3116
+ return {}
3117
+ document_id = str(item.get("document_id") or f"questpath::{relative}").strip()
3118
+ return {
3119
+ "path": relative,
3120
+ "document_id": document_id,
3121
+ "pdf_rel_path": relative,
3122
+ "pdf_url": f"/api/quests/{quest_root.name}/documents/asset?document_id={document_id}",
3123
+ }
3124
+
3125
+ def arxiv(
3126
+ self,
3127
+ paper_id: str | None = None,
3128
+ *,
3129
+ full_text: bool = False,
3130
+ mode: str = "read",
3131
+ quest_root: Path | None = None,
3132
+ ) -> dict[str, Any]:
3133
+ normalized_mode = str(mode or "read").strip().lower() or "read"
3134
+ if normalized_mode == "list":
3135
+ if quest_root is None:
3136
+ return {
3137
+ "ok": False,
3138
+ "mode": "list",
3139
+ "error": "`quest_root` is required for `artifact.arxiv(mode='list')`.",
3140
+ }
3141
+ items = self.arxiv_library.list_items(quest_root)
3142
+ refreshed_any = False
3143
+ for item in items[:]:
3144
+ if not self._arxiv_item_needs_refresh(item):
3145
+ continue
3146
+ refreshed = self._refresh_arxiv_item_metadata(quest_root, item)
3147
+ if refreshed != item:
3148
+ refreshed_any = True
3149
+ if refreshed_any:
3150
+ items = self.arxiv_library.list_items(quest_root)
3151
+ return {
3152
+ "ok": True,
3153
+ "mode": "list",
3154
+ "items": items,
3155
+ "count": len(items),
3156
+ }
3157
+
3158
+ if paper_id is None:
3159
+ return {
3160
+ "ok": False,
3161
+ "mode": normalized_mode,
3162
+ "error": "`paper_id` is required for `artifact.arxiv(mode='read')`.",
3163
+ }
3164
+
3165
+ if quest_root is None:
3166
+ return {
3167
+ **read_arxiv_content(paper_id, full_text=full_text),
3168
+ "mode": normalized_mode,
3169
+ }
3170
+
3171
+ entry = self.arxiv_library.mark_processing(quest_root, paper_id)
3172
+ cached_entry = self.arxiv_library.get_item(quest_root, paper_id)
3173
+ if cached_entry and self._arxiv_item_needs_refresh(cached_entry):
3174
+ refreshed = self._refresh_arxiv_item_metadata(quest_root, cached_entry)
3175
+ if refreshed:
3176
+ cached_entry = refreshed
3177
+ if (
3178
+ cached_entry
3179
+ and not full_text
3180
+ and cached_entry.get("abstract")
3181
+ and (cached_entry.get("summary_source") or cached_entry.get("metadata_status") == "pending")
3182
+ ):
3183
+ paper_ref = str(cached_entry.get("arxiv_id") or paper_id).strip()
3184
+ self.arxiv_library.queue_pdf_download(quest_root, str(cached_entry.get("arxiv_id") or paper_id))
3185
+ return {
3186
+ "ok": True,
3187
+ "mode": normalized_mode,
3188
+ "paper_id": paper_ref,
3189
+ "requested_full_text": full_text,
3190
+ "content_mode": "abstract",
3191
+ "source": "quest_arxiv_library",
3192
+ "source_url": f"https://arxiv.org/abs/{paper_ref}",
3193
+ "title": cached_entry.get("title"),
3194
+ "authors": cached_entry.get("authors") or [],
3195
+ "categories": cached_entry.get("categories") or [],
3196
+ "abstract": cached_entry.get("abstract") or "",
3197
+ "overview": cached_entry.get("overview") or "",
3198
+ "overview_markdown": cached_entry.get("overview_markdown") or "",
3199
+ "summary_source": cached_entry.get("summary_source"),
3200
+ "overview_source": cached_entry.get("overview_source"),
3201
+ "metadata_source": cached_entry.get("metadata_source"),
3202
+ "published_at": cached_entry.get("published_at") or "",
3203
+ "version": cached_entry.get("version"),
3204
+ "primary_class": cached_entry.get("primary_class") or "",
3205
+ "bibtex": cached_entry.get("bibtex") or "",
3206
+ "status": cached_entry.get("status"),
3207
+ "metadata_status": cached_entry.get("metadata_status"),
3208
+ "abs_url": f"https://arxiv.org/abs/{paper_ref}",
3209
+ "pdf_url": f"https://arxiv.org/pdf/{paper_ref}.pdf",
3210
+ "content": self._arxiv_content_from_item(cached_entry),
3211
+ "attempts": [],
3212
+ **self._arxiv_file_payload(quest_root, cached_entry),
3213
+ }
3214
+
3215
+ fetched = read_arxiv_content(str(entry.get("arxiv_id") or paper_id), full_text=full_text)
3216
+ if not fetched.get("ok"):
3217
+ normalized_id = str(entry.get("arxiv_id") or paper_id).strip()
3218
+ placeholder = self.arxiv_library.upsert_item(
3219
+ quest_root,
3220
+ {
3221
+ **(cached_entry or {}),
3222
+ "arxiv_id": normalized_id,
3223
+ "title": str((cached_entry or {}).get("title") or normalized_id).strip(),
3224
+ "display_name": str((cached_entry or {}).get("display_name") or normalized_id).strip(),
3225
+ "status": str((cached_entry or {}).get("status") or "processing").strip() or "processing",
3226
+ "metadata_status": "pending",
3227
+ "error": None,
3228
+ "pdf_rel_path": self.arxiv_library.pdf_relative_path(normalized_id),
3229
+ "abs_url": str((cached_entry or {}).get("abs_url") or f"https://arxiv.org/abs/{normalized_id}"),
3230
+ "pdf_url": str((cached_entry or {}).get("pdf_url") or f"https://arxiv.org/pdf/{normalized_id}.pdf"),
3231
+ },
3232
+ )
3233
+ self.arxiv_library.queue_pdf_download(
3234
+ quest_root,
3235
+ normalized_id,
3236
+ pdf_url=str(placeholder.get("pdf_url") or "").strip() or None,
3237
+ )
3238
+ latest = self.arxiv_library.get_item(quest_root, normalized_id) or placeholder
3239
+ return {
3240
+ "ok": True,
3241
+ "mode": normalized_mode,
3242
+ "paper_id": normalized_id,
3243
+ "requested_full_text": full_text,
3244
+ "content_mode": "pending",
3245
+ "source": "quest_arxiv_library_partial",
3246
+ "source_url": latest.get("abs_url") or f"https://arxiv.org/abs/{normalized_id}",
3247
+ "title": latest.get("title"),
3248
+ "authors": latest.get("authors") or [],
3249
+ "categories": latest.get("categories") or [],
3250
+ "abstract": latest.get("abstract") or "",
3251
+ "overview": latest.get("overview") or "",
3252
+ "overview_markdown": latest.get("overview_markdown") or "",
3253
+ "summary_source": latest.get("summary_source"),
3254
+ "overview_source": latest.get("overview_source"),
3255
+ "metadata_source": latest.get("metadata_source"),
3256
+ "published_at": latest.get("published_at") or "",
3257
+ "version": latest.get("version"),
3258
+ "primary_class": latest.get("primary_class") or "",
3259
+ "bibtex": latest.get("bibtex") or "",
3260
+ "status": latest.get("status"),
3261
+ "metadata_status": "pending",
3262
+ "metadata_pending": True,
3263
+ "message": "Metadata is temporarily unavailable. Open the arXiv link directly while DeepScientist retries later.",
3264
+ "abs_url": latest.get("abs_url") or f"https://arxiv.org/abs/{normalized_id}",
3265
+ "pdf_url": latest.get("pdf_url") or f"https://arxiv.org/pdf/{normalized_id}.pdf",
3266
+ "content": self._arxiv_content_from_item(latest),
3267
+ "attempts": fetched.get("attempts") or [],
3268
+ "guidance": fetched.get("guidance"),
3269
+ **self._arxiv_file_payload(quest_root, latest),
3270
+ }
3271
+
3272
+ saved = self.arxiv_library.upsert_item(
3273
+ quest_root,
3274
+ {
3275
+ **(cached_entry or {}),
3276
+ "arxiv_id": fetched.get("paper_id") or str(entry.get("arxiv_id") or paper_id),
3277
+ "title": fetched.get("title") or cached_entry.get("title") if cached_entry else fetched.get("title"),
3278
+ "authors": fetched.get("authors") or (cached_entry.get("authors") if cached_entry else []),
3279
+ "categories": fetched.get("categories") or (cached_entry.get("categories") if cached_entry else []),
3280
+ "abstract": fetched.get("abstract") or (cached_entry.get("abstract") if cached_entry else ""),
3281
+ "overview": fetched.get("overview") or (cached_entry.get("overview") if cached_entry else ""),
3282
+ "overview_markdown": fetched.get("overview_markdown") or (cached_entry.get("overview_markdown") if cached_entry else ""),
3283
+ "summary_source": fetched.get("summary_source") or (cached_entry.get("summary_source") if cached_entry else None),
3284
+ "overview_source": fetched.get("overview_source") or (cached_entry.get("overview_source") if cached_entry else None),
3285
+ "metadata_source": fetched.get("metadata_source") or (cached_entry.get("metadata_source") if cached_entry else None),
3286
+ "published_at": fetched.get("published_at") or (cached_entry.get("published_at") if cached_entry else ""),
3287
+ "version": fetched.get("version") if fetched.get("version") is not None else (cached_entry.get("version") if cached_entry else None),
3288
+ "primary_class": fetched.get("primary_class") or (cached_entry.get("primary_class") if cached_entry else ""),
3289
+ "bibtex": fetched.get("bibtex") or (cached_entry.get("bibtex") if cached_entry else None),
3290
+ "abs_url": fetched.get("abs_url") or (cached_entry.get("abs_url") if cached_entry else None),
3291
+ "pdf_url": fetched.get("pdf_url") or (cached_entry.get("pdf_url") if cached_entry else None),
3292
+ "display_name": fetched.get("title") or fetched.get("paper_id") or str(entry.get("arxiv_id") or paper_id),
3293
+ "pdf_rel_path": self.arxiv_library.pdf_relative_path(str(fetched.get("paper_id") or entry.get("arxiv_id") or paper_id)),
3294
+ "status": "processing",
3295
+ "metadata_status": "ready",
3296
+ "error": None,
3297
+ },
3298
+ )
3299
+ self.arxiv_library.queue_pdf_download(
3300
+ quest_root,
3301
+ str(saved.get("arxiv_id") or paper_id),
3302
+ pdf_url=str(fetched.get("pdf_url") or "").strip() or None,
3303
+ )
3304
+ latest = self.arxiv_library.get_item(quest_root, str(saved.get("arxiv_id") or paper_id)) or saved
3305
+ return {
3306
+ **fetched,
3307
+ "mode": normalized_mode,
3308
+ "status": latest.get("status"),
3309
+ "metadata_status": latest.get("metadata_status"),
3310
+ **self._arxiv_file_payload(quest_root, latest),
3311
+ }
2898
3312
 
2899
3313
  def record(
2900
3314
  self,
@@ -5349,6 +5763,50 @@ class ArtifactService:
5349
5763
  checkpoint=False,
5350
5764
  workspace_root=workspace_root,
5351
5765
  )
5766
+ interaction = self.interact(
5767
+ quest_root,
5768
+ kind="milestone",
5769
+ message=self._build_paper_bundle_interaction_message(
5770
+ title=str(manifest.get("title") or "").strip() or None,
5771
+ summary=str(manifest.get("summary") or "").strip() or None,
5772
+ paper_branch=paper_branch,
5773
+ source_branch=source_branch,
5774
+ source_run_id=source_run_id,
5775
+ selected_outline_ref=str(manifest.get("selected_outline_ref") or "").strip() or None,
5776
+ manifest_rel_path=self._workspace_relative(quest_root, manifest_path),
5777
+ draft_rel_path=str(manifest.get("draft_path") or "").strip() or None,
5778
+ writing_plan_rel_path=str(manifest.get("writing_plan_path") or "").strip() or None,
5779
+ references_rel_path=str(manifest.get("references_path") or "").strip() or None,
5780
+ claim_evidence_map_rel_path=str(manifest.get("claim_evidence_map_path") or "").strip() or None,
5781
+ compile_report_rel_path=str(manifest.get("compile_report_path") or "").strip() or None,
5782
+ pdf_rel_path=str(manifest.get("pdf_path") or "").strip() or None,
5783
+ latex_root_rel_path=str(manifest.get("latex_root_path") or "").strip() or None,
5784
+ baseline_inventory_rel_path=paper_inventory_rel,
5785
+ open_source_manifest_rel_path=str(manifest.get("open_source_manifest_path") or "").strip() or None,
5786
+ ),
5787
+ deliver_to_bound_conversations=True,
5788
+ include_recent_inbound_messages=False,
5789
+ attachments=[
5790
+ {
5791
+ "kind": "paper_bundle",
5792
+ "title": manifest.get("title"),
5793
+ "paper_branch": paper_branch,
5794
+ "source_branch": source_branch,
5795
+ "source_run_id": source_run_id,
5796
+ "selected_outline_ref": manifest.get("selected_outline_ref"),
5797
+ "manifest_path": str(manifest_path),
5798
+ "draft_path": manifest.get("draft_path"),
5799
+ "writing_plan_path": manifest.get("writing_plan_path"),
5800
+ "references_path": manifest.get("references_path"),
5801
+ "claim_evidence_map_path": manifest.get("claim_evidence_map_path"),
5802
+ "compile_report_path": manifest.get("compile_report_path"),
5803
+ "pdf_path": manifest.get("pdf_path"),
5804
+ "latex_root_path": manifest.get("latex_root_path"),
5805
+ "baseline_inventory_path": str(baseline_inventory_path),
5806
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
5807
+ }
5808
+ ],
5809
+ )
5352
5810
  return {
5353
5811
  "ok": True,
5354
5812
  "manifest_path": str(manifest_path),
@@ -5356,6 +5814,7 @@ class ArtifactService:
5356
5814
  "baseline_inventory_path": str(baseline_inventory_path),
5357
5815
  "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
5358
5816
  "artifact": artifact,
5817
+ "interaction": interaction,
5359
5818
  }
5360
5819
 
5361
5820
  def record_analysis_slice(
@@ -6927,11 +7386,18 @@ class ArtifactService:
6927
7386
  return f"run/{run_id or generate_id('run')}"
6928
7387
 
6929
7388
  def _bound_conversations(self, quest_root: Path) -> list[str]:
6930
- state_path = quest_root / ".ds" / "bindings.json"
6931
- payload = read_json(state_path, {"sources": ["local:default"]})
6932
- sources = [self._normalize_conversation_id(str(item)) for item in (payload.get("sources") or ["local:default"])]
6933
- connector_sources = self._connector_bound_conversations(self._quest_id(quest_root))
6934
- return self._dedupe_targets([*connector_sources, *sources])
7389
+ quest_id = self._quest_id(quest_root)
7390
+ sources = [
7391
+ self._normalize_conversation_id(str(item))
7392
+ for item in self.quest_service.binding_sources(quest_id)
7393
+ ]
7394
+ authoritative_keys = {conversation_identity_key(item) for item in sources}
7395
+ connector_sources = [
7396
+ item
7397
+ for item in self._connector_bound_conversations(quest_id)
7398
+ if conversation_identity_key(item) in authoritative_keys
7399
+ ]
7400
+ return self._dedupe_targets([*sources, *connector_sources])
6935
7401
 
6936
7402
  def _connector_bound_conversations(self, quest_id: str) -> list[str]:
6937
7403
  root = self.home / "logs" / "connectors"