@researai/deepscientist 1.5.15 → 1.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +336 -98
  2. package/bin/ds.js +691 -91
  3. package/docs/en/00_QUICK_START.md +36 -15
  4. package/docs/en/01_SETTINGS_REFERENCE.md +33 -0
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  6. package/docs/en/05_TUI_GUIDE.md +6 -0
  7. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  8. package/docs/en/09_DOCTOR.md +11 -5
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  10. package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
  11. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  12. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  13. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  14. package/docs/en/README.md +18 -0
  15. package/docs/zh/00_QUICK_START.md +36 -15
  16. package/docs/zh/01_SETTINGS_REFERENCE.md +33 -0
  17. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  18. package/docs/zh/05_TUI_GUIDE.md +6 -0
  19. package/docs/zh/09_DOCTOR.md +11 -5
  20. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  21. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
  22. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  23. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  24. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  25. package/docs/zh/README.md +18 -0
  26. package/package.json +1 -1
  27. package/pyproject.toml +1 -1
  28. package/src/deepscientist/__init__.py +1 -1
  29. package/src/deepscientist/acp/envelope.py +6 -0
  30. package/src/deepscientist/artifact/service.py +647 -22
  31. package/src/deepscientist/bash_exec/service.py +234 -9
  32. package/src/deepscientist/cli.py +115 -19
  33. package/src/deepscientist/codex_cli_compat.py +232 -0
  34. package/src/deepscientist/config/models.py +2 -1
  35. package/src/deepscientist/config/service.py +31 -9
  36. package/src/deepscientist/daemon/api/handlers.py +125 -6
  37. package/src/deepscientist/daemon/api/router.py +4 -0
  38. package/src/deepscientist/daemon/app.py +715 -98
  39. package/src/deepscientist/gitops/__init__.py +10 -1
  40. package/src/deepscientist/gitops/diff.py +129 -0
  41. package/src/deepscientist/gitops/service.py +4 -1
  42. package/src/deepscientist/mcp/server.py +39 -0
  43. package/src/deepscientist/prompts/builder.py +255 -32
  44. package/src/deepscientist/quest/layout.py +15 -2
  45. package/src/deepscientist/quest/service.py +295 -43
  46. package/src/deepscientist/quest/stage_views.py +6 -1
  47. package/src/deepscientist/runners/codex.py +86 -31
  48. package/src/deepscientist/skills/__init__.py +2 -2
  49. package/src/deepscientist/skills/installer.py +196 -5
  50. package/src/deepscientist/skills/registry.py +66 -0
  51. package/src/prompts/connectors/qq.md +18 -8
  52. package/src/prompts/connectors/weixin.md +16 -6
  53. package/src/prompts/contracts/shared_interaction.md +12 -1
  54. package/src/prompts/system.md +10 -5
  55. package/src/prompts/system_copilot.md +43 -0
  56. package/src/skills/analysis-campaign/SKILL.md +1 -0
  57. package/src/skills/baseline/SKILL.md +8 -0
  58. package/src/skills/decision/SKILL.md +8 -0
  59. package/src/skills/experiment/SKILL.md +8 -0
  60. package/src/skills/figure-polish/SKILL.md +1 -0
  61. package/src/skills/finalize/SKILL.md +1 -0
  62. package/src/skills/idea/SKILL.md +1 -0
  63. package/src/skills/intake-audit/SKILL.md +8 -0
  64. package/src/skills/mentor/SKILL.md +217 -0
  65. package/src/skills/mentor/references/correction-rules.md +210 -0
  66. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  67. package/src/skills/mentor/references/persona-profile.md +138 -0
  68. package/src/skills/mentor/references/taste-profile.md +128 -0
  69. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  70. package/src/skills/mentor/references/work-profile.md +289 -0
  71. package/src/skills/mentor/references/workflow-profile.md +240 -0
  72. package/src/skills/optimize/SKILL.md +1 -0
  73. package/src/skills/rebuttal/SKILL.md +1 -0
  74. package/src/skills/review/SKILL.md +1 -0
  75. package/src/skills/scout/SKILL.md +8 -0
  76. package/src/skills/write/SKILL.md +1 -0
  77. package/src/tui/dist/app/AppContainer.js +19 -11
  78. package/src/tui/dist/index.js +4 -1
  79. package/src/tui/dist/lib/api.js +33 -3
  80. package/src/tui/package.json +1 -1
  81. package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +204 -0
  82. package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
  83. package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
  84. package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
  85. package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
  86. package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
  87. package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
  88. package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
  89. package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
  90. package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
  91. package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
  92. package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
  93. package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
  94. package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
  95. package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -0
  96. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  97. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  98. package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +81 -0
  99. package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
  100. package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
  101. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  102. package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
  103. package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
  104. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  105. package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
  106. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  107. package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
  108. package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
  109. package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
  110. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  111. package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
  112. package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
  113. package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
  114. package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
  115. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  116. package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
  117. package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
  118. package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
  119. package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
  120. package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
  121. package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
  122. package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
  123. package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
  124. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  125. package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +6 -0
  126. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  127. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  128. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  129. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  130. package/src/ui/dist/assets/popover-DL6h35vr.js +1 -0
  131. package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
  132. package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
  133. package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
  134. package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
  135. package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
  136. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  137. package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
  138. package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
  139. package/src/ui/dist/index.html +5 -2
  140. package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
  141. package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
  142. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
  143. package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
  144. package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
  145. package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
  146. package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
  147. package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
  148. package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
  149. package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
  150. package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
  151. package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
  152. package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
  153. package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
  154. package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
  155. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  156. package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
  157. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  158. package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
  159. package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
  160. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  161. package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
  162. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  163. package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
  164. package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
  165. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  166. package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
  167. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  168. package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
  169. package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
  170. package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
  171. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  172. package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
  173. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  174. package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
  175. package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
  176. package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
  177. package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
  178. package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
  179. package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
  180. package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
  181. package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
  182. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  183. package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
  184. package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
  185. package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
  186. package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
  187. package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
  188. package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
  189. package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
  190. package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
  191. package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
  192. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  193. package/src/ui/dist/assets/zoom-out-BgVMmOW4.js +0 -34
@@ -1,4 +1,12 @@
1
- from .diff import commit_detail, compare_refs, diff_file_between_refs, diff_file_for_commit, list_branch_canvas, log_ref_history
1
+ from .diff import (
2
+ commit_detail,
3
+ compare_refs,
4
+ diff_file_between_refs,
5
+ diff_file_for_commit,
6
+ list_branch_canvas,
7
+ list_commit_canvas,
8
+ log_ref_history,
9
+ )
2
10
  from .graph import export_git_graph
3
11
  from .service import (
4
12
  branch_exists,
@@ -26,5 +34,6 @@ __all__ = [
26
34
  "head_commit",
27
35
  "init_repo",
28
36
  "list_branch_canvas",
37
+ "list_commit_canvas",
29
38
  "log_ref_history",
30
39
  ]
@@ -113,6 +113,67 @@ def list_branch_canvas(repo: Path, *, quest_id: str) -> dict[str, Any]:
113
113
  }
114
114
 
115
115
 
116
+ def list_commit_canvas(repo: Path, *, quest_id: str, limit: int = 80) -> dict[str, Any]:
117
+ resolved_limit = max(1, min(int(limit), 200))
118
+ head = head_commit(repo)
119
+ current_ref = current_branch(repo)
120
+ commits = _git_commit_canvas_log(repo, limit=resolved_limit)
121
+ nodes: list[dict[str, Any]] = []
122
+ edges: list[dict[str, Any]] = []
123
+ seen_shas = {item["sha"] for item in commits if str(item.get("sha") or "").strip()}
124
+
125
+ for commit in commits:
126
+ sha = str(commit.get("sha") or "").strip()
127
+ if not sha:
128
+ continue
129
+ detail = commit_detail(repo, sha=sha)
130
+ parents = [str(item).strip() for item in (detail.get("parents") or []) if str(item).strip()]
131
+ files = detail.get("files") or []
132
+ changed_paths = [
133
+ str(item.get("path") or "").strip()
134
+ for item in files
135
+ if str(item.get("path") or "").strip()
136
+ ]
137
+ node = {
138
+ "sha": sha,
139
+ "short_sha": str(detail.get("short_sha") or commit.get("short_sha") or sha[:7]).strip(),
140
+ "parents": parents,
141
+ "subject": str(detail.get("subject") or commit.get("subject") or "").strip() or sha[:7],
142
+ "body_preview": _body_preview(str(detail.get("body") or "").strip()),
143
+ "authored_at": str(detail.get("authored_at") or commit.get("authored_at") or "").strip() or None,
144
+ "author_name": str(detail.get("author_name") or commit.get("author_name") or "").strip() or None,
145
+ "branch_refs": _normalize_branch_refs(commit.get("decorations")),
146
+ "current": bool(head and sha == head),
147
+ "active_workspace": bool(head and sha == head),
148
+ "changed_paths": changed_paths,
149
+ "file_count": int(detail.get("file_count") or len(files)),
150
+ "added": int(((detail.get("stats") or {}) if isinstance(detail.get("stats"), dict) else {}).get("added") or 0),
151
+ "removed": int(((detail.get("stats") or {}) if isinstance(detail.get("stats"), dict) else {}).get("removed") or 0),
152
+ "compare_base": parents[0] if parents else None,
153
+ "compare_head": sha,
154
+ "selection_type": "git_commit_node",
155
+ }
156
+ nodes.append(node)
157
+ for parent in parents:
158
+ if parent in seen_shas:
159
+ edges.append(
160
+ {
161
+ "from": parent,
162
+ "to": sha,
163
+ "relation": "parent",
164
+ }
165
+ )
166
+
167
+ return {
168
+ "quest_id": quest_id,
169
+ "workspace_mode": "copilot",
170
+ "head": head,
171
+ "current_ref": current_ref,
172
+ "nodes": nodes,
173
+ "edges": edges,
174
+ }
175
+
176
+
116
177
  def compare_refs(repo: Path, *, base: str, head: str) -> dict[str, Any]:
117
178
  _require_ref(repo, base)
118
179
  _require_ref(repo, head)
@@ -775,6 +836,74 @@ def _git_log(repo: Path, *, revspec: str, limit: int = 30) -> list[dict[str, Any
775
836
  return commits
776
837
 
777
838
 
839
+ def _git_commit_canvas_log(repo: Path, *, limit: int = 80) -> list[dict[str, Any]]:
840
+ result = _git_stdout(
841
+ repo,
842
+ [
843
+ "log",
844
+ "--all",
845
+ "--topo-order",
846
+ "--date=iso-strict",
847
+ f"-n{limit}",
848
+ "--decorate=short",
849
+ "--pretty=format:%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%s%x1f%b%x1f%D",
850
+ ],
851
+ )
852
+ commits: list[dict[str, Any]] = []
853
+ for line in result.splitlines():
854
+ if not line.strip():
855
+ continue
856
+ sha, short_sha, parents_raw, authored_at, author_name, subject, body, decorations = (
857
+ line.split("\x1f") + ["", "", "", "", "", "", "", ""]
858
+ )[:8]
859
+ commits.append(
860
+ {
861
+ "sha": sha.strip(),
862
+ "short_sha": short_sha.strip(),
863
+ "parents": [item for item in parents_raw.strip().split() if item],
864
+ "authored_at": authored_at.strip(),
865
+ "author_name": author_name.strip(),
866
+ "subject": subject.strip(),
867
+ "body": body.strip(),
868
+ "decorations": decorations.strip(),
869
+ }
870
+ )
871
+ return commits
872
+
873
+
874
+ def _normalize_branch_refs(raw: Any) -> list[str]:
875
+ text = str(raw or "").strip()
876
+ if not text:
877
+ return []
878
+ refs: list[str] = []
879
+ for part in text.split(","):
880
+ cleaned = part.strip()
881
+ if not cleaned:
882
+ continue
883
+ if cleaned.startswith("HEAD -> "):
884
+ cleaned = cleaned[len("HEAD -> ") :].strip()
885
+ if cleaned.startswith("tag: "):
886
+ continue
887
+ if cleaned.startswith("origin/"):
888
+ continue
889
+ if cleaned == "HEAD":
890
+ continue
891
+ refs.append(cleaned)
892
+ return refs
893
+
894
+
895
+ def _body_preview(body: str, *, max_lines: int = 3, max_chars: int = 220) -> str | None:
896
+ if not body:
897
+ return None
898
+ lines = [line.strip() for line in body.splitlines() if line.strip()]
899
+ if not lines:
900
+ return None
901
+ preview = " ".join(lines[:max_lines]).strip()
902
+ if len(preview) > max_chars:
903
+ preview = preview[: max_chars - 1].rstrip() + "…"
904
+ return preview or None
905
+
906
+
778
907
  def _normalize_patch_lines(patch: str) -> list[str]:
779
908
  lines = [line.rstrip("\n") for line in patch.splitlines()]
780
909
  if not lines:
@@ -100,7 +100,10 @@ def checkpoint_repo(repo: Path, message: str, allow_empty: bool = False) -> dict
100
100
  "branch": current_branch(repo),
101
101
  "head": head_commit(repo),
102
102
  }
103
- result = run_command(["git", "commit", "-m", message], cwd=repo, check=False)
103
+ command = ["git", "commit", "-m", message]
104
+ if allow_empty:
105
+ command.insert(2, "--allow-empty")
106
+ result = run_command(command, cwd=repo, check=False)
104
107
  return {
105
108
  "committed": result.returncode == 0,
106
109
  "branch": current_branch(repo),
@@ -526,6 +526,45 @@ def build_artifact_server(context: McpContext) -> FastMCP:
526
526
  allow_empty=allow_empty,
527
527
  )
528
528
 
529
+ @server.tool(
530
+ name="git",
531
+ description=(
532
+ "Run git-oriented workspace operations for Copilot mode and general quest maintenance. "
533
+ "Use action=status|commit|branch|checkout|log|show|diff|graph."
534
+ ),
535
+ )
536
+ def git(
537
+ action: str,
538
+ message: str | None = None,
539
+ ref: str | None = None,
540
+ base: str | None = None,
541
+ head: str | None = None,
542
+ sha: str | None = None,
543
+ path: str | None = None,
544
+ branch: str | None = None,
545
+ create_from: str | None = None,
546
+ limit: int = 30,
547
+ allow_empty: bool = False,
548
+ checkout_new_branch: bool = False,
549
+ comment: str | dict[str, Any] | None = None,
550
+ ) -> dict[str, Any]:
551
+ return service.git_action(
552
+ context.require_quest_root(),
553
+ action=action,
554
+ workspace_root=context.worktree_root,
555
+ message=message,
556
+ ref=ref,
557
+ base=base,
558
+ head=head,
559
+ sha=sha,
560
+ path=path,
561
+ branch=branch,
562
+ create_from=create_from,
563
+ limit=limit,
564
+ allow_empty=allow_empty,
565
+ checkout_new_branch=checkout_new_branch,
566
+ )
567
+
529
568
  @server.tool(name="prepare_branch", description="Prepare an idea or run branch and optional worktree.")
530
569
  def prepare_branch(
531
570
  run_id: str | None = None,
@@ -6,30 +6,21 @@ from pathlib import Path
6
6
 
7
7
  from ..connector_runtime import normalize_conversation_id, parse_conversation_id
8
8
  from ..config import ConfigManager
9
+ from ..home import repo_root
9
10
  from ..memory import MemoryService
10
11
  from ..memory.frontmatter import load_markdown_document
11
12
  from ..quest import QuestService
12
13
  from ..registries import BaselineRegistry
13
14
  from ..shared import read_json, read_text, read_yaml
15
+ from ..skills import SkillInstaller, companion_skill_ids, stage_skill_ids
14
16
 
15
- STANDARD_SKILLS = (
16
- "scout",
17
- "baseline",
18
- "idea",
19
- "optimize",
20
- "experiment",
21
- "analysis-campaign",
22
- "write",
23
- "finalize",
24
- "decision",
25
- )
26
-
27
- COMPANION_SKILLS = (
28
- "figure-polish",
29
- "intake-audit",
30
- "review",
31
- "rebuttal",
32
- )
17
+ # Backward-compatible snapshots for modules or tests that still import these names directly.
18
+ # Runtime routing should call `current_standard_skills(...)` / `current_companion_skills(...)`.
19
+ STANDARD_SKILLS = stage_skill_ids(repo_root())
20
+
21
+ _AUTO_CONTINUE_MONITOR_INTERVAL_SECONDS = 240
22
+
23
+ COMPANION_SKILLS = companion_skill_ids(repo_root())
33
24
 
34
25
  STAGE_MEMORY_PLAN = {
35
26
  "scout": {
@@ -71,6 +62,14 @@ STAGE_MEMORY_PLAN = {
71
62
  }
72
63
 
73
64
 
65
+ def current_standard_skills(repo_root_path: Path | None = None) -> tuple[str, ...]:
66
+ return stage_skill_ids(repo_root_path or repo_root())
67
+
68
+
69
+ def current_companion_skills(repo_root_path: Path | None = None) -> tuple[str, ...]:
70
+ return companion_skill_ids(repo_root_path or repo_root())
71
+
72
+
74
73
  def classify_turn_intent(user_message: str) -> str:
75
74
  text = str(user_message or "").strip()
76
75
  if not text:
@@ -104,13 +103,15 @@ def classify_turn_intent(user_message: str) -> str:
104
103
 
105
104
 
106
105
  class PromptBuilder:
107
- def __init__(self, repo_root: Path, home: Path) -> None:
106
+ def __init__(self, repo_root: Path, home: Path, *, prompt_version_selection: str | None = None) -> None:
108
107
  self.repo_root = repo_root
109
108
  self.home = home
110
109
  self.quest_service = QuestService(home)
111
110
  self.memory_service = MemoryService(home)
112
111
  self.baseline_registry = BaselineRegistry(home)
113
112
  self.config_manager = ConfigManager(home)
113
+ self.skill_installer = SkillInstaller(repo_root, home)
114
+ self.prompt_version_selection = str(prompt_version_selection or "").strip() or None
114
115
 
115
116
  def build(
116
117
  self,
@@ -128,9 +129,14 @@ class PromptBuilder:
128
129
  runtime_config = self.config_manager.load_named("config")
129
130
  connectors_config = self.config_manager.load_named_normalized("connectors")
130
131
  quest_root = Path(snapshot["quest_root"])
132
+ self.skill_installer.sync_quest_prompts(quest_root)
131
133
  active_anchor = str(snapshot.get("active_anchor") or skill_id)
132
134
  default_locale = str(runtime_config.get("default_locale") or "en-US")
133
- system_block = self._prompt_fragment("system.md", quest_root=quest_root)
135
+ workspace_mode = self._workspace_mode(snapshot)
136
+ system_block = self._prompt_fragment(
137
+ "system_copilot.md" if workspace_mode == "copilot" else "system.md",
138
+ quest_root=quest_root,
139
+ )
134
140
  shared_interaction_block = self._prompt_fragment(
135
141
  Path("contracts") / "shared_interaction.md",
136
142
  quest_root=quest_root,
@@ -159,7 +165,7 @@ class PromptBuilder:
159
165
  f"conversation_id: quest:{quest_id}",
160
166
  f"default_locale: {default_locale}",
161
167
  "built_in_mcp_namespaces: memory, artifact, bash_exec",
162
- "mcp_namespace_note: any shell-like command execution must use bash_exec, including curl/python/bash/node and similar CLI tools; do not use transient shell snippets.",
168
+ "mcp_namespace_note: **any shell-like command execution must use `bash_exec(...)`, including curl/python/bash/node/git/npm/uv and similar CLI tools; do not use native `shell_command` / `command_execution`.**",
163
169
  "",
164
170
  "Canonical stage skills root:",
165
171
  str((self.repo_root / "src" / "skills").resolve()),
@@ -229,6 +235,14 @@ class PromptBuilder:
229
235
  "## Recovery Resume Packet",
230
236
  self._recovery_resume_block(snapshot=snapshot, turn_reason=turn_reason),
231
237
  "",
238
+ "## Resume Context Spine",
239
+ self._resume_context_spine_block(
240
+ quest_id=quest_id,
241
+ quest_root=quest_root,
242
+ snapshot=snapshot,
243
+ turn_reason=turn_reason,
244
+ ),
245
+ "",
232
246
  "## Interaction Style",
233
247
  self._interaction_style_block(default_locale=default_locale, user_message=user_message, snapshot=snapshot),
234
248
  "",
@@ -486,8 +500,14 @@ class PromptBuilder:
486
500
  ]
487
501
  )
488
502
  if str(turn_reason or "").strip() == "auto_continue":
489
- lines.append(
490
- "- auto_continue_rule: this turn has no new user message; continue from the active requirements, durable artifacts, and current quest state instead of replaying the previous user message"
503
+ lines.extend(
504
+ [
505
+ "- auto_continue_rule: this turn has no new user message; continue from the active requirements, durable artifacts, current quest state, and resume context spine instead of replaying the previous user message",
506
+ f"- auto_continue_interval_rule: when a real long-running external task is already active, background-progress auto-continue becomes a low-frequency monitoring pass, about every {_AUTO_CONTINUE_MONITOR_INTERVAL_SECONDS} seconds rather than sub-minute polling",
507
+ "- auto_continue_fast_prepare_rule: in autonomous mode before a real external long-running task exists, auto-continue may advance quickly, around 0.2 seconds between turns, so the agent can keep preparing or launching the real work without idling",
508
+ "- autonomous_prepare_rule: in autonomous mode, if no real long-running external task is active yet, use the next turns to keep preparing, launching, or durably deciding the next real unit of work instead of parking idly",
509
+ "- copilot_park_rule: in copilot mode, once the current requested unit is complete, it is normal to park and wait for the next user message or `/resume` instead of continuing autonomously",
510
+ ]
491
511
  )
492
512
  else:
493
513
  lines.append(
@@ -614,6 +634,76 @@ class PromptBuilder:
614
634
  lines.append(f"- remaining_attachment_count: {len(attachments) - 6}")
615
635
  return "\n".join(lines)
616
636
 
637
+ def _resume_context_spine_block(self, *, quest_id: str, quest_root: Path, snapshot: dict, turn_reason: str) -> str:
638
+ if str(turn_reason or "").strip() != "auto_continue":
639
+ return "- none"
640
+ lines = [
641
+ "- resume_spine_rule: on auto_continue turns, first continue from the latest durable user requirement, the latest assistant checkpoint, the latest run summary, and recent memory cues instead of reconstructing intent from scratch",
642
+ ]
643
+ bash_running_count = int(((snapshot.get("counts") or {}).get("bash_running_count")) or 0)
644
+ latest_bash_session = (
645
+ dict((snapshot.get("summary") or {}).get("latest_bash_session") or {})
646
+ if isinstance((snapshot.get("summary") or {}).get("latest_bash_session"), dict)
647
+ else {}
648
+ )
649
+ lines.append(f"- active_bash_exec_run_count: {bash_running_count}")
650
+ if latest_bash_session:
651
+ command_preview = " ".join(str(latest_bash_session.get("command") or "").split())
652
+ if len(command_preview) > 180:
653
+ command_preview = command_preview[:177].rstrip() + "..."
654
+ lines.append(
655
+ f"- latest_bash_exec_session: bash_id={str(latest_bash_session.get('bash_id') or 'none')} | "
656
+ f"status={str(latest_bash_session.get('status') or 'unknown')} | "
657
+ f"command={command_preview or 'none'}"
658
+ )
659
+ latest_user = self._latest_user_message(quest_id)
660
+ if latest_user is not None:
661
+ preview = " ".join(str(latest_user.get("content") or "").split())
662
+ if len(preview) > 320:
663
+ preview = preview[:317].rstrip() + "..."
664
+ lines.append(
665
+ f"- latest_user_message: {str(latest_user.get('created_at') or 'unknown')} | "
666
+ f"source={str(latest_user.get('source') or 'unknown')} | "
667
+ f"reply_to={str(latest_user.get('reply_to_interaction_id') or 'none')} | "
668
+ f"preview={preview or 'none'}"
669
+ )
670
+ latest_assistant = self._latest_assistant_message(quest_id)
671
+ if latest_assistant is not None:
672
+ preview = " ".join(str(latest_assistant.get("content") or "").split())
673
+ if len(preview) > 360:
674
+ preview = preview[:357].rstrip() + "..."
675
+ lines.append(
676
+ f"- latest_assistant_checkpoint: {str(latest_assistant.get('created_at') or 'unknown')} | "
677
+ f"skill={str(latest_assistant.get('skill_id') or 'none')} | "
678
+ f"run_id={str(latest_assistant.get('run_id') or 'none')} | "
679
+ f"preview={preview or 'none'}"
680
+ )
681
+ latest_run = self._latest_run_result(quest_root)
682
+ if latest_run is not None:
683
+ preview = " ".join(str(latest_run.get("preview") or "").split())
684
+ if len(preview) > 360:
685
+ preview = preview[:357].rstrip() + "..."
686
+ lines.append(
687
+ f"- latest_run_result: {str(latest_run.get('completed_at') or 'unknown')} | "
688
+ f"run_id={str(latest_run.get('run_id') or 'none')} | "
689
+ f"exit_code={latest_run.get('exit_code') if latest_run.get('exit_code') is not None else 'none'} | "
690
+ f"preview={preview or 'none'}"
691
+ )
692
+ recent_memory = self.memory_service.list_recent(scope="quest", quest_root=quest_root, limit=3)
693
+ if recent_memory:
694
+ lines.append("- recent_memory_cues:")
695
+ for item in recent_memory:
696
+ title = str(item.get("title") or "memory").strip() or "memory"
697
+ card_type = str(item.get("type") or "memory").strip() or "memory"
698
+ excerpt = " ".join(str(item.get("excerpt") or "").split())
699
+ if len(excerpt) > 200:
700
+ excerpt = excerpt[:197].rstrip() + "..."
701
+ lines.append(f" - [{card_type}] {title}: {excerpt or 'no excerpt'}")
702
+ else:
703
+ lines.append("- recent_memory_cues: none")
704
+ lines.append("- resume_spine_conflict_rule: if these spine items conflict with newer durable files or artifacts, trust the newer durable state and update the summary rather than replaying the older plan verbatim")
705
+ return "\n".join(lines)
706
+
617
707
  def _retry_recovery_block(self, retry_context: dict | None) -> str:
618
708
  if not isinstance(retry_context, dict) or not retry_context:
619
709
  return "- none"
@@ -726,6 +816,19 @@ class PromptBuilder:
726
816
  def _prompt_path(self, relative_path: str | Path, *, quest_root: Path | None = None) -> Path:
727
817
  normalized = Path(relative_path)
728
818
  if quest_root is not None:
819
+ selected_version = str(self.prompt_version_selection or "").strip()
820
+ if selected_version and selected_version not in {"latest", "current", "active"}:
821
+ selected_root = self.skill_installer.resolve_prompt_version_root(quest_root, selected_version)
822
+ if selected_root is None:
823
+ raise FileNotFoundError(
824
+ f"Prompt version `{selected_version}` is unavailable for quest `{quest_root.name}`."
825
+ )
826
+ selected_path = selected_root / normalized
827
+ if not selected_path.exists():
828
+ raise FileNotFoundError(
829
+ f"Prompt version `{selected_version}` does not include `{normalized.as_posix()}` for quest `{quest_root.name}`."
830
+ )
831
+ return selected_path
729
832
  quest_path = quest_root / ".codex" / "prompts" / normalized
730
833
  if quest_path.exists():
731
834
  return quest_path
@@ -737,16 +840,45 @@ class PromptBuilder:
737
840
  return item
738
841
  return None
739
842
 
843
+ def _latest_assistant_message(self, quest_id: str) -> dict | None:
844
+ for item in reversed(self.quest_service.history(quest_id, limit=120)):
845
+ if str(item.get("role") or "") == "assistant":
846
+ return item
847
+ return None
848
+
849
+ @staticmethod
850
+ def _latest_run_result(quest_root: Path) -> dict[str, object] | None:
851
+ runs_root = quest_root / ".ds" / "runs"
852
+ if not runs_root.exists():
853
+ return None
854
+ candidates = [path for path in runs_root.glob("*/result.json") if path.is_file()]
855
+ if not candidates:
856
+ return None
857
+ latest = max(candidates, key=lambda path: path.stat().st_mtime)
858
+ payload = read_json(latest, {})
859
+ if not isinstance(payload, dict):
860
+ return None
861
+ preview = (
862
+ str(payload.get("output_text") or "").strip()
863
+ or str(payload.get("stderr_text") or "").strip()
864
+ )
865
+ return {
866
+ "run_id": latest.parent.name,
867
+ "completed_at": str(payload.get("completed_at") or "").strip() or None,
868
+ "exit_code": payload.get("exit_code"),
869
+ "preview": preview,
870
+ }
871
+
740
872
  def _skill_paths_block(self) -> str:
741
873
  lines = []
742
- for skill_id in STANDARD_SKILLS:
874
+ for skill_id in current_standard_skills(self.repo_root):
743
875
  primary = (self.repo_root / "src" / "skills" / skill_id / "SKILL.md").resolve()
744
876
  lines.append(f"- {skill_id}: primary={primary}")
745
877
  return "\n".join(lines)
746
878
 
747
879
  def _companion_skill_paths_block(self) -> str:
748
880
  lines = []
749
- for skill_id in COMPANION_SKILLS:
881
+ for skill_id in current_companion_skills(self.repo_root):
750
882
  primary = (self.repo_root / "src" / "skills" / skill_id / "SKILL.md").resolve()
751
883
  lines.append(f"- {skill_id}: primary={primary}")
752
884
  return "\n".join(lines)
@@ -760,6 +892,18 @@ class PromptBuilder:
760
892
  return value
761
893
  return True
762
894
 
895
+ @staticmethod
896
+ def _workspace_mode(snapshot: dict) -> str:
897
+ value = str(snapshot.get("workspace_mode") or "").strip().lower()
898
+ if value in {"copilot", "autonomous"}:
899
+ return value
900
+ startup_contract = snapshot.get("startup_contract")
901
+ if isinstance(startup_contract, dict):
902
+ value = str(startup_contract.get("workspace_mode") or "").strip().lower()
903
+ if value in {"copilot", "autonomous"}:
904
+ return value
905
+ return "autonomous"
906
+
763
907
  @staticmethod
764
908
  def _decision_policy(snapshot: dict) -> str:
765
909
  startup_contract = snapshot.get("startup_contract")
@@ -824,6 +968,18 @@ class PromptBuilder:
824
968
  return "none"
825
969
 
826
970
  def _research_delivery_policy_block(self, snapshot: dict) -> str:
971
+ if self._workspace_mode(snapshot) == "copilot":
972
+ return "\n".join(
973
+ [
974
+ "- workspace_mode: copilot",
975
+ "- delivery_goal: complete the user-requested unit of work instead of forcing the full research graph by default.",
976
+ "- task_scope_rule: arbitrary research tasks such as reading, coding, debugging, experiment design, run inspection, analysis, writing, and planning can all be handled directly in this mode.",
977
+ "- autonomy_boundary: only expand into longer autonomous continuation when the user explicitly asks for end-to-end or unattended progress.",
978
+ "- routing_rule: open only the skills actually needed for the current request.",
979
+ "- durability_rule: keep important plan, evidence, decisions, and outputs durable in quest files or artifacts so later turns can resume cleanly.",
980
+ "- completion_rule: after the requested unit is complete, summarize what changed and stop instead of auto-continuing.",
981
+ ]
982
+ )
827
983
  need_research_paper = self._need_research_paper(snapshot)
828
984
  launch_mode = self._launch_mode(snapshot)
829
985
  standard_profile = self._standard_profile(snapshot)
@@ -1029,6 +1185,30 @@ class PromptBuilder:
1029
1185
  def _interaction_style_block(self, *, default_locale: str, user_message: str, snapshot: dict) -> str:
1030
1186
  normalized_locale = str(default_locale or "").lower()
1031
1187
  chinese_turn = normalized_locale.startswith("zh") or bool(re.search(r"[\u4e00-\u9fff]", user_message))
1188
+ if self._workspace_mode(snapshot) == "copilot":
1189
+ lines = [
1190
+ f"- configured_default_locale: {default_locale}",
1191
+ f"- current_turn_language_bias: {'zh' if chinese_turn else 'en'}",
1192
+ "- collaboration_mode: user-directed copilot",
1193
+ "- freeform_task_rule: if the user asks for a concrete research task, solve that task directly before introducing stage-routing language.",
1194
+ "- requested_skill_hint_rule: in copilot mode, treat `requested_skill` as a lightweight routing hint, not as an instruction to default into `decision` for ordinary direct tasks.",
1195
+ "- response_pattern: say what changed -> say what it means -> say what happens next",
1196
+ "- mailbox_protocol: artifact.interact(include_recent_inbound_messages=True) remains the queued human-message mailbox and should be checked whenever human continuity matters.",
1197
+ "- planning_rule: before non-trivial execution, make the immediate plan explicit and keep the first step small.",
1198
+ "- tool_rule: use memory for durable recall, artifact for quest state and git-aware research operations, and bash_exec for terminal execution.",
1199
+ "- copilot_sop_rule: classify the request first, choose the narrowest correct tool path, execute the smallest useful unit, persist the important result, then answer plainly.",
1200
+ "- shell_tool_mandate: **for any shell, CLI, Python, bash, node, git, npm, uv, or environment command execution, use `bash_exec(...)`; do not use native `shell_command` or Codex `command_execution`.**",
1201
+ "- git_tool_mandate: for git work inside the current quest repository or worktree, prefer `artifact.git(...)` before raw shell git commands.",
1202
+ "- git_test_rule: if the user wants a generic git smoke test rather than a quest-repo mutation, use `bash_exec(...)` in an isolated scratch repository.",
1203
+ "- decision_entry_rule: use `decision` only for real route, scope, cost, branch, or scientific-direction judgments; do not default to it for ordinary repo, code, environment, or execution tasks.",
1204
+ "- stop_rule: once the current requested unit is done, send a concise update and wait for the next message or `/resume`.",
1205
+ "- escalation_rule: if a route change materially affects cost, scope, or scientific direction, ask before proceeding.",
1206
+ ]
1207
+ if chinese_turn:
1208
+ lines.append("- tone_hint: 使用自然、礼貌、专业的中文,先解释结论,再说明下一步。")
1209
+ else:
1210
+ lines.append("- tone_hint: use concise, natural, professional English and lead with the conclusion.")
1211
+ return "\n".join(lines)
1032
1212
  bound_conversations = snapshot.get("bound_conversations") or []
1033
1213
  need_research_paper = self._need_research_paper(snapshot)
1034
1214
  decision_policy = self._decision_policy(snapshot)
@@ -1047,6 +1227,7 @@ class PromptBuilder:
1047
1227
  "- response_pattern: say what changed -> say what it means -> say what happens next",
1048
1228
  "- interaction_protocol: first message may be plain conversation; after that, treat artifact.interact threads and mailbox polls as the main continuity spine across TUI, web, and connectors",
1049
1229
  "- shared_interaction_contract_precedence: use the shared interaction contract as the default user-facing cadence; the rules below add runtime-specific execution behavior instead of restating the same chat cadence",
1230
+ "- shell_tool_mandate: **native `shell_command` / `command_execution` is forbidden; all shell-like execution must use `bash_exec(...)`.**",
1050
1231
  "- mailbox_protocol: artifact.interact(include_recent_inbound_messages=True) is the queued human-message mailbox; when it returns user text, treat that input as higher priority than background subtasks until it has been acknowledged",
1051
1232
  "- acknowledgment_protocol: after artifact.interact returns any human message, immediately send one substantive artifact.interact(...) follow-up; if the active connector runtime already emitted a transport-level receipt acknowledgement, do not send a redundant receipt-only message; if answerable, answer directly, otherwise state the short plan, nearest checkpoint, and that the current background subtask is paused",
1052
1233
  "- subtask_boundary_protocol: send a user-visible update whenever the active subtask changes materially, especially across intake -> audit, audit -> experiment planning, experiment planning -> run launch, run result -> drafting, or drafting -> review/rebuttal",
@@ -1055,6 +1236,10 @@ class PromptBuilder:
1055
1236
  "- long_run_reporting_protocol: inspect real logs/status after each meaningful await cycle and at least once every 30 minutes at worst, but only send a user-visible update when there is a human-meaningful delta, blocker, recovery, route change, or the visibility bound would otherwise be exceeded",
1056
1237
  "- intervention_threshold_protocol: do not kill or restart a run merely because a short watch window passed without final completion; intervene only on explicit failure, clear invalidity, process exit, or no meaningful delta across a sufficiently long observation window",
1057
1238
  "- timeout_protocol: before using bash_exec(mode='await', ...), estimate whether the command can finish within the selected wait window; if runtime is uncertain or likely longer, use bash_exec(mode='detach', ...) and monitor instead of guessing a fake deadline",
1239
+ f"- auto_continue_monitoring_protocol: if the runtime schedules background-progress auto_continue turns while a real external task is already active, treat them as low-frequency monitoring passes roughly every {_AUTO_CONTINUE_MONITOR_INTERVAL_SECONDS} seconds rather than as a fast polling loop",
1240
+ "- auto_continue_prepare_protocol: in autonomous mode before a real long-running external task exists, rapid auto-continue passes around 0.2 seconds apart are acceptable only for active preparation, launch, or durable route closure work; they are not a substitute for starting the real task",
1241
+ "- long_run_ownership_protocol: real long-running execution should stay alive in detached bash_exec sessions or the runtime process it launched; do not rely on repeated model turns to simulate continuous execution",
1242
+ "- auto_continue_resume_protocol: on auto_continue turns, read the resume context spine first and continue from the latest durable user requirement, latest assistant checkpoint, latest run summary, recent memory cues, and current bash_exec state before changing route",
1058
1243
  "- blocking_protocol: use reply_mode='blocking' only for true unresolved user decisions; ordinary progress updates should stay threaded and non-blocking",
1059
1244
  "- credential_blocking_protocol: if continuation requires user-supplied external credentials or secrets such as an API key, GitHub key/token, or Hugging Face key/token, emit one structured blocking decision request that asks the user to provide the credential or choose an alternative route; do not invent placeholders or silently skip the blocked step",
1060
1245
  "- credential_wait_protocol: if that credential request remains unanswered, keep the quest waiting rather than self-resolving; if you are resumed without new credentials and no other work is possible, a long low-frequency park such as `bash_exec(command='sleep 3600', mode='await', timeout_seconds=3700)` is acceptable to avoid busy-looping",
@@ -1212,13 +1397,51 @@ class PromptBuilder:
1212
1397
  plan = STAGE_MEMORY_PLAN.get(stage, STAGE_MEMORY_PLAN["decision"])
1213
1398
  quest_kinds = ", ".join(plan.get("quest", ())) or "none"
1214
1399
  global_kinds = ", ".join(plan.get("global", ())) or "none"
1215
- return "\n".join(
1216
- [
1217
- f"- stage_memory_rule: for `{stage}`, prefer quest memory kinds [{quest_kinds}] and global memory kinds [{global_kinds}] when memory lookup is needed.",
1218
- "- memory_lookup_tool: call memory.list_recent(...) to recover context after pause/restart and memory.search(...) before repeating prior work.",
1219
- "- memory_injection_rule: memory is intentionally not pre-expanded here; pull only the cards that matter now.",
1220
- ]
1221
- )
1400
+ lines = [
1401
+ f"- stage_memory_rule: for `{stage}`, prefer quest memory kinds [{quest_kinds}] and global memory kinds [{global_kinds}] when memory lookup is needed.",
1402
+ "- memory_lookup_tool: call memory.list_recent(...) to recover context after pause/restart and memory.search(...) before repeating prior work.",
1403
+ "- memory_injection_rule: keep the injected memory compact, but do not drop all continuity on auto_continue turns; reuse a few recent durable cues directly when they materially anchor the next action.",
1404
+ ]
1405
+ selected: list[dict] = []
1406
+ seen_paths: set[str] = set()
1407
+ for kind in plan.get("quest", ())[:2]:
1408
+ for card in self.memory_service.list_recent(scope="quest", quest_root=quest_root, limit=2, kind=kind)[:1]:
1409
+ self._append_priority_memory(
1410
+ selected,
1411
+ seen_paths,
1412
+ card=card,
1413
+ scope="quest",
1414
+ quest_root=quest_root,
1415
+ reason=f"recent quest memory for stage `{stage}`",
1416
+ )
1417
+ for kind in plan.get("global", ())[:2]:
1418
+ for card in self.memory_service.list_recent(scope="global", limit=2, kind=kind)[:1]:
1419
+ self._append_priority_memory(
1420
+ selected,
1421
+ seen_paths,
1422
+ card=card,
1423
+ scope="global",
1424
+ quest_root=quest_root,
1425
+ reason=f"recent global memory for stage `{stage}`",
1426
+ )
1427
+ for query in self._memory_queries(user_message)[:2]:
1428
+ for scope in ("quest", "global"):
1429
+ for card in self.memory_service.search(
1430
+ query,
1431
+ scope=scope if scope == "global" else "quest",
1432
+ quest_root=quest_root if scope == "quest" else None,
1433
+ limit=1,
1434
+ ):
1435
+ self._append_priority_memory(
1436
+ selected,
1437
+ seen_paths,
1438
+ card=card,
1439
+ scope=scope,
1440
+ quest_root=quest_root,
1441
+ reason=f"matched current-turn query `{query}`",
1442
+ )
1443
+ lines.extend(["- selected_memory:", self._format_priority_memory(selected)])
1444
+ return "\n".join(lines)
1222
1445
 
1223
1446
  def _append_priority_memory(
1224
1447
  self,