@researai/deepscientist 1.5.15 → 1.5.17
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.
- package/README.md +385 -104
- package/bin/ds.js +1241 -110
- package/docs/en/00_QUICK_START.md +100 -19
- package/docs/en/01_SETTINGS_REFERENCE.md +34 -1
- package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/en/05_TUI_GUIDE.md +6 -0
- package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
- package/docs/en/09_DOCTOR.md +25 -8
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
- package/docs/en/15_CODEX_PROVIDER_SETUP.md +37 -11
- package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
- package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
- package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
- package/docs/en/21_LOCAL_MODEL_BACKENDS_GUIDE.md +283 -0
- package/docs/en/91_DEVELOPMENT.md +237 -0
- package/docs/en/README.md +24 -2
- package/docs/zh/00_QUICK_START.md +89 -19
- package/docs/zh/01_SETTINGS_REFERENCE.md +34 -1
- package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/zh/05_TUI_GUIDE.md +6 -0
- package/docs/zh/09_DOCTOR.md +26 -9
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
- package/docs/zh/15_CODEX_PROVIDER_SETUP.md +37 -11
- package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
- package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
- package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
- package/docs/zh/21_LOCAL_MODEL_BACKENDS_GUIDE.md +281 -0
- package/docs/zh/README.md +24 -2
- package/install.sh +46 -4
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/acp/envelope.py +6 -0
- package/src/deepscientist/artifact/service.py +647 -22
- package/src/deepscientist/bash_exec/service.py +234 -9
- package/src/deepscientist/bridges/connectors.py +8 -2
- package/src/deepscientist/cli.py +115 -19
- package/src/deepscientist/codex_cli_compat.py +367 -22
- package/src/deepscientist/config/models.py +2 -1
- package/src/deepscientist/config/service.py +183 -13
- package/src/deepscientist/daemon/api/handlers.py +255 -31
- package/src/deepscientist/daemon/api/router.py +9 -0
- package/src/deepscientist/daemon/app.py +1146 -105
- package/src/deepscientist/diagnostics/__init__.py +6 -0
- package/src/deepscientist/diagnostics/runner_failures.py +130 -0
- package/src/deepscientist/doctor.py +207 -3
- package/src/deepscientist/gitops/__init__.py +10 -1
- package/src/deepscientist/gitops/diff.py +129 -0
- package/src/deepscientist/gitops/service.py +4 -1
- package/src/deepscientist/mcp/server.py +39 -0
- package/src/deepscientist/prompts/builder.py +275 -34
- package/src/deepscientist/quest/layout.py +15 -2
- package/src/deepscientist/quest/service.py +707 -55
- package/src/deepscientist/quest/stage_views.py +6 -1
- package/src/deepscientist/runners/codex.py +143 -43
- package/src/deepscientist/shared.py +19 -0
- package/src/deepscientist/skills/__init__.py +2 -2
- package/src/deepscientist/skills/installer.py +196 -5
- package/src/deepscientist/skills/registry.py +66 -0
- package/src/prompts/connectors/qq.md +18 -8
- package/src/prompts/connectors/weixin.md +16 -6
- package/src/prompts/contracts/shared_interaction.md +14 -2
- package/src/prompts/system.md +23 -5
- package/src/prompts/system_copilot.md +56 -0
- package/src/skills/analysis-campaign/SKILL.md +1 -0
- package/src/skills/baseline/SKILL.md +8 -0
- package/src/skills/decision/SKILL.md +8 -0
- package/src/skills/experiment/SKILL.md +8 -0
- package/src/skills/figure-polish/SKILL.md +1 -0
- package/src/skills/finalize/SKILL.md +1 -0
- package/src/skills/idea/SKILL.md +1 -0
- package/src/skills/intake-audit/SKILL.md +8 -0
- package/src/skills/mentor/SKILL.md +217 -0
- package/src/skills/mentor/references/correction-rules.md +210 -0
- package/src/skills/mentor/references/knowledge-profile.md +91 -0
- package/src/skills/mentor/references/persona-profile.md +138 -0
- package/src/skills/mentor/references/taste-profile.md +128 -0
- package/src/skills/mentor/references/thought-style-profile.md +138 -0
- package/src/skills/mentor/references/work-profile.md +289 -0
- package/src/skills/mentor/references/workflow-profile.md +240 -0
- package/src/skills/optimize/SKILL.md +1 -0
- package/src/skills/rebuttal/SKILL.md +1 -0
- package/src/skills/review/SKILL.md +1 -0
- package/src/skills/scout/SKILL.md +8 -0
- package/src/skills/write/SKILL.md +1 -0
- package/src/tui/dist/app/AppContainer.js +19 -11
- package/src/tui/dist/index.js +4 -1
- package/src/tui/dist/lib/api.js +33 -3
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/AiManusChatView-Bv-Z8YpU.js +204 -0
- package/src/ui/dist/assets/AnalysisPlugin-BCKAfjba.js +1 -0
- package/src/ui/dist/assets/CliPlugin-BCKcpc35.js +109 -0
- package/src/ui/dist/assets/CodeEditorPlugin-DbOfSJ8K.js +2 -0
- package/src/ui/dist/assets/CodeViewerPlugin-CbaFRrUU.js +270 -0
- package/src/ui/dist/assets/DocViewerPlugin-DAjLVeQD.js +7 -0
- package/src/ui/dist/assets/GitCommitViewerPlugin-CIUqbUDO.js +1 -0
- package/src/ui/dist/assets/GitDiffViewerPlugin-CQACjoAA.js +6 -0
- package/src/ui/dist/assets/GitSnapshotViewer-0r4nLPke.js +30 -0
- package/src/ui/dist/assets/ImageViewerPlugin-nBOmI2v_.js +26 -0
- package/src/ui/dist/assets/LabCopilotPanel-BHxOxF4z.js +14 -0
- package/src/ui/dist/assets/LabPlugin-BKoZGs95.js +22 -0
- package/src/ui/dist/assets/LatexPlugin-ZwtV8pIp.js +25 -0
- package/src/ui/dist/assets/MarkdownViewerPlugin-DKqVfKyW.js +128 -0
- package/src/ui/dist/assets/MarketplacePlugin-BwxStZ9D.js +13 -0
- package/src/ui/dist/assets/NotebookEditor-BEQhaQbt.js +81 -0
- package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
- package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
- package/src/ui/dist/assets/NotebookEditor-DB9N_T9q.js +361 -0
- package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
- package/src/ui/dist/assets/PdfLoader-eWBONbQP.js +16 -0
- package/src/ui/dist/assets/PdfMarkdownPlugin-D22YOZL3.js +1 -0
- package/src/ui/dist/assets/PdfViewerPlugin-c-RK9DLM.js +17 -0
- package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
- package/src/ui/dist/assets/SearchPlugin-CxF9ytAx.js +16 -0
- package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
- package/src/ui/dist/assets/TextViewerPlugin-C5xqeeUH.js +54 -0
- package/src/ui/dist/assets/VNCViewer-BoLGLnHz.js +11 -0
- package/src/ui/dist/assets/bot-DREQOxzP.js +6 -0
- package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
- package/src/ui/dist/assets/chevron-up-C9Qpx4DE.js +6 -0
- package/src/ui/dist/assets/code-WlFHE7z_.js +6 -0
- package/src/ui/dist/assets/file-content-BZMz3RYp.js +1 -0
- package/src/ui/dist/assets/file-diff-panel-CQhw0jS2.js +1 -0
- package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
- package/src/ui/dist/assets/file-socket-CfQPKQKj.js +1 -0
- package/src/ui/dist/assets/git-commit-horizontal-DxZ8DCZh.js +6 -0
- package/src/ui/dist/assets/image-Bgl4VIyx.js +6 -0
- package/src/ui/dist/assets/index-BpV6lusQ.css +33 -0
- package/src/ui/dist/assets/index-CBNVuWcP.js +2496 -0
- package/src/ui/dist/assets/index-CwNu1aH4.js +11 -0
- package/src/ui/dist/assets/index-DrUnlf6K.js +1 -0
- package/src/ui/dist/assets/index-NW-h8VzN.js +1 -0
- package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
- package/src/ui/dist/assets/pdf-effect-queue-J8OnM0jE.js +6 -0
- package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
- package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
- package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
- package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
- package/src/ui/dist/assets/popover-CLc0pPP8.js +1 -0
- package/src/ui/dist/assets/project-sync-C9IdzdZW.js +1 -0
- package/src/ui/dist/assets/select-Cs2PmzwL.js +11 -0
- package/src/ui/dist/assets/sigma-ClKcHAXm.js +6 -0
- package/src/ui/dist/assets/trash-DwpbFr3w.js +11 -0
- package/src/ui/dist/assets/useCliAccess-NQ8m0Let.js +1 -0
- package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
- package/src/ui/dist/assets/wrap-text-BC-Hltpd.js +11 -0
- package/src/ui/dist/assets/zoom-out-E_gaeAxL.js +11 -0
- package/src/ui/dist/index.html +5 -2
- package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
- package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
- package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
- package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
- package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
- package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
- package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
- package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
- package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
- package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
- package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
- package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
- package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
- package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
- package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
- package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
- package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
- package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
- package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
- package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
- package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
- package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
- package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
- package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
- package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
- package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
- package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
- package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
- package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
- package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
- package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
- package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
- package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
- package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
- package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
- package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
- package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
- package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
- package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
- package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
- package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
- package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
- package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
- package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
- package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
- package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
- package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
- package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
- package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
- package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
- package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
- package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
- package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
- package/src/ui/dist/assets/zoom-out-BgVMmOW4.js +0 -34
|
@@ -234,10 +234,11 @@ class QuestStageViewBuilder:
|
|
|
234
234
|
|
|
235
235
|
def build(self) -> dict[str, Any]:
|
|
236
236
|
selection_type = str(self.selection.get("selection_type") or "").strip()
|
|
237
|
+
explicit_stage_key = str(self.selection.get("stage_key") or "").strip()
|
|
237
238
|
self.stage_key = self._resolve_effective_stage_key()
|
|
238
239
|
if selection_type == "idea_candidate":
|
|
239
240
|
return self._build_idea_candidate()
|
|
240
|
-
if selection_type == "branch_node" and
|
|
241
|
+
if selection_type == "branch_node" and not explicit_stage_key:
|
|
241
242
|
return self._build_branch()
|
|
242
243
|
if self.stage_key == "baseline":
|
|
243
244
|
return self._build_baseline()
|
|
@@ -1288,11 +1289,15 @@ class QuestStageViewBuilder:
|
|
|
1288
1289
|
for item in self.artifacts
|
|
1289
1290
|
if self._branch_matches(self._payload(item), allow_parent=True, include_unscoped=False)
|
|
1290
1291
|
]
|
|
1292
|
+
latest_branch_payload = self._payload(branch_items[-1] if branch_items else {})
|
|
1291
1293
|
note = (
|
|
1292
1294
|
str(
|
|
1293
1295
|
latest_experiment_payload.get("summary")
|
|
1294
1296
|
or latest_idea_payload.get("summary")
|
|
1295
1297
|
or latest_idea_payload.get("reason")
|
|
1298
|
+
or latest_branch_payload.get("summary")
|
|
1299
|
+
or latest_branch_payload.get("message")
|
|
1300
|
+
or latest_branch_payload.get("reason")
|
|
1296
1301
|
or self.trace.get("summary")
|
|
1297
1302
|
or self.selection.get("summary")
|
|
1298
1303
|
or ""
|
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import signal
|
|
6
|
-
import shutil
|
|
7
6
|
import subprocess
|
|
8
7
|
import sys
|
|
9
8
|
import threading
|
|
@@ -11,12 +10,18 @@ from pathlib import Path
|
|
|
11
10
|
from typing import Any
|
|
12
11
|
|
|
13
12
|
from ..artifact import ArtifactService
|
|
14
|
-
from ..codex_cli_compat import
|
|
13
|
+
from ..codex_cli_compat import (
|
|
14
|
+
active_provider_metadata_from_home,
|
|
15
|
+
materialize_codex_runtime_home,
|
|
16
|
+
normalize_codex_reasoning_effort,
|
|
17
|
+
provider_profile_metadata_from_home,
|
|
18
|
+
)
|
|
15
19
|
from ..config import ConfigManager
|
|
16
20
|
from ..gitops import export_git_graph
|
|
21
|
+
from ..process_control import process_session_popen_kwargs
|
|
17
22
|
from ..prompts import PromptBuilder
|
|
18
23
|
from ..runtime_logs import JsonlLogger
|
|
19
|
-
from ..shared import append_jsonl, ensure_dir, generate_id,
|
|
24
|
+
from ..shared import append_jsonl, ensure_dir, generate_id, read_yaml, resolve_runner_binary, utc_now, write_json, write_text
|
|
20
25
|
from ..web_search import extract_web_search_payload
|
|
21
26
|
from .base import RunRequest, RunResult
|
|
22
27
|
|
|
@@ -69,6 +74,12 @@ _BUILTIN_MCP_TOOL_APPROVALS: dict[str, tuple[str, ...]] = {
|
|
|
69
74
|
),
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
_PROVIDER_ENV_CONFLICT_KEYS = (
|
|
78
|
+
"OPENAI_API_KEY",
|
|
79
|
+
"OPENAI_BASE_URL",
|
|
80
|
+
)
|
|
81
|
+
_CHAT_WIRE_TOOL_CALL_GUARD_MARKER = "## Codex Chat-Wire Tool Call Compatibility"
|
|
82
|
+
|
|
72
83
|
|
|
73
84
|
def _compact_text(value: object, *, limit: int = 1200) -> str:
|
|
74
85
|
if value is None:
|
|
@@ -195,7 +206,9 @@ def _iter_event_texts(event: dict[str, Any]) -> list[str]:
|
|
|
195
206
|
if isinstance(value, str) and value.strip():
|
|
196
207
|
texts.append(value)
|
|
197
208
|
delta = event.get("delta")
|
|
198
|
-
if isinstance(delta,
|
|
209
|
+
if isinstance(delta, str) and delta.strip():
|
|
210
|
+
texts.append(delta)
|
|
211
|
+
elif isinstance(delta, dict):
|
|
199
212
|
for key in ("text", "content"):
|
|
200
213
|
value = delta.get(key)
|
|
201
214
|
if isinstance(value, str) and value.strip():
|
|
@@ -222,6 +235,36 @@ def _web_search_text_payload(item: dict[str, Any]) -> str:
|
|
|
222
235
|
return _compact_text(payload, limit=2400)
|
|
223
236
|
|
|
224
237
|
|
|
238
|
+
def _message_stream_id(event: dict[str, Any], item: dict[str, Any], *, run_id: str, kind: str) -> str:
|
|
239
|
+
for value in (
|
|
240
|
+
event.get("stream_id"),
|
|
241
|
+
item.get("stream_id"),
|
|
242
|
+
event.get("message_id"),
|
|
243
|
+
item.get("message_id"),
|
|
244
|
+
event.get("item_id"),
|
|
245
|
+
item.get("id"),
|
|
246
|
+
event.get("output_item_id"),
|
|
247
|
+
event.get("response_id"),
|
|
248
|
+
):
|
|
249
|
+
if value:
|
|
250
|
+
return str(value)
|
|
251
|
+
normalized_kind = str(kind or "message").strip().lower() or "message"
|
|
252
|
+
return f"{run_id}:{normalized_kind}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _message_id(event: dict[str, Any], item: dict[str, Any], *, stream_id: str) -> str:
|
|
256
|
+
for value in (
|
|
257
|
+
event.get("message_id"),
|
|
258
|
+
item.get("message_id"),
|
|
259
|
+
event.get("item_id"),
|
|
260
|
+
item.get("id"),
|
|
261
|
+
event.get("output_item_id"),
|
|
262
|
+
):
|
|
263
|
+
if value:
|
|
264
|
+
return str(value)
|
|
265
|
+
return stream_id
|
|
266
|
+
|
|
267
|
+
|
|
225
268
|
def _message_events(
|
|
226
269
|
event: dict[str, Any],
|
|
227
270
|
*,
|
|
@@ -238,6 +281,8 @@ def _message_events(
|
|
|
238
281
|
|
|
239
282
|
if item_type == "agent_message":
|
|
240
283
|
texts = _dedupe_texts(_iter_event_texts(event))
|
|
284
|
+
stream_id = _message_stream_id(event, item, run_id=run_id, kind="assistant")
|
|
285
|
+
message_id = _message_id(event, item, stream_id=stream_id)
|
|
241
286
|
for text in texts:
|
|
242
287
|
quest_events.append(
|
|
243
288
|
{
|
|
@@ -248,6 +293,8 @@ def _message_events(
|
|
|
248
293
|
"source": "codex",
|
|
249
294
|
"skill_id": skill_id,
|
|
250
295
|
"text": text,
|
|
296
|
+
"stream_id": stream_id,
|
|
297
|
+
"message_id": message_id,
|
|
251
298
|
"created_at": created_at,
|
|
252
299
|
}
|
|
253
300
|
)
|
|
@@ -255,6 +302,8 @@ def _message_events(
|
|
|
255
302
|
|
|
256
303
|
if item_type in {"reasoning", "reasoning_summary"} or "reasoning" in event_type:
|
|
257
304
|
texts = _dedupe_texts(_iter_event_texts(event))
|
|
305
|
+
stream_id = _message_stream_id(event, item, run_id=run_id, kind=item_type or "reasoning")
|
|
306
|
+
message_id = _message_id(event, item, stream_id=stream_id)
|
|
258
307
|
for text in texts:
|
|
259
308
|
quest_events.append(
|
|
260
309
|
{
|
|
@@ -265,6 +314,8 @@ def _message_events(
|
|
|
265
314
|
"source": "codex",
|
|
266
315
|
"skill_id": skill_id,
|
|
267
316
|
"text": text,
|
|
317
|
+
"stream_id": stream_id,
|
|
318
|
+
"message_id": message_id,
|
|
268
319
|
"kind": item_type or "reasoning",
|
|
269
320
|
"created_at": created_at,
|
|
270
321
|
}
|
|
@@ -278,6 +329,8 @@ def _message_events(
|
|
|
278
329
|
return [], []
|
|
279
330
|
|
|
280
331
|
texts = _dedupe_texts(_iter_event_texts(event))
|
|
332
|
+
stream_id = _message_stream_id(event, item, run_id=run_id, kind="assistant")
|
|
333
|
+
message_id = _message_id(event, item, stream_id=stream_id)
|
|
281
334
|
for text in texts:
|
|
282
335
|
quest_events.append(
|
|
283
336
|
{
|
|
@@ -288,6 +341,8 @@ def _message_events(
|
|
|
288
341
|
"source": "codex",
|
|
289
342
|
"skill_id": skill_id,
|
|
290
343
|
"text": text,
|
|
344
|
+
"stream_id": stream_id,
|
|
345
|
+
"message_id": message_id,
|
|
291
346
|
"created_at": created_at,
|
|
292
347
|
}
|
|
293
348
|
)
|
|
@@ -679,6 +734,18 @@ class CodexRunner:
|
|
|
679
734
|
self._process_lock = threading.Lock()
|
|
680
735
|
self._active_processes: dict[str, subprocess.Popen[str]] = {}
|
|
681
736
|
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _subprocess_popen_kwargs(*, workspace_root: Path, env: dict[str, str]) -> dict[str, Any]:
|
|
739
|
+
return {
|
|
740
|
+
"cwd": str(workspace_root),
|
|
741
|
+
"env": env,
|
|
742
|
+
"stdin": subprocess.PIPE,
|
|
743
|
+
"stdout": subprocess.PIPE,
|
|
744
|
+
"stderr": subprocess.PIPE,
|
|
745
|
+
"text": True,
|
|
746
|
+
**process_session_popen_kwargs(hide_window=True),
|
|
747
|
+
}
|
|
748
|
+
|
|
682
749
|
def run(self, request: RunRequest) -> RunResult:
|
|
683
750
|
workspace_root = request.worktree_root or request.quest_root
|
|
684
751
|
run_root = ensure_dir(request.quest_root / ".ds" / "runs" / request.run_id)
|
|
@@ -694,6 +761,7 @@ class CodexRunner:
|
|
|
694
761
|
turn_mode=request.turn_mode,
|
|
695
762
|
retry_context=request.retry_context,
|
|
696
763
|
)
|
|
764
|
+
prompt = self._apply_chat_wire_tool_call_guard(prompt, runner_config=runner_config)
|
|
697
765
|
write_text(run_root / "prompt.md", prompt)
|
|
698
766
|
|
|
699
767
|
codex_home = self._prepare_project_codex_home(
|
|
@@ -729,6 +797,7 @@ class CodexRunner:
|
|
|
729
797
|
continue
|
|
730
798
|
env[env_key] = env_value
|
|
731
799
|
env["CODEX_HOME"] = str(codex_home)
|
|
800
|
+
env = self._sanitize_provider_env(env, runner_config=runner_config)
|
|
732
801
|
env["DEEPSCIENTIST_HOME"] = str(self.home)
|
|
733
802
|
env["DS_HOME"] = str(self.home)
|
|
734
803
|
env["DS_QUEST_ID"] = request.quest_id
|
|
@@ -743,18 +812,7 @@ class CodexRunner:
|
|
|
743
812
|
env["DS_CONVERSATION_ID"] = f"quest:{request.quest_id}"
|
|
744
813
|
env["DS_AGENT_ROLE"] = request.skill_id
|
|
745
814
|
env["DS_TEAM_MODE"] = "single"
|
|
746
|
-
popen_kwargs
|
|
747
|
-
"cwd": str(workspace_root),
|
|
748
|
-
"env": env,
|
|
749
|
-
"stdin": subprocess.PIPE,
|
|
750
|
-
"stdout": subprocess.PIPE,
|
|
751
|
-
"stderr": subprocess.PIPE,
|
|
752
|
-
"text": True,
|
|
753
|
-
}
|
|
754
|
-
if os.name == "nt" and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
|
755
|
-
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP")
|
|
756
|
-
else:
|
|
757
|
-
popen_kwargs["start_new_session"] = True
|
|
815
|
+
popen_kwargs = self._subprocess_popen_kwargs(workspace_root=workspace_root, env=env)
|
|
758
816
|
process = subprocess.Popen(command, **popen_kwargs)
|
|
759
817
|
with self._process_lock:
|
|
760
818
|
self._active_processes[request.quest_id] = process
|
|
@@ -975,6 +1033,8 @@ class CodexRunner:
|
|
|
975
1033
|
resolved_runner_config = runner_config if isinstance(runner_config, dict) else self._load_runner_config()
|
|
976
1034
|
profile = str(resolved_runner_config.get("profile") or "").strip()
|
|
977
1035
|
normalized_model = str(request.model or "").strip()
|
|
1036
|
+
if profile and normalized_model.lower() not in {"", "inherit", "default", "codex-default"}:
|
|
1037
|
+
normalized_model = "inherit"
|
|
978
1038
|
command = [
|
|
979
1039
|
resolved_binary or self.binary,
|
|
980
1040
|
"--search",
|
|
@@ -1010,6 +1070,46 @@ class CodexRunner:
|
|
|
1010
1070
|
command.append("-")
|
|
1011
1071
|
return command
|
|
1012
1072
|
|
|
1073
|
+
def _apply_chat_wire_tool_call_guard(
|
|
1074
|
+
self,
|
|
1075
|
+
prompt: str,
|
|
1076
|
+
*,
|
|
1077
|
+
runner_config: dict[str, Any] | None = None,
|
|
1078
|
+
) -> str:
|
|
1079
|
+
prompt_text = str(prompt or "")
|
|
1080
|
+
if not prompt_text or _CHAT_WIRE_TOOL_CALL_GUARD_MARKER in prompt_text:
|
|
1081
|
+
return prompt_text
|
|
1082
|
+
|
|
1083
|
+
resolved_runner_config = runner_config if isinstance(runner_config, dict) else self._load_runner_config()
|
|
1084
|
+
profile = str(resolved_runner_config.get("profile") or "").strip()
|
|
1085
|
+
if not profile:
|
|
1086
|
+
return prompt_text
|
|
1087
|
+
config_home = str(resolved_runner_config.get("config_dir") or os.environ.get("CODEX_HOME") or "").strip()
|
|
1088
|
+
if not config_home:
|
|
1089
|
+
return prompt_text
|
|
1090
|
+
|
|
1091
|
+
metadata = active_provider_metadata_from_home(config_home, profile=profile or None)
|
|
1092
|
+
wire_api = str(metadata.get("wire_api") or "").strip().lower()
|
|
1093
|
+
if wire_api != "chat":
|
|
1094
|
+
return prompt_text
|
|
1095
|
+
|
|
1096
|
+
provider = str(metadata.get("provider") or "").strip() or "unknown"
|
|
1097
|
+
guard_lines = [
|
|
1098
|
+
_CHAT_WIRE_TOOL_CALL_GUARD_MARKER,
|
|
1099
|
+
f"active_provider_profile: {profile}",
|
|
1100
|
+
f"active_provider_name: {provider}",
|
|
1101
|
+
"active_provider_wire_api: chat",
|
|
1102
|
+
"single_tool_call_per_turn_rule: emit at most one tool call in each assistant message.",
|
|
1103
|
+
"tool_call_serialization_rule: after each tool result, decide whether to make the next tool call or produce the answer.",
|
|
1104
|
+
"no_batched_mcp_rule: never bundle multiple `artifact.*`, `memory.*`, or `bash_exec.*` calls into the same response, even when the reads look independent.",
|
|
1105
|
+
"no_immediate_repeat_rule: if a tool already returned the information needed for the current subtask, do not immediately call that same tool again; move to the next tool or answer.",
|
|
1106
|
+
"state_recovery_preference_rule: on a fresh quest turn, prefer `artifact.get_quest_state`, `artifact.read_quest_documents`, and `memory.list_recent` to recover context before reaching for `bash_exec`.",
|
|
1107
|
+
"bash_exec_after_context_rule: use `bash_exec` only after you know the exact command you need and why the `artifact` / `memory` path is insufficient.",
|
|
1108
|
+
"tool_call_json_rule: every tool call must contain exactly one complete JSON object argument with no trailing characters.",
|
|
1109
|
+
]
|
|
1110
|
+
guard_block = "\n".join(guard_lines)
|
|
1111
|
+
return f"{prompt_text.rstrip()}\n\n{guard_block}\n"
|
|
1112
|
+
|
|
1013
1113
|
def _prepare_project_codex_home(
|
|
1014
1114
|
self,
|
|
1015
1115
|
workspace_root: Path,
|
|
@@ -1019,36 +1119,16 @@ class CodexRunner:
|
|
|
1019
1119
|
run_id: str,
|
|
1020
1120
|
runner_config: dict[str, Any] | None = None,
|
|
1021
1121
|
) -> Path:
|
|
1022
|
-
target = ensure_dir(workspace_root / ".codex")
|
|
1122
|
+
target = ensure_dir(workspace_root / ".ds" / "codex-home")
|
|
1023
1123
|
resolved_runner_config = runner_config if isinstance(runner_config, dict) else self._load_runner_config()
|
|
1024
1124
|
configured_home = str(resolved_runner_config.get("config_dir") or os.environ.get("CODEX_HOME") or str(Path.home() / ".codex"))
|
|
1025
1125
|
profile = str(resolved_runner_config.get("profile") or "").strip()
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
if source_path.resolve() == target_path.resolve():
|
|
1033
|
-
continue
|
|
1034
|
-
shutil.copy2(source_path, target_path)
|
|
1035
|
-
config_path = target / "config.toml"
|
|
1036
|
-
if profile and config_path.exists():
|
|
1037
|
-
adapted_text, _ = adapt_profile_only_provider_config(read_text(config_path), profile=profile)
|
|
1038
|
-
write_text(config_path, adapted_text)
|
|
1039
|
-
ensure_dir(target / "skills")
|
|
1040
|
-
quest_skills_root = quest_root / ".codex" / "skills"
|
|
1041
|
-
if quest_skills_root.exists():
|
|
1042
|
-
for source_path in sorted(quest_skills_root.rglob("*")):
|
|
1043
|
-
relative = source_path.relative_to(quest_skills_root)
|
|
1044
|
-
target_path = target / "skills" / relative
|
|
1045
|
-
if source_path.is_dir():
|
|
1046
|
-
ensure_dir(target_path)
|
|
1047
|
-
continue
|
|
1048
|
-
if source_path.resolve() == target_path.resolve():
|
|
1049
|
-
continue
|
|
1050
|
-
ensure_dir(target_path.parent)
|
|
1051
|
-
shutil.copy2(source_path, target_path)
|
|
1126
|
+
materialize_codex_runtime_home(
|
|
1127
|
+
source_home=configured_home,
|
|
1128
|
+
target_home=target,
|
|
1129
|
+
profile=profile,
|
|
1130
|
+
quest_codex_root=quest_root / ".codex",
|
|
1131
|
+
)
|
|
1052
1132
|
self._inject_built_in_mcp(
|
|
1053
1133
|
target,
|
|
1054
1134
|
quest_root=quest_root,
|
|
@@ -1157,3 +1237,23 @@ class CodexRunner:
|
|
|
1157
1237
|
except (TypeError, ValueError):
|
|
1158
1238
|
return None
|
|
1159
1239
|
return timeout if timeout > 0 else None
|
|
1240
|
+
|
|
1241
|
+
@staticmethod
|
|
1242
|
+
def _sanitize_provider_env(
|
|
1243
|
+
env: dict[str, str],
|
|
1244
|
+
*,
|
|
1245
|
+
runner_config: dict[str, Any] | None = None,
|
|
1246
|
+
) -> dict[str, str]:
|
|
1247
|
+
resolved_runner_config = runner_config if isinstance(runner_config, dict) else {}
|
|
1248
|
+
profile = str(resolved_runner_config.get("profile") or "").strip()
|
|
1249
|
+
config_home = str(resolved_runner_config.get("config_dir") or env.get("CODEX_HOME") or "").strip()
|
|
1250
|
+
if not config_home:
|
|
1251
|
+
return env
|
|
1252
|
+
metadata = active_provider_metadata_from_home(config_home, profile=profile or None)
|
|
1253
|
+
requires_openai_auth = metadata.get("requires_openai_auth")
|
|
1254
|
+
if requires_openai_auth is not False:
|
|
1255
|
+
return env
|
|
1256
|
+
sanitized = dict(env)
|
|
1257
|
+
for key in _PROVIDER_ENV_CONFLICT_KEYS:
|
|
1258
|
+
sanitized.pop(key, None)
|
|
1259
|
+
return sanitized
|
|
@@ -13,6 +13,8 @@ from pathlib import Path
|
|
|
13
13
|
from typing import Any, Iterator
|
|
14
14
|
from uuid import uuid4
|
|
15
15
|
|
|
16
|
+
from .process_control import process_session_popen_kwargs
|
|
17
|
+
|
|
16
18
|
try:
|
|
17
19
|
import yaml
|
|
18
20
|
except ModuleNotFoundError as exc: # pragma: no cover
|
|
@@ -168,6 +170,23 @@ def run_command(
|
|
|
168
170
|
check=check,
|
|
169
171
|
text=True,
|
|
170
172
|
capture_output=True,
|
|
173
|
+
**process_session_popen_kwargs(hide_window=True, new_process_group=False),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def run_command_bytes(
|
|
178
|
+
args: list[str],
|
|
179
|
+
*,
|
|
180
|
+
cwd: Path | None = None,
|
|
181
|
+
check: bool = True,
|
|
182
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
183
|
+
return subprocess.run(
|
|
184
|
+
args,
|
|
185
|
+
cwd=str(cwd) if cwd else None,
|
|
186
|
+
check=check,
|
|
187
|
+
text=False,
|
|
188
|
+
capture_output=True,
|
|
189
|
+
**process_session_popen_kwargs(hide_window=True, new_process_group=False),
|
|
171
190
|
)
|
|
172
191
|
|
|
173
192
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
from .installer import SkillInstaller
|
|
2
|
-
from .registry import SkillBundle, discover_skill_bundles
|
|
2
|
+
from .registry import SkillBundle, companion_skill_ids, discover_skill_bundles, stage_skill_ids
|
|
3
3
|
|
|
4
|
-
__all__ = ["SkillBundle", "SkillInstaller", "discover_skill_bundles"]
|
|
4
|
+
__all__ = ["SkillBundle", "SkillInstaller", "discover_skill_bundles", "stage_skill_ids", "companion_skill_ids"]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
3
5
|
import shutil
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
from uuid import uuid4
|
|
@@ -8,6 +10,10 @@ from ..memory.frontmatter import load_markdown_document
|
|
|
8
10
|
from ..shared import ensure_dir, read_json, utc_now, write_json
|
|
9
11
|
from .registry import discover_skill_bundles
|
|
10
12
|
|
|
13
|
+
_PROMPT_SYNC_STATE_FILENAME = ".deepscientist-prompt-sync.json"
|
|
14
|
+
_PROMPT_VERSIONS_DIRNAME = "prompt_versions"
|
|
15
|
+
_PROMPT_VERSIONS_INDEX_FILENAME = "index.json"
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
class SkillInstaller:
|
|
13
19
|
def __init__(self, repo_root: Path, home: Path) -> None:
|
|
@@ -40,9 +46,9 @@ class SkillInstaller:
|
|
|
40
46
|
"notes": [],
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
def sync_quest(self, quest_root: Path) -> dict:
|
|
49
|
+
def sync_quest(self, quest_root: Path, *, installed_version: str | None = None) -> dict:
|
|
50
|
+
prompt_sync = self.sync_quest_prompts(quest_root, installed_version=installed_version)
|
|
44
51
|
prompts_root = ensure_dir(quest_root / ".codex" / "prompts")
|
|
45
|
-
self._sync_prompt_tree(prompts_root)
|
|
46
52
|
codex_root = ensure_dir(quest_root / ".codex" / "skills")
|
|
47
53
|
claude_root = ensure_dir(quest_root / ".claude" / "agents")
|
|
48
54
|
copied_codex: list[str] = []
|
|
@@ -61,12 +67,13 @@ class SkillInstaller:
|
|
|
61
67
|
self._prune_bundle_targets(claude_root, expected_claude)
|
|
62
68
|
return {
|
|
63
69
|
"prompts": [str(path) for path in sorted(prompts_root.rglob("*")) if path.is_file()],
|
|
70
|
+
"prompt_sync": prompt_sync,
|
|
64
71
|
"codex": copied_codex,
|
|
65
72
|
"claude": copied_claude,
|
|
66
73
|
"notes": [],
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
def sync_existing_quests(self) -> dict:
|
|
76
|
+
def sync_existing_quests(self, *, installed_version: str | None = None) -> dict:
|
|
70
77
|
quests_root = self.home / "quests"
|
|
71
78
|
synced: list[dict[str, object]] = []
|
|
72
79
|
if not quests_root.exists():
|
|
@@ -79,13 +86,15 @@ class SkillInstaller:
|
|
|
79
86
|
continue
|
|
80
87
|
if not (quest_root / "quest.yaml").exists():
|
|
81
88
|
continue
|
|
82
|
-
result = self.sync_quest(quest_root)
|
|
89
|
+
result = self.sync_quest(quest_root, installed_version=installed_version)
|
|
83
90
|
synced.append(
|
|
84
91
|
{
|
|
85
92
|
"quest_id": quest_root.name,
|
|
86
93
|
"quest_root": str(quest_root),
|
|
87
94
|
"codex_count": len(result.get("codex") or []),
|
|
88
95
|
"claude_count": len(result.get("claude") or []),
|
|
96
|
+
"prompt_backup_id": (result.get("prompt_sync") or {}).get("backup_id"),
|
|
97
|
+
"prompt_fingerprint": (result.get("prompt_sync") or {}).get("prompt_fingerprint"),
|
|
89
98
|
}
|
|
90
99
|
)
|
|
91
100
|
return {
|
|
@@ -127,11 +136,193 @@ class SkillInstaller:
|
|
|
127
136
|
summary["global"] = self.sync_global()
|
|
128
137
|
summary["global_synced"] = True
|
|
129
138
|
if sync_existing_quests_enabled:
|
|
130
|
-
summary["existing_quests"] = self.sync_existing_quests()
|
|
139
|
+
summary["existing_quests"] = self.sync_existing_quests(installed_version=normalized_version)
|
|
131
140
|
summary["existing_quests_synced"] = True
|
|
132
141
|
self._write_release_sync_state(summary)
|
|
133
142
|
return summary
|
|
134
143
|
|
|
144
|
+
def sync_quest_prompts(
|
|
145
|
+
self,
|
|
146
|
+
quest_root: Path,
|
|
147
|
+
*,
|
|
148
|
+
installed_version: str | None = None,
|
|
149
|
+
) -> dict[str, object]:
|
|
150
|
+
prompts_root = ensure_dir(quest_root / ".codex" / "prompts")
|
|
151
|
+
source_root = self.repo_root / "src" / "prompts"
|
|
152
|
+
normalized_version = self._normalized_installed_version(installed_version)
|
|
153
|
+
previous_state = self._read_prompt_sync_state(prompts_root)
|
|
154
|
+
current_fingerprint = self._prompt_tree_fingerprint(prompts_root, exclude_state_file=True)
|
|
155
|
+
source_fingerprint = self._prompt_tree_fingerprint(source_root, exclude_state_file=False)
|
|
156
|
+
backup_id: str | None = None
|
|
157
|
+
updated = False
|
|
158
|
+
|
|
159
|
+
if current_fingerprint != source_fingerprint:
|
|
160
|
+
if current_fingerprint:
|
|
161
|
+
backup_id = self._backup_prompt_tree(
|
|
162
|
+
quest_root,
|
|
163
|
+
prompts_root=prompts_root,
|
|
164
|
+
installed_version=str(previous_state.get("installed_version") or normalized_version),
|
|
165
|
+
prompt_fingerprint=current_fingerprint,
|
|
166
|
+
)
|
|
167
|
+
self._sync_prompt_tree(prompts_root)
|
|
168
|
+
updated = True
|
|
169
|
+
|
|
170
|
+
prompt_state = {
|
|
171
|
+
"installed_version": normalized_version,
|
|
172
|
+
"prompt_fingerprint": self._prompt_tree_fingerprint(prompts_root, exclude_state_file=True),
|
|
173
|
+
"synced_at": utc_now(),
|
|
174
|
+
"backup_id": backup_id,
|
|
175
|
+
"source_root": str(source_root),
|
|
176
|
+
}
|
|
177
|
+
write_json(self._prompt_sync_state_path(prompts_root), prompt_state)
|
|
178
|
+
return {
|
|
179
|
+
"updated": updated,
|
|
180
|
+
"backup_id": backup_id,
|
|
181
|
+
"prompt_fingerprint": prompt_state["prompt_fingerprint"],
|
|
182
|
+
"installed_version": normalized_version,
|
|
183
|
+
"source_root": str(source_root),
|
|
184
|
+
"active_root": str(prompts_root),
|
|
185
|
+
"versions_root": str(self._prompt_versions_root(quest_root)),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def list_prompt_versions(self, quest_root: Path) -> list[dict[str, object]]:
|
|
189
|
+
payload = read_json(self._prompt_versions_index_path(quest_root), {})
|
|
190
|
+
versions = payload.get("versions") if isinstance(payload.get("versions"), list) else []
|
|
191
|
+
return [dict(item) for item in versions if isinstance(item, dict)]
|
|
192
|
+
|
|
193
|
+
def resolve_prompt_version_root(self, quest_root: Path, selection: str) -> Path | None:
|
|
194
|
+
normalized = str(selection or "").strip()
|
|
195
|
+
if not normalized:
|
|
196
|
+
return None
|
|
197
|
+
exact_root = self._prompt_versions_root(quest_root) / normalized
|
|
198
|
+
if exact_root.exists():
|
|
199
|
+
return exact_root
|
|
200
|
+
candidates = [
|
|
201
|
+
dict(item)
|
|
202
|
+
for item in self.list_prompt_versions(quest_root)
|
|
203
|
+
if str(item.get("installed_version") or "").strip() == normalized
|
|
204
|
+
]
|
|
205
|
+
if not candidates:
|
|
206
|
+
return None
|
|
207
|
+
candidates.sort(key=lambda item: str(item.get("created_at") or ""))
|
|
208
|
+
selected_path = Path(str(candidates[-1].get("path") or "")).expanduser()
|
|
209
|
+
return selected_path if selected_path.exists() else None
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _normalized_installed_version(installed_version: str | None) -> str:
|
|
213
|
+
normalized = str(installed_version or "").strip()
|
|
214
|
+
if normalized:
|
|
215
|
+
return normalized
|
|
216
|
+
from .. import __version__
|
|
217
|
+
|
|
218
|
+
return str(__version__ or "").strip() or "unknown"
|
|
219
|
+
|
|
220
|
+
def _backup_prompt_tree(
|
|
221
|
+
self,
|
|
222
|
+
quest_root: Path,
|
|
223
|
+
*,
|
|
224
|
+
prompts_root: Path,
|
|
225
|
+
installed_version: str,
|
|
226
|
+
prompt_fingerprint: str,
|
|
227
|
+
) -> str:
|
|
228
|
+
versions_root = ensure_dir(self._prompt_versions_root(quest_root))
|
|
229
|
+
backup_id = ""
|
|
230
|
+
target_root: Path | None = None
|
|
231
|
+
for _attempt in range(8):
|
|
232
|
+
backup_id = self._unique_prompt_backup_id(
|
|
233
|
+
versions_root,
|
|
234
|
+
installed_version=installed_version,
|
|
235
|
+
prompt_fingerprint=prompt_fingerprint,
|
|
236
|
+
)
|
|
237
|
+
target_root = versions_root / backup_id
|
|
238
|
+
try:
|
|
239
|
+
shutil.copytree(prompts_root, target_root)
|
|
240
|
+
break
|
|
241
|
+
except FileExistsError:
|
|
242
|
+
# Another sync run may have created the same backup directory between
|
|
243
|
+
# name selection and copy. Regenerate a fresh id and retry.
|
|
244
|
+
continue
|
|
245
|
+
else:
|
|
246
|
+
raise FileExistsError(
|
|
247
|
+
f"Failed to allocate a unique prompt backup directory under `{versions_root}` after multiple attempts."
|
|
248
|
+
)
|
|
249
|
+
assert target_root is not None
|
|
250
|
+
entry = {
|
|
251
|
+
"backup_id": backup_id,
|
|
252
|
+
"installed_version": str(installed_version or "").strip() or "unknown",
|
|
253
|
+
"prompt_fingerprint": prompt_fingerprint,
|
|
254
|
+
"created_at": utc_now(),
|
|
255
|
+
"path": str(target_root),
|
|
256
|
+
}
|
|
257
|
+
versions = self.list_prompt_versions(quest_root)
|
|
258
|
+
versions = [item for item in versions if str(item.get("backup_id") or "").strip() != backup_id]
|
|
259
|
+
versions.append(entry)
|
|
260
|
+
versions.sort(key=lambda item: str(item.get("created_at") or ""))
|
|
261
|
+
write_json(self._prompt_versions_index_path(quest_root), {"versions": versions})
|
|
262
|
+
return backup_id
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _prompt_versions_root(quest_root: Path) -> Path:
|
|
266
|
+
return ensure_dir(quest_root / ".codex" / _PROMPT_VERSIONS_DIRNAME)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _prompt_versions_index_path(quest_root: Path) -> Path:
|
|
270
|
+
return SkillInstaller._prompt_versions_root(quest_root) / _PROMPT_VERSIONS_INDEX_FILENAME
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _prompt_sync_state_path(prompts_root: Path) -> Path:
|
|
274
|
+
return prompts_root / _PROMPT_SYNC_STATE_FILENAME
|
|
275
|
+
|
|
276
|
+
def _read_prompt_sync_state(self, prompts_root: Path) -> dict[str, object]:
|
|
277
|
+
payload = read_json(self._prompt_sync_state_path(prompts_root), {})
|
|
278
|
+
return payload if isinstance(payload, dict) else {}
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _sanitize_prompt_label(value: str) -> str:
|
|
282
|
+
normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value or "").strip()).strip("-")
|
|
283
|
+
return normalized or "unknown"
|
|
284
|
+
|
|
285
|
+
def _unique_prompt_backup_id(
|
|
286
|
+
self,
|
|
287
|
+
versions_root: Path,
|
|
288
|
+
*,
|
|
289
|
+
installed_version: str,
|
|
290
|
+
prompt_fingerprint: str,
|
|
291
|
+
) -> str:
|
|
292
|
+
version_label = self._sanitize_prompt_label(installed_version)
|
|
293
|
+
timestamp_label = self._sanitize_prompt_label(
|
|
294
|
+
utc_now().replace(":", "").replace("+00:00", "Z")
|
|
295
|
+
)
|
|
296
|
+
fingerprint_label = (str(prompt_fingerprint or "").strip() or "unknown")[:12]
|
|
297
|
+
base = f"{version_label}__prompts-{fingerprint_label}__{timestamp_label}"
|
|
298
|
+
candidate = base
|
|
299
|
+
counter = 2
|
|
300
|
+
while (versions_root / candidate).exists():
|
|
301
|
+
candidate = f"{base}__{counter}"
|
|
302
|
+
counter += 1
|
|
303
|
+
return candidate
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _prompt_tree_fingerprint(root: Path, *, exclude_state_file: bool) -> str:
|
|
307
|
+
if not root.exists():
|
|
308
|
+
return ""
|
|
309
|
+
files = [
|
|
310
|
+
path
|
|
311
|
+
for path in sorted(root.rglob("*"))
|
|
312
|
+
if path.is_file()
|
|
313
|
+
and not (exclude_state_file and path.name == _PROMPT_SYNC_STATE_FILENAME)
|
|
314
|
+
]
|
|
315
|
+
if not files:
|
|
316
|
+
return ""
|
|
317
|
+
hasher = hashlib.sha256()
|
|
318
|
+
for path in files:
|
|
319
|
+
relative = path.relative_to(root).as_posix()
|
|
320
|
+
hasher.update(relative.encode("utf-8"))
|
|
321
|
+
hasher.update(b"\0")
|
|
322
|
+
hasher.update(hashlib.sha256(path.read_bytes()).hexdigest().encode("ascii"))
|
|
323
|
+
hasher.update(b"\0")
|
|
324
|
+
return hasher.hexdigest()
|
|
325
|
+
|
|
135
326
|
def _sync_claude_projection(self, bundle, target_root: Path) -> Path:
|
|
136
327
|
target = target_root / f"deepscientist-{bundle.skill_id}.md"
|
|
137
328
|
if bundle.claude_md and bundle.claude_md.exists():
|