@researai/deepscientist 1.5.8 → 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 (148) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +108 -95
  3. package/assets/branding/connector-qq.png +0 -0
  4. package/assets/branding/connector-rokid.png +0 -0
  5. package/assets/branding/connector-weixin.png +0 -0
  6. package/assets/branding/projects.png +0 -0
  7. package/bin/ds.js +172 -13
  8. package/docs/assets/branding/projects.png +0 -0
  9. package/docs/en/00_QUICK_START.md +308 -70
  10. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  11. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  12. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  13. package/docs/en/09_DOCTOR.md +41 -5
  14. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  15. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  16. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  17. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  18. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +79 -0
  21. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  23. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  24. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  25. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  26. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  27. package/docs/zh/00_QUICK_START.md +315 -74
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +41 -5
  32. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  33. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  34. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  35. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  36. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  37. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  38. package/docs/zh/README.md +126 -0
  39. package/install.sh +0 -34
  40. package/package.json +3 -3
  41. package/pyproject.toml +2 -2
  42. package/src/deepscientist/__init__.py +1 -1
  43. package/src/deepscientist/annotations.py +343 -0
  44. package/src/deepscientist/artifact/arxiv.py +484 -37
  45. package/src/deepscientist/artifact/metrics.py +1 -3
  46. package/src/deepscientist/artifact/service.py +1347 -111
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/service.py +9 -0
  49. package/src/deepscientist/bridges/builtins.py +2 -0
  50. package/src/deepscientist/bridges/connectors.py +447 -0
  51. package/src/deepscientist/channels/__init__.py +2 -0
  52. package/src/deepscientist/channels/builtins.py +3 -1
  53. package/src/deepscientist/channels/qq.py +1 -1
  54. package/src/deepscientist/channels/qq_gateway.py +1 -1
  55. package/src/deepscientist/channels/relay.py +7 -1
  56. package/src/deepscientist/channels/weixin.py +59 -0
  57. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  58. package/src/deepscientist/config/models.py +22 -2
  59. package/src/deepscientist/config/service.py +431 -60
  60. package/src/deepscientist/connector/__init__.py +4 -0
  61. package/src/deepscientist/connector/connector_profiles.py +481 -0
  62. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  63. package/src/deepscientist/connector/qq_profiles.py +206 -0
  64. package/src/deepscientist/connector/weixin_support.py +663 -0
  65. package/src/deepscientist/connector_profiles.py +1 -374
  66. package/src/deepscientist/connector_runtime.py +2 -0
  67. package/src/deepscientist/daemon/api/handlers.py +295 -5
  68. package/src/deepscientist/daemon/api/router.py +16 -1
  69. package/src/deepscientist/daemon/app.py +1130 -61
  70. package/src/deepscientist/doctor.py +5 -2
  71. package/src/deepscientist/gitops/diff.py +120 -29
  72. package/src/deepscientist/lingzhu_support.py +1 -182
  73. package/src/deepscientist/mcp/server.py +14 -5
  74. package/src/deepscientist/prompts/builder.py +29 -1
  75. package/src/deepscientist/qq_profiles.py +1 -196
  76. package/src/deepscientist/quest/node_traces.py +152 -2
  77. package/src/deepscientist/quest/service.py +169 -43
  78. package/src/deepscientist/quest/stage_views.py +172 -9
  79. package/src/deepscientist/registries/baseline.py +56 -4
  80. package/src/deepscientist/runners/codex.py +55 -3
  81. package/src/deepscientist/weixin_support.py +1 -0
  82. package/src/prompts/connectors/lingzhu.md +3 -1
  83. package/src/prompts/connectors/weixin.md +230 -0
  84. package/src/prompts/system.md +9 -0
  85. package/src/skills/idea/SKILL.md +16 -0
  86. package/src/skills/idea/references/literature-survey-template.md +24 -0
  87. package/src/skills/idea/references/related-work-playbook.md +4 -0
  88. package/src/skills/idea/references/selection-gate.md +9 -0
  89. package/src/skills/write/SKILL.md +1 -1
  90. package/src/tui/package.json +1 -1
  91. package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  92. package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  93. package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
  94. package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  95. package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  96. package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  97. package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  98. package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  99. package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
  100. package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
  101. package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
  102. package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  103. package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
  104. package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
  105. package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  106. package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
  107. package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  108. package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  109. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  110. package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
  111. package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  112. package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
  113. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  114. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  115. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  116. package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
  117. package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
  118. package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
  119. package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
  120. package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
  121. package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
  122. package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
  123. package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
  124. package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
  125. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  126. package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
  127. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  128. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  129. package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
  130. package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
  131. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  132. package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
  133. package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
  134. package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
  135. package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
  136. package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  137. package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
  138. package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
  139. package/src/ui/dist/index.html +2 -2
  140. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  141. package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
  142. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  143. package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
  144. package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
  145. package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
  146. package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
  147. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  148. package/src/ui/dist/assets/tooltip-B1OspAkx.js +0 -108
@@ -1,196 +1 @@
1
- from __future__ import annotations
2
-
3
- from copy import deepcopy
4
- from typing import Any
5
-
6
- from .shared import slugify
7
-
8
-
9
- QQ_PROFILE_ID_PREFIX = "qq-profile"
10
- def default_qq_profile() -> dict[str, Any]:
11
- return {
12
- "profile_id": None,
13
- "enabled": True,
14
- "app_id": None,
15
- "app_secret": None,
16
- "app_secret_env": None,
17
- "bot_name": "DeepScientist",
18
- "main_chat_id": None,
19
- }
20
-
21
-
22
- def _as_text(value: Any) -> str | None:
23
- text = str(value or "").strip()
24
- return text or None
25
-
26
-
27
- def _normalize_secret_pair(payload: dict[str, Any], direct_key: str, env_key: str) -> None:
28
- direct = _as_text(payload.get(direct_key))
29
- env_name = _as_text(payload.get(env_key))
30
- payload[direct_key] = direct
31
- payload[env_key] = None if direct else env_name
32
-
33
-
34
- def _profile_id_seed(*, profile_id: Any, app_id: Any, bot_name: Any, index: int) -> str:
35
- explicit = _as_text(profile_id)
36
- if explicit:
37
- return explicit
38
- app_text = _as_text(app_id)
39
- if app_text:
40
- return f"qq-{app_text}"
41
- bot_text = slugify(str(bot_name or "").strip(), default="")
42
- if bot_text:
43
- return f"{QQ_PROFILE_ID_PREFIX}-{bot_text}"
44
- return f"{QQ_PROFILE_ID_PREFIX}-{index:03d}"
45
-
46
-
47
- def _unique_profile_id(seed: str, *, used: set[str]) -> str:
48
- base = slugify(seed, default=QQ_PROFILE_ID_PREFIX)
49
- candidate = base
50
- suffix = 2
51
- while candidate in used:
52
- candidate = f"{base}-{suffix}"
53
- suffix += 1
54
- used.add(candidate)
55
- return candidate
56
-
57
-
58
- def list_qq_profiles(config: dict[str, Any] | None) -> list[dict[str, Any]]:
59
- normalized = normalize_qq_connector_config(config)
60
- profiles = normalized.get("profiles")
61
- return [dict(item) for item in profiles] if isinstance(profiles, list) else []
62
-
63
-
64
- def find_qq_profile(
65
- config: dict[str, Any] | None,
66
- *,
67
- profile_id: str | None = None,
68
- app_id: str | None = None,
69
- ) -> dict[str, Any] | None:
70
- normalized_profile_id = _as_text(profile_id)
71
- normalized_app_id = _as_text(app_id)
72
- for profile in list_qq_profiles(config):
73
- if normalized_profile_id and str(profile.get("profile_id") or "").strip() == normalized_profile_id:
74
- return profile
75
- if normalized_app_id and str(profile.get("app_id") or "").strip() == normalized_app_id:
76
- return profile
77
- return None
78
-
79
-
80
- def merge_qq_profile_config(shared_config: dict[str, Any] | None, profile: dict[str, Any]) -> dict[str, Any]:
81
- normalized = normalize_qq_connector_config(shared_config)
82
- merged = deepcopy(normalized)
83
- merged.pop("profiles", None)
84
- app_secret = _as_text(profile.get("app_secret"))
85
- app_secret_env = _as_text(profile.get("app_secret_env"))
86
- merged.update(
87
- {
88
- "profile_id": str(profile.get("profile_id") or "").strip() or None,
89
- "app_id": _as_text(profile.get("app_id")),
90
- "app_secret": app_secret,
91
- "app_secret_env": None if app_secret else app_secret_env,
92
- "bot_name": _as_text(profile.get("bot_name")) or str(normalized.get("bot_name") or "DeepScientist"),
93
- "main_chat_id": _as_text(profile.get("main_chat_id")),
94
- "enabled": bool(normalized.get("enabled", False)) and bool(profile.get("enabled", True)),
95
- "transport": "gateway_direct",
96
- }
97
- )
98
- return merged
99
-
100
-
101
- def qq_profile_label(profile: dict[str, Any] | None) -> str:
102
- if not isinstance(profile, dict):
103
- return "QQ"
104
- bot_name = _as_text(profile.get("bot_name"))
105
- app_id = _as_text(profile.get("app_id"))
106
- if bot_name and app_id:
107
- return f"{bot_name} · {app_id}"
108
- if bot_name:
109
- return bot_name
110
- if app_id:
111
- return f"QQ · {app_id}"
112
- return "QQ"
113
-
114
-
115
- def normalize_qq_connector_config(config: dict[str, Any] | None) -> dict[str, Any]:
116
- payload = deepcopy(config or {})
117
- shared_defaults = {
118
- "enabled": False,
119
- "transport": "gateway_direct",
120
- "app_id": None,
121
- "app_secret": None,
122
- "app_secret_env": None,
123
- "bot_name": "DeepScientist",
124
- "command_prefix": "/",
125
- "main_chat_id": None,
126
- "require_at_in_groups": True,
127
- "auto_bind_dm_to_active_quest": True,
128
- "gateway_restart_on_config_change": True,
129
- "auto_send_main_experiment_png": True,
130
- "auto_send_analysis_summary_png": True,
131
- "auto_send_slice_png": True,
132
- "auto_send_paper_pdf": True,
133
- "enable_markdown_send": False,
134
- "enable_file_upload_experimental": False,
135
- "profiles": [],
136
- }
137
- shared = {**shared_defaults, **payload}
138
- shared["transport"] = "gateway_direct"
139
- shared["command_prefix"] = _as_text(shared.get("command_prefix")) or "/"
140
- shared["bot_name"] = _as_text(shared.get("bot_name")) or "DeepScientist"
141
- _normalize_secret_pair(shared, "app_secret", "app_secret_env")
142
-
143
- raw_profiles = payload.get("profiles")
144
- items = list(raw_profiles) if isinstance(raw_profiles, list) else []
145
- legacy_profile_seed = {
146
- "app_id": payload.get("app_id"),
147
- "app_secret": payload.get("app_secret"),
148
- "app_secret_env": payload.get("app_secret_env"),
149
- "bot_name": payload.get("bot_name"),
150
- "main_chat_id": payload.get("main_chat_id"),
151
- }
152
- if not items:
153
- has_direct_profile_seed = any(_as_text(legacy_profile_seed.get(key)) for key in ("app_id", "app_secret", "main_chat_id"))
154
- has_env_profile_seed = bool(payload.get("enabled")) and bool(_as_text(legacy_profile_seed.get("app_secret_env")))
155
- if has_direct_profile_seed or has_env_profile_seed:
156
- items = [legacy_profile_seed]
157
-
158
- profiles: list[dict[str, Any]] = []
159
- used_ids: set[str] = set()
160
- for index, raw in enumerate(items, start=1):
161
- if not isinstance(raw, dict):
162
- continue
163
- current = {**default_qq_profile(), **raw}
164
- current["enabled"] = bool(current.get("enabled", True))
165
- current["app_id"] = _as_text(current.get("app_id"))
166
- current["app_secret"] = _as_text(current.get("app_secret"))
167
- current["app_secret_env"] = _as_text(current.get("app_secret_env")) or shared["app_secret_env"]
168
- _normalize_secret_pair(current, "app_secret", "app_secret_env")
169
- current["bot_name"] = _as_text(current.get("bot_name")) or shared["bot_name"]
170
- current["main_chat_id"] = _as_text(current.get("main_chat_id"))
171
- current["profile_id"] = _unique_profile_id(
172
- _profile_id_seed(
173
- profile_id=current.get("profile_id"),
174
- app_id=current.get("app_id"),
175
- bot_name=current.get("bot_name"),
176
- index=index,
177
- ),
178
- used=used_ids,
179
- )
180
- profiles.append(current)
181
-
182
- shared["profiles"] = profiles
183
- if len(profiles) == 1:
184
- mirror = profiles[0]
185
- shared["app_id"] = mirror.get("app_id")
186
- shared["app_secret"] = mirror.get("app_secret")
187
- shared["app_secret_env"] = mirror.get("app_secret_env")
188
- shared["bot_name"] = mirror.get("bot_name")
189
- shared["main_chat_id"] = mirror.get("main_chat_id")
190
- else:
191
- shared["app_id"] = None
192
- shared["app_secret"] = None
193
- shared["app_secret_env"] = None
194
- shared["main_chat_id"] = None
195
-
196
- return shared
1
+ from .connector.qq_profiles import * # noqa: F401,F403
@@ -6,6 +6,8 @@ from typing import Any
6
6
 
7
7
  from ..shared import ensure_dir, read_json, sha256_text, utc_now, write_json
8
8
 
9
+ NODE_TRACE_SCHEMA_VERSION = 2
10
+
9
11
 
10
12
  def _format_state_label(value: str | None) -> str:
11
13
  normalized = str(value or "").strip().replace("_", " ").replace("-", " ")
@@ -36,6 +38,23 @@ def _normalize_branch_name(value: object, *, fallback: str) -> str:
36
38
  return text or fallback
37
39
 
38
40
 
41
+ def _infer_stage_from_branch_name(value: object) -> str | None:
42
+ normalized = str(value or "").strip().lower()
43
+ if not normalized:
44
+ return None
45
+ if normalized.startswith("analysis/"):
46
+ return "analysis"
47
+ if normalized.startswith("run/"):
48
+ return "experiment"
49
+ if normalized.startswith("idea/"):
50
+ return "idea"
51
+ if normalized.startswith("paper/") or normalized.startswith("write/"):
52
+ return "writing"
53
+ if normalized.startswith("baseline/"):
54
+ return "baseline"
55
+ return None
56
+
57
+
39
58
  def _infer_stage_from_skill(skill_id: object) -> str | None:
40
59
  normalized = str(skill_id or "").strip().lower()
41
60
  if not normalized:
@@ -46,8 +65,10 @@ def _infer_stage_from_skill(skill_id: object) -> str | None:
46
65
  return "baseline"
47
66
  if normalized in {"idea", "scout+idea"}:
48
67
  return "idea"
49
- if normalized in {"experiment", "analysis-campaign", "analysis"}:
68
+ if normalized == "experiment":
50
69
  return "experiment"
70
+ if normalized in {"analysis-campaign", "analysis", "analysis_slice"}:
71
+ return "analysis"
51
72
  if normalized in {"write", "finalize"}:
52
73
  return "writing"
53
74
  if normalized == "decision":
@@ -103,6 +124,56 @@ def _load_artifact_record(path: Path) -> dict[str, Any] | None:
103
124
  return None
104
125
 
105
126
 
127
+ def _normalize_paths_map(value: object) -> dict[str, str | None]:
128
+ if not isinstance(value, dict):
129
+ return {}
130
+ normalized: dict[str, str | None] = {}
131
+ for raw_key, raw_value in value.items():
132
+ key = str(raw_key or "").strip()
133
+ if not key:
134
+ continue
135
+ if raw_value is None:
136
+ normalized[key] = None
137
+ continue
138
+ text = str(raw_value).strip()
139
+ normalized[key] = text or None
140
+ return normalized
141
+
142
+
143
+ def _normalize_string_list(value: object) -> list[str]:
144
+ if not isinstance(value, list):
145
+ return []
146
+ items: list[str] = []
147
+ seen: set[str] = set()
148
+ for raw in value:
149
+ text = str(raw or "").strip()
150
+ if not text or text in seen:
151
+ continue
152
+ seen.add(text)
153
+ items.append(text)
154
+ return items
155
+
156
+
157
+ def _run_artifact_context(quest_root: Path, run_id: str | None) -> dict[str, Any] | None:
158
+ normalized = str(run_id or "").strip()
159
+ if not normalized:
160
+ return None
161
+ path = quest_root / ".ds" / "runs" / normalized / "artifact.json"
162
+ payload = read_json(path, {})
163
+ if not isinstance(payload, dict) or not payload:
164
+ return None
165
+ record = dict(payload.get("record") or {}) if isinstance(payload.get("record"), dict) else {}
166
+ checkpoint = dict(payload.get("checkpoint") or {}) if isinstance(payload.get("checkpoint"), dict) else {}
167
+ return {
168
+ "path": str(path),
169
+ "record": record,
170
+ "checkpoint": checkpoint,
171
+ "head_commit": str(checkpoint.get("head") or record.get("head_commit") or "").strip() or None,
172
+ "paths_map": _normalize_paths_map(record.get("paths")),
173
+ "changed_files": _normalize_string_list(record.get("files_changed")),
174
+ }
175
+
176
+
106
177
  def _build_run_contexts(quest_root: Path, *, default_branch: str) -> dict[str, dict[str, Any]]:
107
178
  contexts: dict[str, dict[str, Any]] = {}
108
179
  artifact_roots = [quest_root / "artifacts"]
@@ -145,6 +216,7 @@ def _build_run_contexts(quest_root: Path, *, default_branch: str) -> dict[str, d
145
216
  current["worktree_rel_path"] = current.get("worktree_rel_path") or record.get("worktree_rel_path")
146
217
  current["summary"] = current.get("summary") or summary
147
218
  current["updated_at"] = current.get("updated_at") or updated_at
219
+ current["artifact_path"] = current.get("artifact_path") or str(artifact_path)
148
220
  contexts[run_id] = current
149
221
  return contexts
150
222
 
@@ -160,6 +232,7 @@ def _resolve_entry_context(
160
232
  raw_event_type = str(entry.get("raw_event_type") or entry.get("kind") or "").strip()
161
233
  run_context = run_contexts.get(run_id or "")
162
234
  artifact_context: dict[str, Any] | None = None
235
+ artifact_path: str | None = None
163
236
  for raw_path in entry.get("paths") or []:
164
237
  try:
165
238
  path = Path(str(raw_path))
@@ -169,16 +242,24 @@ def _resolve_entry_context(
169
242
  continue
170
243
  artifact_context = _load_artifact_record(path)
171
244
  if artifact_context:
245
+ artifact_path = str(path)
172
246
  break
247
+ if run_id is None and artifact_context:
248
+ run_id = str(artifact_context.get("run_id") or "").strip() or None
249
+ run_artifact = _run_artifact_context(quest_root, run_id)
173
250
  branch_name = _normalize_branch_name(
174
- (artifact_context or {}).get("branch") or (run_context or {}).get("branch_name"),
251
+ (artifact_context or {}).get("branch")
252
+ or (((run_artifact or {}).get("record") or {}) if isinstance((run_artifact or {}).get("record"), dict) else {}).get("branch")
253
+ or (run_context or {}).get("branch_name"),
175
254
  fallback=default_branch,
176
255
  )
177
256
  stage_key = (
178
257
  _infer_stage_from_skill(entry.get("skill_id"))
179
258
  or _infer_stage_from_artifact(artifact_context or {})
259
+ or _infer_stage_from_artifact((((run_artifact or {}).get("record") or {}) if run_artifact else {}))
180
260
  or (run_context or {}).get("stage_key")
181
261
  or _infer_stage_from_event_type(raw_event_type)
262
+ or _infer_stage_from_branch_name(branch_name)
182
263
  or "general"
183
264
  )
184
265
  return {
@@ -186,9 +267,15 @@ def _resolve_entry_context(
186
267
  "branch_name": branch_name,
187
268
  "stage_key": stage_key,
188
269
  "worktree_rel_path": (artifact_context or {}).get("worktree_rel_path")
270
+ or (((run_artifact or {}).get("record") or {}) if run_artifact else {}).get("worktree_rel_path")
189
271
  or (run_context or {}).get("worktree_rel_path"),
272
+ "artifact_context": artifact_context,
273
+ "artifact_path": artifact_path or (run_context or {}).get("artifact_path"),
274
+ "run_artifact": run_artifact,
190
275
  "trace_confidence": "artifact"
191
276
  if artifact_context
277
+ else "run_artifact"
278
+ if run_artifact
192
279
  else "run_context"
193
280
  if run_context
194
281
  else "default_branch",
@@ -196,6 +283,25 @@ def _resolve_entry_context(
196
283
 
197
284
 
198
285
  def _build_action(entry: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
286
+ artifact_context = (
287
+ dict(context.get("artifact_context") or {})
288
+ if isinstance(context.get("artifact_context"), dict)
289
+ else {}
290
+ )
291
+ run_artifact = (
292
+ dict(context.get("run_artifact") or {})
293
+ if isinstance(context.get("run_artifact"), dict)
294
+ else {}
295
+ )
296
+ run_record = dict(run_artifact.get("record") or {}) if isinstance(run_artifact.get("record"), dict) else {}
297
+ paths_map = _normalize_paths_map(artifact_context.get("paths")) or _normalize_paths_map(run_record.get("paths"))
298
+ changed_files = _normalize_string_list(artifact_context.get("files_changed")) or _normalize_string_list(
299
+ artifact_context.get("changed_files")
300
+ ) or _normalize_string_list(run_record.get("files_changed")) or _normalize_string_list(run_artifact.get("changed_files"))
301
+ details_json = dict(artifact_context.get("details") or {}) if isinstance(artifact_context.get("details"), dict) else {}
302
+ checkpoint_json = dict(run_artifact.get("checkpoint") or {}) if isinstance(run_artifact.get("checkpoint"), dict) else {}
303
+ metadata = dict(entry.get("metadata") or {}) if isinstance(entry.get("metadata"), dict) else {}
304
+ payload_json = artifact_context or metadata or None
199
305
  return {
200
306
  "action_id": entry.get("id"),
201
307
  "kind": entry.get("kind"),
@@ -217,6 +323,22 @@ def _build_action(entry: dict[str, Any], context: dict[str, Any]) -> dict[str, A
217
323
  "reason": entry.get("reason"),
218
324
  "raw_event_type": entry.get("raw_event_type"),
219
325
  "paths": [str(item) for item in (entry.get("paths") or []) if item],
326
+ "paths_map": paths_map or None,
327
+ "artifact_id": artifact_context.get("artifact_id"),
328
+ "artifact_kind": artifact_context.get("kind"),
329
+ "artifact_path": context.get("artifact_path"),
330
+ "head_commit": (
331
+ str(
332
+ artifact_context.get("head_commit")
333
+ or run_artifact.get("head_commit")
334
+ or ""
335
+ ).strip()
336
+ or None
337
+ ),
338
+ "payload_json": payload_json,
339
+ "details_json": details_json or (metadata or None),
340
+ "checkpoint_json": checkpoint_json or None,
341
+ "changed_files": changed_files or None,
220
342
  "trace_confidence": context.get("trace_confidence"),
221
343
  }
222
344
 
@@ -277,6 +399,24 @@ def _build_trace_item(
277
399
  ) or None
278
400
  run_ids = sorted({str(item.get("run_id") or "").strip() for item in ordered_actions if item.get("run_id")})
279
401
  skill_ids = sorted({str(item.get("skill_id") or "").strip() for item in ordered_actions if item.get("skill_id")})
402
+ primary_action = next(
403
+ (
404
+ action
405
+ for action in reversed(ordered_actions)
406
+ if action.get("artifact_id")
407
+ or action.get("head_commit")
408
+ or action.get("payload_json")
409
+ or action.get("details_json")
410
+ ),
411
+ ordered_actions[-1] if ordered_actions else {},
412
+ )
413
+ merged_paths_map: dict[str, str | None] = {}
414
+ for action in ordered_actions:
415
+ if isinstance(action.get("paths_map"), dict):
416
+ merged_paths_map.update(action.get("paths_map") or {})
417
+ merged_changed_files = _normalize_string_list(
418
+ [item for action in ordered_actions for item in (action.get("changed_files") or [])]
419
+ )
280
420
  return {
281
421
  "selection_type": selection_type,
282
422
  "selection_ref": selection_ref,
@@ -291,6 +431,13 @@ def _build_trace_item(
291
431
  "counts": _build_counts(ordered_actions),
292
432
  "run_ids": run_ids,
293
433
  "skill_ids": skill_ids,
434
+ "artifact_id": primary_action.get("artifact_id"),
435
+ "artifact_kind": primary_action.get("artifact_kind"),
436
+ "head_commit": primary_action.get("head_commit"),
437
+ "payload_json": primary_action.get("payload_json"),
438
+ "details_json": primary_action.get("details_json"),
439
+ "paths_map": merged_paths_map or None,
440
+ "changed_files": merged_changed_files or None,
294
441
  "actions": ordered_actions,
295
442
  }
296
443
 
@@ -313,6 +460,7 @@ class QuestNodeTraceManager:
313
460
  source_signature = sha256_text(
314
461
  json.dumps(
315
462
  {
463
+ "schema_version": NODE_TRACE_SCHEMA_VERSION,
316
464
  "entries": workflow.get("entries") or [],
317
465
  "branch": (snapshot or {}).get("branch"),
318
466
  },
@@ -324,6 +472,7 @@ class QuestNodeTraceManager:
324
472
  if (
325
473
  isinstance(existing, dict)
326
474
  and existing.get("quest_id") == quest_id
475
+ and existing.get("schema_version") == NODE_TRACE_SCHEMA_VERSION
327
476
  and existing.get("source_signature") == source_signature
328
477
  and isinstance(existing.get("items"), list)
329
478
  ):
@@ -418,6 +567,7 @@ class QuestNodeTraceManager:
418
567
 
419
568
  payload = {
420
569
  "quest_id": quest_id,
570
+ "schema_version": NODE_TRACE_SCHEMA_VERSION,
421
571
  "generated_at": utc_now(),
422
572
  "source_signature": source_signature,
423
573
  "items": items,