@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
|
@@ -5,10 +5,10 @@ from collections import deque
|
|
|
5
5
|
from contextlib import contextmanager
|
|
6
6
|
from datetime import UTC, datetime, timedelta
|
|
7
7
|
import hashlib
|
|
8
|
-
import subprocess
|
|
9
8
|
import json
|
|
10
9
|
import mimetypes
|
|
11
10
|
import re
|
|
11
|
+
import shutil
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
14
|
from pathlib import Path, PurePosixPath
|
|
@@ -24,10 +24,10 @@ from ..artifact.metrics import build_baseline_compare_payload, build_metrics_tim
|
|
|
24
24
|
from ..config import ConfigManager
|
|
25
25
|
from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
|
|
26
26
|
from ..file_lock import advisory_file_lock
|
|
27
|
-
from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas
|
|
27
|
+
from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas, list_commit_canvas
|
|
28
28
|
from ..home import repo_root
|
|
29
29
|
from ..registries import BaselineRegistry
|
|
30
|
-
from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
|
|
30
|
+
from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, run_command_bytes, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
|
|
31
31
|
from ..skills import SkillInstaller
|
|
32
32
|
from ..web_search import extract_web_search_payload
|
|
33
33
|
from .layout import (
|
|
@@ -172,6 +172,127 @@ def _iter_jsonl_records_safely(
|
|
|
172
172
|
yield payload
|
|
173
173
|
|
|
174
174
|
|
|
175
|
+
def _parse_jsonl_record_line_safely(
|
|
176
|
+
raw_line: bytes,
|
|
177
|
+
*,
|
|
178
|
+
oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
|
|
179
|
+
) -> dict[str, Any] | None:
|
|
180
|
+
raw = bytes(raw_line).strip()
|
|
181
|
+
if not raw:
|
|
182
|
+
return None
|
|
183
|
+
line_bytes = len(raw)
|
|
184
|
+
if line_bytes > oversized_line_bytes:
|
|
185
|
+
return _oversized_event_placeholder(
|
|
186
|
+
prefix=raw[:_OVERSIZED_EVENT_PREFIX_BYTES],
|
|
187
|
+
line_bytes=line_bytes,
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
payload = json.loads(raw)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
return None
|
|
193
|
+
return payload if isinstance(payload, dict) else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _tail_jsonl_records_safely(
|
|
197
|
+
path: Path,
|
|
198
|
+
*,
|
|
199
|
+
limit: int,
|
|
200
|
+
oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
|
|
201
|
+
) -> tuple[list[tuple[int, dict[str, Any]]], int]:
|
|
202
|
+
normalized_limit = max(int(limit or 0), 0)
|
|
203
|
+
if normalized_limit <= 0 or not path.exists():
|
|
204
|
+
return [], 0
|
|
205
|
+
total = _count_jsonl_lines_fast(path)
|
|
206
|
+
if total <= 0:
|
|
207
|
+
return [], 0
|
|
208
|
+
|
|
209
|
+
raw_tail = _read_jsonl_tail_lines_fast(path, normalized_limit)
|
|
210
|
+
if not raw_tail:
|
|
211
|
+
return [], total
|
|
212
|
+
|
|
213
|
+
cursor_start = max(total - len(raw_tail) + 1, 1)
|
|
214
|
+
parsed: list[tuple[int, dict[str, Any]]] = []
|
|
215
|
+
for cursor, raw_line in enumerate(raw_tail, start=cursor_start):
|
|
216
|
+
payload = _parse_jsonl_record_line_safely(
|
|
217
|
+
raw_line,
|
|
218
|
+
oversized_line_bytes=oversized_line_bytes,
|
|
219
|
+
)
|
|
220
|
+
if isinstance(payload, dict):
|
|
221
|
+
parsed.append((cursor, payload))
|
|
222
|
+
return parsed, total
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _count_jsonl_lines_fast(path: Path, *, chunk_size: int = 1024 * 1024) -> int:
|
|
226
|
+
if not path.exists():
|
|
227
|
+
return 0
|
|
228
|
+
total = 0
|
|
229
|
+
last_byte = b""
|
|
230
|
+
with path.open("rb") as handle:
|
|
231
|
+
while True:
|
|
232
|
+
chunk = handle.read(chunk_size)
|
|
233
|
+
if not chunk:
|
|
234
|
+
break
|
|
235
|
+
total += chunk.count(b"\n")
|
|
236
|
+
last_byte = chunk[-1:]
|
|
237
|
+
if total == 0 and last_byte:
|
|
238
|
+
return 1
|
|
239
|
+
if last_byte not in {b"", b"\n"}:
|
|
240
|
+
total += 1
|
|
241
|
+
return total
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_jsonl_tail_lines_fast(path: Path, limit: int, *, chunk_size: int = 1024 * 1024) -> list[bytes]:
|
|
245
|
+
normalized_limit = max(int(limit or 0), 0)
|
|
246
|
+
if normalized_limit <= 0 or not path.exists():
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
size = path.stat().st_size
|
|
250
|
+
if size <= 0:
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
lines: deque[bytes] = deque()
|
|
254
|
+
remainder = b""
|
|
255
|
+
with path.open("rb") as handle:
|
|
256
|
+
position = size
|
|
257
|
+
while position > 0 and len(lines) < normalized_limit:
|
|
258
|
+
read_size = min(chunk_size, position)
|
|
259
|
+
position -= read_size
|
|
260
|
+
handle.seek(position)
|
|
261
|
+
chunk = handle.read(read_size)
|
|
262
|
+
payload = chunk + remainder
|
|
263
|
+
parts = payload.split(b"\n")
|
|
264
|
+
remainder = parts[0]
|
|
265
|
+
for raw_line in reversed(parts[1:]):
|
|
266
|
+
stripped = raw_line.rstrip(b"\r")
|
|
267
|
+
if not stripped.strip():
|
|
268
|
+
continue
|
|
269
|
+
lines.appendleft(stripped)
|
|
270
|
+
if len(lines) >= normalized_limit:
|
|
271
|
+
break
|
|
272
|
+
if len(lines) < normalized_limit and remainder.strip():
|
|
273
|
+
lines.appendleft(remainder.rstrip(b"\r"))
|
|
274
|
+
return list(lines)[-normalized_limit:]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _iter_jsonl_records_from_offset_safely(
|
|
278
|
+
path: Path,
|
|
279
|
+
*,
|
|
280
|
+
start_offset: int,
|
|
281
|
+
oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
|
|
282
|
+
):
|
|
283
|
+
if not path.exists():
|
|
284
|
+
return
|
|
285
|
+
with path.open("rb") as handle:
|
|
286
|
+
handle.seek(max(int(start_offset or 0), 0))
|
|
287
|
+
for raw_line in handle:
|
|
288
|
+
payload = _parse_jsonl_record_line_safely(
|
|
289
|
+
raw_line,
|
|
290
|
+
oversized_line_bytes=oversized_line_bytes,
|
|
291
|
+
)
|
|
292
|
+
if isinstance(payload, dict):
|
|
293
|
+
yield payload
|
|
294
|
+
|
|
295
|
+
|
|
175
296
|
class QuestService:
|
|
176
297
|
def __init__(self, home: Path, skill_installer: SkillInstaller | None = None) -> None:
|
|
177
298
|
self.home = home
|
|
@@ -182,6 +303,7 @@ class QuestService:
|
|
|
182
303
|
self._file_cache: dict[str, dict[str, Any]] = {}
|
|
183
304
|
self._jsonl_cache_lock = threading.Lock()
|
|
184
305
|
self._jsonl_cache: dict[str, dict[str, Any]] = {}
|
|
306
|
+
self._jsonl_tail_cache: dict[str, dict[str, Any]] = {}
|
|
185
307
|
self._snapshot_cache_lock = threading.Lock()
|
|
186
308
|
self._snapshot_cache: dict[str, dict[str, Any]] = {}
|
|
187
309
|
self._codex_history_cache_lock = threading.Lock()
|
|
@@ -200,6 +322,12 @@ class QuestService:
|
|
|
200
322
|
def _quest_root(self, quest_id: str) -> Path:
|
|
201
323
|
return self.quests_root / quest_id
|
|
202
324
|
|
|
325
|
+
def _require_initialized_quest_root(self, quest_id: str) -> Path:
|
|
326
|
+
quest_root = self._quest_root(quest_id)
|
|
327
|
+
if not quest_root.exists() or not self._quest_yaml_path(quest_root).exists():
|
|
328
|
+
raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
|
|
329
|
+
return quest_root
|
|
330
|
+
|
|
203
331
|
def _normalized_binding_sources(self, sources: list[Any] | None) -> list[str]:
|
|
204
332
|
local_present = False
|
|
205
333
|
external_source: str | None = None
|
|
@@ -288,6 +416,13 @@ class QuestService:
|
|
|
288
416
|
return quest_root / ".ds" / "lab_canvas_state.json"
|
|
289
417
|
|
|
290
418
|
def _default_research_state(self, quest_root: Path) -> dict[str, Any]:
|
|
419
|
+
quest_yaml = self.read_quest_yaml(quest_root)
|
|
420
|
+
startup_contract = (
|
|
421
|
+
dict(quest_yaml.get("startup_contract") or {})
|
|
422
|
+
if isinstance(quest_yaml.get("startup_contract"), dict)
|
|
423
|
+
else {}
|
|
424
|
+
)
|
|
425
|
+
workspace_mode = str(startup_contract.get("workspace_mode") or "").strip().lower() or "quest"
|
|
291
426
|
return {
|
|
292
427
|
"version": 1,
|
|
293
428
|
"active_idea_id": None,
|
|
@@ -304,7 +439,7 @@ class QuestService:
|
|
|
304
439
|
"paper_parent_worktree_root": None,
|
|
305
440
|
"paper_parent_run_id": None,
|
|
306
441
|
"next_pending_slice_id": None,
|
|
307
|
-
"workspace_mode":
|
|
442
|
+
"workspace_mode": workspace_mode,
|
|
308
443
|
"last_flow_type": None,
|
|
309
444
|
"updated_at": utc_now(),
|
|
310
445
|
}
|
|
@@ -354,7 +489,7 @@ class QuestService:
|
|
|
354
489
|
continue
|
|
355
490
|
current[key] = str(value) if isinstance(value, Path) else value
|
|
356
491
|
payload = self.write_research_state(quest_root, current)
|
|
357
|
-
self.schedule_projection_refresh(quest_root, kinds=("details", "canvas"))
|
|
492
|
+
self.schedule_projection_refresh(quest_root, kinds=("details", "canvas", "git_canvas"))
|
|
358
493
|
return payload
|
|
359
494
|
|
|
360
495
|
def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
|
|
@@ -971,6 +1106,8 @@ class QuestService:
|
|
|
971
1106
|
return self._details_projection_state(quest_root)
|
|
972
1107
|
if kind == "canvas":
|
|
973
1108
|
return self._canvas_projection_state(quest_root)
|
|
1109
|
+
if kind == "git_canvas":
|
|
1110
|
+
return self._canvas_projection_state(quest_root)
|
|
974
1111
|
raise ValueError(f"Unsupported projection kind `{kind}`.")
|
|
975
1112
|
|
|
976
1113
|
def _projection_source_signature(self, quest_root: Path, kind: str) -> str:
|
|
@@ -1434,6 +1571,17 @@ class QuestService:
|
|
|
1434
1571
|
update_progress(2, "Computing branch canvas")
|
|
1435
1572
|
return list_branch_canvas(quest_root, quest_id=quest_root.name)
|
|
1436
1573
|
|
|
1574
|
+
def _build_git_canvas_projection_payload(
|
|
1575
|
+
self,
|
|
1576
|
+
quest_root: Path,
|
|
1577
|
+
*,
|
|
1578
|
+
source_signature: str,
|
|
1579
|
+
update_progress: Any,
|
|
1580
|
+
) -> dict[str, Any]:
|
|
1581
|
+
update_progress(1, "Scanning commit history")
|
|
1582
|
+
update_progress(2, "Computing commit canvas")
|
|
1583
|
+
return list_commit_canvas(quest_root, quest_id=quest_root.name)
|
|
1584
|
+
|
|
1437
1585
|
def _build_projection_payload(
|
|
1438
1586
|
self,
|
|
1439
1587
|
quest_root: Path,
|
|
@@ -1454,6 +1602,12 @@ class QuestService:
|
|
|
1454
1602
|
source_signature=source_signature,
|
|
1455
1603
|
update_progress=update_progress,
|
|
1456
1604
|
)
|
|
1605
|
+
if kind == "git_canvas":
|
|
1606
|
+
return self._build_git_canvas_projection_payload(
|
|
1607
|
+
quest_root,
|
|
1608
|
+
source_signature=source_signature,
|
|
1609
|
+
update_progress=update_progress,
|
|
1610
|
+
)
|
|
1457
1611
|
raise ValueError(f"Unsupported projection kind `{kind}`.")
|
|
1458
1612
|
|
|
1459
1613
|
def _placeholder_workflow_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
|
|
@@ -1486,6 +1640,17 @@ class QuestService:
|
|
|
1486
1640
|
},
|
|
1487
1641
|
}
|
|
1488
1642
|
|
|
1643
|
+
def _placeholder_git_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
|
|
1644
|
+
research_state = self.read_research_state(quest_root)
|
|
1645
|
+
return {
|
|
1646
|
+
"quest_id": quest_id,
|
|
1647
|
+
"workspace_mode": str(research_state.get("workspace_mode") or "copilot").strip() or "copilot",
|
|
1648
|
+
"head": head_commit(quest_root),
|
|
1649
|
+
"current_ref": current_branch(quest_root),
|
|
1650
|
+
"nodes": [],
|
|
1651
|
+
"edges": [],
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1489
1654
|
def _projected_payload(self, quest_id: str, kind: str) -> dict[str, Any]:
|
|
1490
1655
|
quest_root = self._quest_root(quest_id)
|
|
1491
1656
|
source_signature = self._projection_source_signature(quest_root, kind)
|
|
@@ -1510,11 +1675,12 @@ class QuestService:
|
|
|
1510
1675
|
else None
|
|
1511
1676
|
)
|
|
1512
1677
|
if payload is None:
|
|
1513
|
-
|
|
1514
|
-
self._placeholder_workflow_payload(quest_id, quest_root)
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1678
|
+
if kind == "details":
|
|
1679
|
+
payload = self._placeholder_workflow_payload(quest_id, quest_root)
|
|
1680
|
+
elif kind == "git_canvas":
|
|
1681
|
+
payload = self._placeholder_git_canvas_payload(quest_id, quest_root)
|
|
1682
|
+
else:
|
|
1683
|
+
payload = self._placeholder_canvas_payload(quest_id, quest_root)
|
|
1518
1684
|
payload["projection_status"] = status
|
|
1519
1685
|
return payload
|
|
1520
1686
|
|
|
@@ -1535,8 +1701,8 @@ class QuestService:
|
|
|
1535
1701
|
) -> None:
|
|
1536
1702
|
resolved_kinds = [
|
|
1537
1703
|
str(kind).strip()
|
|
1538
|
-
for kind in (kinds or ("details", "canvas"))
|
|
1539
|
-
if str(kind).strip() in {"details", "canvas"}
|
|
1704
|
+
for kind in (kinds or ("details", "canvas", "git_canvas"))
|
|
1705
|
+
if str(kind).strip() in {"details", "canvas", "git_canvas"}
|
|
1540
1706
|
]
|
|
1541
1707
|
if not resolved_kinds:
|
|
1542
1708
|
return
|
|
@@ -1563,6 +1729,9 @@ class QuestService:
|
|
|
1563
1729
|
def git_branch_canvas(self, quest_id: str) -> dict[str, Any]:
|
|
1564
1730
|
return self._projected_payload(quest_id, "canvas")
|
|
1565
1731
|
|
|
1732
|
+
def git_commit_canvas(self, quest_id: str) -> dict[str, Any]:
|
|
1733
|
+
return self._projected_payload(quest_id, "git_canvas")
|
|
1734
|
+
|
|
1566
1735
|
def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1567
1736
|
attachments: list[dict[str, Any]] = []
|
|
1568
1737
|
seen_paths: set[str] = set()
|
|
@@ -2602,7 +2771,7 @@ class QuestService:
|
|
|
2602
2771
|
)
|
|
2603
2772
|
write_text(quest_root / "brief.md", initial_brief(goal))
|
|
2604
2773
|
write_text(quest_root / "plan.md", initial_plan())
|
|
2605
|
-
write_text(quest_root / "status.md", initial_status())
|
|
2774
|
+
write_text(quest_root / "status.md", initial_status(startup_contract))
|
|
2606
2775
|
write_text(quest_root / "SUMMARY.md", initial_summary())
|
|
2607
2776
|
write_text(quest_root / ".gitignore", gitignore())
|
|
2608
2777
|
self._write_active_user_requirements(
|
|
@@ -2619,6 +2788,86 @@ class QuestService:
|
|
|
2619
2788
|
self._initialize_runtime_files(quest_root)
|
|
2620
2789
|
return self.snapshot(quest_id)
|
|
2621
2790
|
|
|
2791
|
+
def repair_orphaned_quest_scaffold(
|
|
2792
|
+
self,
|
|
2793
|
+
quest_id: str,
|
|
2794
|
+
*,
|
|
2795
|
+
title: str | None = None,
|
|
2796
|
+
goal: str | None = None,
|
|
2797
|
+
runner: str = "codex",
|
|
2798
|
+
) -> dict[str, Any]:
|
|
2799
|
+
quest_root = self._quest_root(quest_id)
|
|
2800
|
+
if not quest_root.exists():
|
|
2801
|
+
raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
|
|
2802
|
+
quest_yaml_path = self._quest_yaml_path(quest_root)
|
|
2803
|
+
if quest_yaml_path.exists():
|
|
2804
|
+
raise FileExistsError(f"Quest `{quest_id}` already has a scaffold.")
|
|
2805
|
+
|
|
2806
|
+
restored_goal = str(goal or f"Recovered quest {quest_id}").strip() or f"Recovered quest {quest_id}"
|
|
2807
|
+
restored_title = str(title or quest_id).strip() or quest_id
|
|
2808
|
+
|
|
2809
|
+
for relative in QUEST_DIRECTORIES:
|
|
2810
|
+
ensure_dir(quest_root / relative)
|
|
2811
|
+
|
|
2812
|
+
write_yaml(
|
|
2813
|
+
quest_yaml_path,
|
|
2814
|
+
initial_quest_yaml(
|
|
2815
|
+
quest_id,
|
|
2816
|
+
restored_goal,
|
|
2817
|
+
quest_root,
|
|
2818
|
+
runner,
|
|
2819
|
+
title=restored_title,
|
|
2820
|
+
),
|
|
2821
|
+
)
|
|
2822
|
+
write_text(
|
|
2823
|
+
quest_root / "brief.md",
|
|
2824
|
+
"\n".join(
|
|
2825
|
+
[
|
|
2826
|
+
"# Quest Brief",
|
|
2827
|
+
"",
|
|
2828
|
+
"## Recovery Note",
|
|
2829
|
+
"",
|
|
2830
|
+
"This quest scaffold was recreated because the core quest files were missing.",
|
|
2831
|
+
"Existing runtime traces under `.ds/` were preserved.",
|
|
2832
|
+
"",
|
|
2833
|
+
"## Goal",
|
|
2834
|
+
"",
|
|
2835
|
+
restored_goal,
|
|
2836
|
+
"",
|
|
2837
|
+
]
|
|
2838
|
+
),
|
|
2839
|
+
)
|
|
2840
|
+
write_text(
|
|
2841
|
+
quest_root / "plan.md",
|
|
2842
|
+
"\n".join(
|
|
2843
|
+
[
|
|
2844
|
+
"# Plan",
|
|
2845
|
+
"",
|
|
2846
|
+
"- [ ] Inspect preserved runtime traces under `.ds/`",
|
|
2847
|
+
"- [ ] Re-establish the baseline context",
|
|
2848
|
+
"- [ ] Recreate any missing durable files or artifacts",
|
|
2849
|
+
"",
|
|
2850
|
+
]
|
|
2851
|
+
),
|
|
2852
|
+
)
|
|
2853
|
+
write_text(
|
|
2854
|
+
quest_root / "status.md",
|
|
2855
|
+
"# Status\n\nRecovered scaffold. Review preserved runtime state before continuing.\n",
|
|
2856
|
+
)
|
|
2857
|
+
write_text(
|
|
2858
|
+
quest_root / "SUMMARY.md",
|
|
2859
|
+
"# Summary\n\nRecovered quest scaffold. Original top-level quest files were missing.\n",
|
|
2860
|
+
)
|
|
2861
|
+
write_text(quest_root / ".gitignore", gitignore())
|
|
2862
|
+
self._write_active_user_requirements(
|
|
2863
|
+
quest_root,
|
|
2864
|
+
latest_requirement=None,
|
|
2865
|
+
)
|
|
2866
|
+
if not (quest_root / ".git").exists():
|
|
2867
|
+
init_repo(quest_root)
|
|
2868
|
+
self._initialize_runtime_files(quest_root)
|
|
2869
|
+
return self.snapshot(quest_id)
|
|
2870
|
+
|
|
2622
2871
|
def list_quests(self) -> list[dict]:
|
|
2623
2872
|
items: list[dict] = []
|
|
2624
2873
|
if not self.quests_root.exists():
|
|
@@ -2716,7 +2965,7 @@ class QuestService:
|
|
|
2716
2965
|
)
|
|
2717
2966
|
|
|
2718
2967
|
def summary_compact(self, quest_id: str) -> dict[str, Any]:
|
|
2719
|
-
quest_root = self.
|
|
2968
|
+
quest_root = self._require_initialized_quest_root(quest_id)
|
|
2720
2969
|
cache_key = f"compact:{self._cache_key_for_path(quest_root)}"
|
|
2721
2970
|
state = self._compact_summary_state(quest_root)
|
|
2722
2971
|
with self._snapshot_cache_lock:
|
|
@@ -2790,6 +3039,7 @@ class QuestService:
|
|
|
2790
3039
|
"research_head_worktree_root": research_state.get("research_head_worktree_root"),
|
|
2791
3040
|
"current_workspace_branch": research_state.get("current_workspace_branch"),
|
|
2792
3041
|
"current_workspace_root": research_state.get("current_workspace_root"),
|
|
3042
|
+
"workspace_mode": research_state.get("workspace_mode") or "quest",
|
|
2793
3043
|
"active_idea_id": research_state.get("active_idea_id"),
|
|
2794
3044
|
"active_baseline_id": active_baseline_id,
|
|
2795
3045
|
"active_baseline_variant_id": active_baseline_variant_id,
|
|
@@ -2876,8 +3126,8 @@ class QuestService:
|
|
|
2876
3126
|
}
|
|
2877
3127
|
return items
|
|
2878
3128
|
|
|
2879
|
-
@staticmethod
|
|
2880
3129
|
def _read_jsonl_cursor_slice(
|
|
3130
|
+
self,
|
|
2881
3131
|
path: Path,
|
|
2882
3132
|
*,
|
|
2883
3133
|
after: int = 0,
|
|
@@ -2886,7 +3136,10 @@ class QuestService:
|
|
|
2886
3136
|
tail: bool = False,
|
|
2887
3137
|
) -> tuple[list[tuple[int, dict[str, Any]]], int, bool]:
|
|
2888
3138
|
normalized_limit = max(int(limit or 0), 0)
|
|
3139
|
+
cache_key = self._cache_key_for_path(path)
|
|
2889
3140
|
if not path.exists():
|
|
3141
|
+
with self._jsonl_cache_lock:
|
|
3142
|
+
self._jsonl_tail_cache.pop(cache_key, None)
|
|
2890
3143
|
return [], 0, False
|
|
2891
3144
|
if normalized_limit <= 0:
|
|
2892
3145
|
total = sum(1 for _ in _iter_jsonl_records_safely(path))
|
|
@@ -2905,11 +3158,71 @@ class QuestService:
|
|
|
2905
3158
|
return list(window), total, has_more
|
|
2906
3159
|
|
|
2907
3160
|
if tail:
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
3161
|
+
state = self._path_state(path)
|
|
3162
|
+
cached_tail: dict[str, Any] | None = None
|
|
3163
|
+
with self._jsonl_cache_lock:
|
|
3164
|
+
candidate = self._jsonl_tail_cache.get(cache_key)
|
|
3165
|
+
if isinstance(candidate, dict):
|
|
3166
|
+
cached_tail = dict(candidate)
|
|
3167
|
+
|
|
3168
|
+
if cached_tail and cached_tail.get("state") == state:
|
|
3169
|
+
cached_limit = int(cached_tail.get("limit") or 0)
|
|
3170
|
+
cached_records = list(cached_tail.get("records") or [])
|
|
3171
|
+
cached_total = int(cached_tail.get("total") or 0)
|
|
3172
|
+
if cached_limit >= normalized_limit and cached_records:
|
|
3173
|
+
window = cached_records[-normalized_limit:]
|
|
3174
|
+
has_more = cached_total > len(window)
|
|
3175
|
+
return window, cached_total, has_more
|
|
3176
|
+
|
|
3177
|
+
if (
|
|
3178
|
+
cached_tail
|
|
3179
|
+
and state is not None
|
|
3180
|
+
and cached_tail.get("state")
|
|
3181
|
+
and tuple(cached_tail.get("state"))[0] == state[0]
|
|
3182
|
+
and state[2] >= tuple(cached_tail.get("state"))[2]
|
|
3183
|
+
):
|
|
3184
|
+
cached_state = tuple(cached_tail.get("state"))
|
|
3185
|
+
cached_limit = int(cached_tail.get("limit") or 0)
|
|
3186
|
+
cached_total = int(cached_tail.get("total") or 0)
|
|
3187
|
+
max_limit = max(normalized_limit, cached_limit)
|
|
3188
|
+
window = deque(
|
|
3189
|
+
list(cached_tail.get("records") or []),
|
|
3190
|
+
maxlen=max_limit,
|
|
3191
|
+
)
|
|
3192
|
+
appended_records = list(
|
|
3193
|
+
_iter_jsonl_records_from_offset_safely(
|
|
3194
|
+
path,
|
|
3195
|
+
start_offset=int(cached_state[2]),
|
|
3196
|
+
)
|
|
3197
|
+
)
|
|
3198
|
+
if appended_records:
|
|
3199
|
+
next_cursor = cached_total + 1
|
|
3200
|
+
for payload in appended_records:
|
|
3201
|
+
window.append((next_cursor, payload))
|
|
3202
|
+
next_cursor += 1
|
|
3203
|
+
total = cached_total + len(appended_records)
|
|
3204
|
+
else:
|
|
3205
|
+
total = cached_total
|
|
3206
|
+
stored_records = list(window)
|
|
3207
|
+
with self._jsonl_cache_lock:
|
|
3208
|
+
self._jsonl_tail_cache[cache_key] = {
|
|
3209
|
+
"state": state,
|
|
3210
|
+
"limit": max_limit,
|
|
3211
|
+
"total": total,
|
|
3212
|
+
"records": stored_records,
|
|
3213
|
+
}
|
|
3214
|
+
selected = stored_records[-normalized_limit:]
|
|
3215
|
+
has_more = total > len(selected)
|
|
3216
|
+
return selected, total, has_more
|
|
3217
|
+
|
|
3218
|
+
window, total = _tail_jsonl_records_safely(path, limit=normalized_limit)
|
|
3219
|
+
with self._jsonl_cache_lock:
|
|
3220
|
+
self._jsonl_tail_cache[cache_key] = {
|
|
3221
|
+
"state": state,
|
|
3222
|
+
"limit": normalized_limit,
|
|
3223
|
+
"total": total,
|
|
3224
|
+
"records": list(window),
|
|
3225
|
+
}
|
|
2913
3226
|
has_more = total > len(window)
|
|
2914
3227
|
return list(window), total, has_more
|
|
2915
3228
|
|
|
@@ -2945,6 +3258,14 @@ class QuestService:
|
|
|
2945
3258
|
except FileNotFoundError:
|
|
2946
3259
|
return str(path.absolute())
|
|
2947
3260
|
|
|
3261
|
+
def jsonl_tail_cache_entry(self, path: Path) -> dict[str, Any] | None:
|
|
3262
|
+
cache_key = self._cache_key_for_path(path)
|
|
3263
|
+
with self._jsonl_cache_lock:
|
|
3264
|
+
candidate = self._jsonl_tail_cache.get(cache_key)
|
|
3265
|
+
if isinstance(candidate, dict):
|
|
3266
|
+
return dict(candidate)
|
|
3267
|
+
return None
|
|
3268
|
+
|
|
2948
3269
|
def _read_cached_path(
|
|
2949
3270
|
self,
|
|
2950
3271
|
path: Path,
|
|
@@ -3019,7 +3340,7 @@ class QuestService:
|
|
|
3019
3340
|
return self._snapshot(quest_id)
|
|
3020
3341
|
|
|
3021
3342
|
def _snapshot(self, quest_id: str) -> dict:
|
|
3022
|
-
quest_root = self.
|
|
3343
|
+
quest_root = self._require_initialized_quest_root(quest_id)
|
|
3023
3344
|
cache_key = f"snapshot:{self._cache_key_for_path(quest_root)}"
|
|
3024
3345
|
state = self._snapshot_state(quest_root)
|
|
3025
3346
|
with self._snapshot_cache_lock:
|
|
@@ -3589,6 +3910,7 @@ class QuestService:
|
|
|
3589
3910
|
title: str | None = None,
|
|
3590
3911
|
active_anchor: str | None = None,
|
|
3591
3912
|
default_runner: str | None = None,
|
|
3913
|
+
workspace_mode: str | None = None,
|
|
3592
3914
|
) -> dict:
|
|
3593
3915
|
quest_root = self._quest_root(quest_id)
|
|
3594
3916
|
quest_yaml_path = self._quest_yaml_path(quest_root)
|
|
@@ -3597,6 +3919,8 @@ class QuestService:
|
|
|
3597
3919
|
|
|
3598
3920
|
quest_data = self.read_quest_yaml(quest_root)
|
|
3599
3921
|
changed = False
|
|
3922
|
+
research_state_updates: dict[str, Any] = {}
|
|
3923
|
+
runtime_state_updates: dict[str, Any] = {}
|
|
3600
3924
|
|
|
3601
3925
|
if title is not None:
|
|
3602
3926
|
normalized_title = str(title).strip()
|
|
@@ -3610,10 +3934,11 @@ class QuestService:
|
|
|
3610
3934
|
normalized_anchor = str(active_anchor).strip()
|
|
3611
3935
|
if not normalized_anchor:
|
|
3612
3936
|
raise ValueError("`active_anchor` cannot be empty.")
|
|
3613
|
-
from ..prompts.builder import
|
|
3937
|
+
from ..prompts.builder import current_standard_skills
|
|
3614
3938
|
|
|
3615
|
-
|
|
3616
|
-
|
|
3939
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
3940
|
+
if normalized_anchor not in available_stage_skills:
|
|
3941
|
+
allowed = ", ".join(available_stage_skills)
|
|
3617
3942
|
raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
|
|
3618
3943
|
if quest_data.get("active_anchor") != normalized_anchor:
|
|
3619
3944
|
quest_data["active_anchor"] = normalized_anchor
|
|
@@ -3633,9 +3958,35 @@ class QuestService:
|
|
|
3633
3958
|
quest_data["default_runner"] = normalized_runner
|
|
3634
3959
|
changed = True
|
|
3635
3960
|
|
|
3961
|
+
if workspace_mode is not None:
|
|
3962
|
+
normalized_workspace_mode = str(workspace_mode).strip().lower()
|
|
3963
|
+
if normalized_workspace_mode not in {"copilot", "autonomous"}:
|
|
3964
|
+
raise ValueError("Unsupported workspace mode. Allowed values: copilot, autonomous.")
|
|
3965
|
+
startup_contract = (
|
|
3966
|
+
dict(quest_data.get("startup_contract") or {})
|
|
3967
|
+
if isinstance(quest_data.get("startup_contract"), dict)
|
|
3968
|
+
else {}
|
|
3969
|
+
)
|
|
3970
|
+
if str(startup_contract.get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
|
|
3971
|
+
startup_contract["workspace_mode"] = normalized_workspace_mode
|
|
3972
|
+
quest_data["startup_contract"] = startup_contract
|
|
3973
|
+
changed = True
|
|
3974
|
+
if str(self.read_research_state(quest_root).get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
|
|
3975
|
+
research_state_updates["workspace_mode"] = normalized_workspace_mode
|
|
3976
|
+
runtime_state_updates["continuation_policy"] = (
|
|
3977
|
+
"wait_for_user_or_resume" if normalized_workspace_mode == "copilot" else "auto"
|
|
3978
|
+
)
|
|
3979
|
+
runtime_state_updates["continuation_reason"] = (
|
|
3980
|
+
"copilot_mode" if normalized_workspace_mode == "copilot" else "autonomous_mode"
|
|
3981
|
+
)
|
|
3982
|
+
|
|
3636
3983
|
if changed:
|
|
3637
3984
|
quest_data["updated_at"] = utc_now()
|
|
3638
3985
|
write_yaml(quest_yaml_path, quest_data)
|
|
3986
|
+
if research_state_updates:
|
|
3987
|
+
self.update_research_state(quest_root, **research_state_updates)
|
|
3988
|
+
if runtime_state_updates:
|
|
3989
|
+
self.update_runtime_state(quest_root=quest_root, **runtime_state_updates)
|
|
3639
3990
|
|
|
3640
3991
|
return self.snapshot(quest_id)
|
|
3641
3992
|
|
|
@@ -3670,10 +4021,11 @@ class QuestService:
|
|
|
3670
4021
|
normalized_anchor = str(active_anchor or "").strip()
|
|
3671
4022
|
if not normalized_anchor:
|
|
3672
4023
|
raise ValueError("`active_anchor` cannot be empty.")
|
|
3673
|
-
from ..prompts.builder import
|
|
4024
|
+
from ..prompts.builder import current_standard_skills
|
|
3674
4025
|
|
|
3675
|
-
|
|
3676
|
-
|
|
4026
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
4027
|
+
if normalized_anchor not in available_stage_skills:
|
|
4028
|
+
allowed = ", ".join(available_stage_skills)
|
|
3677
4029
|
raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
|
|
3678
4030
|
if quest_data.get("active_anchor") != normalized_anchor:
|
|
3679
4031
|
quest_data["active_anchor"] = normalized_anchor
|
|
@@ -4071,7 +4423,7 @@ class QuestService:
|
|
|
4071
4423
|
return payload
|
|
4072
4424
|
|
|
4073
4425
|
def list_documents(self, quest_id: str) -> list[dict]:
|
|
4074
|
-
quest_root = self.
|
|
4426
|
+
quest_root = self._require_initialized_quest_root(quest_id)
|
|
4075
4427
|
workspace_root = self.active_workspace_root(quest_root)
|
|
4076
4428
|
documents = []
|
|
4077
4429
|
for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
|
|
@@ -4125,7 +4477,7 @@ class QuestService:
|
|
|
4125
4477
|
if revision:
|
|
4126
4478
|
return self._revision_explorer(quest_id, revision=revision, mode=mode or "ref")
|
|
4127
4479
|
|
|
4128
|
-
quest_root = self.
|
|
4480
|
+
quest_root = self._require_initialized_quest_root(quest_id)
|
|
4129
4481
|
workspace_root = self.active_workspace_root(quest_root)
|
|
4130
4482
|
git_status = self._git_status_map(workspace_root)
|
|
4131
4483
|
|
|
@@ -4154,7 +4506,7 @@ class QuestService:
|
|
|
4154
4506
|
def search_files(self, quest_id: str, term: str, limit: int = 50) -> dict[str, Any]:
|
|
4155
4507
|
query = term.strip()
|
|
4156
4508
|
normalized_query = query.casefold()
|
|
4157
|
-
workspace_root = self.active_workspace_root(self.
|
|
4509
|
+
workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
|
|
4158
4510
|
resolved_limit = max(1, min(limit, 200))
|
|
4159
4511
|
if not normalized_query:
|
|
4160
4512
|
return {
|
|
@@ -4249,7 +4601,7 @@ class QuestService:
|
|
|
4249
4601
|
}
|
|
4250
4602
|
|
|
4251
4603
|
def open_document(self, quest_id: str, document_id: str) -> dict:
|
|
4252
|
-
quest_root = self.
|
|
4604
|
+
quest_root = self._require_initialized_quest_root(quest_id)
|
|
4253
4605
|
workspace_root = self.active_workspace_root(quest_root)
|
|
4254
4606
|
if document_id.startswith("git::"):
|
|
4255
4607
|
revision, relative = self._parse_git_document_id(document_id)
|
|
@@ -4285,23 +4637,7 @@ class QuestService:
|
|
|
4285
4637
|
},
|
|
4286
4638
|
}
|
|
4287
4639
|
|
|
4288
|
-
|
|
4289
|
-
quest_root
|
|
4290
|
-
if document_id.startswith(("questpath::", "memory::"))
|
|
4291
|
-
else workspace_root
|
|
4292
|
-
)
|
|
4293
|
-
try:
|
|
4294
|
-
path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
|
|
4295
|
-
except FileNotFoundError:
|
|
4296
|
-
legacy_relative = None
|
|
4297
|
-
if document_id.startswith("path::"):
|
|
4298
|
-
legacy_relative = document_id.split("::", 1)[1].lstrip("/")
|
|
4299
|
-
if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
|
|
4300
|
-
path, writable, scope, source_kind = self._resolve_document(
|
|
4301
|
-
quest_root, f"questpath::{legacy_relative}"
|
|
4302
|
-
)
|
|
4303
|
-
else:
|
|
4304
|
-
raise
|
|
4640
|
+
path, writable, scope, source_kind = self.resolve_document(quest_id, document_id)
|
|
4305
4641
|
renderer_hint, mime_type = self._renderer_hint_for(path)
|
|
4306
4642
|
is_text = self._is_text_document(path, mime_type, renderer_hint)
|
|
4307
4643
|
content = read_text(path) if is_text else ""
|
|
@@ -4329,6 +4665,24 @@ class QuestService:
|
|
|
4329
4665
|
},
|
|
4330
4666
|
}
|
|
4331
4667
|
|
|
4668
|
+
def resolve_document(self, quest_id: str, document_id: str) -> tuple[Path, bool, str, str]:
|
|
4669
|
+
quest_root = self._require_initialized_quest_root(quest_id)
|
|
4670
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
4671
|
+
resolution_root = self._document_resolution_root(
|
|
4672
|
+
quest_root=quest_root,
|
|
4673
|
+
workspace_root=workspace_root,
|
|
4674
|
+
document_id=document_id,
|
|
4675
|
+
)
|
|
4676
|
+
try:
|
|
4677
|
+
return self._resolve_document(resolution_root, document_id)
|
|
4678
|
+
except FileNotFoundError:
|
|
4679
|
+
legacy_relative = None
|
|
4680
|
+
if document_id.startswith("path::"):
|
|
4681
|
+
legacy_relative = document_id.split("::", 1)[1].lstrip("/")
|
|
4682
|
+
if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
|
|
4683
|
+
return self._resolve_document(quest_root, f"questpath::{legacy_relative}")
|
|
4684
|
+
raise
|
|
4685
|
+
|
|
4332
4686
|
def save_document(self, quest_id: str, document_id: str, content: str, previous_revision: str | None = None) -> dict:
|
|
4333
4687
|
current = self.open_document(quest_id, document_id)
|
|
4334
4688
|
if not current.get("writable", False):
|
|
@@ -4505,6 +4859,291 @@ class QuestService:
|
|
|
4505
4859
|
"saved_at": utc_now(),
|
|
4506
4860
|
}
|
|
4507
4861
|
|
|
4862
|
+
@staticmethod
|
|
4863
|
+
def _normalize_workspace_relative_path(
|
|
4864
|
+
relative: str | None,
|
|
4865
|
+
*,
|
|
4866
|
+
field_name: str,
|
|
4867
|
+
allow_root: bool = True,
|
|
4868
|
+
) -> str | None:
|
|
4869
|
+
if relative is None:
|
|
4870
|
+
if allow_root:
|
|
4871
|
+
return None
|
|
4872
|
+
raise ValueError(f"`{field_name}` is required.")
|
|
4873
|
+
raw = str(relative).strip().replace("\\", "/")
|
|
4874
|
+
if not raw:
|
|
4875
|
+
if allow_root:
|
|
4876
|
+
return None
|
|
4877
|
+
raise ValueError(f"`{field_name}` is required.")
|
|
4878
|
+
normalized = raw.lstrip("/").rstrip("/")
|
|
4879
|
+
if normalized in {"", "."}:
|
|
4880
|
+
if allow_root:
|
|
4881
|
+
return None
|
|
4882
|
+
raise ValueError(f"`{field_name}` must point to a workspace entry.")
|
|
4883
|
+
return normalized
|
|
4884
|
+
|
|
4885
|
+
@staticmethod
|
|
4886
|
+
def _normalize_workspace_entry_name(name: str | None, *, field_name: str) -> str:
|
|
4887
|
+
raw = str(name or "").strip().replace("\\", "/")
|
|
4888
|
+
if not raw:
|
|
4889
|
+
raise ValueError(f"`{field_name}` is required.")
|
|
4890
|
+
if "/" in raw:
|
|
4891
|
+
raise ValueError(f"`{field_name}` must be a single path segment.")
|
|
4892
|
+
candidate = Path(raw).name
|
|
4893
|
+
if candidate != raw or candidate in {"", ".", ".."}:
|
|
4894
|
+
raise ValueError(f"`{field_name}` must be a valid file or folder name.")
|
|
4895
|
+
if candidate == ".git":
|
|
4896
|
+
raise ValueError("`.git` cannot be created or renamed from the explorer.")
|
|
4897
|
+
return candidate
|
|
4898
|
+
|
|
4899
|
+
@staticmethod
|
|
4900
|
+
def _normalize_workspace_path_list(paths: Any, *, field_name: str) -> list[str]:
|
|
4901
|
+
if not isinstance(paths, list) or not paths:
|
|
4902
|
+
raise ValueError(f"`{field_name}` must be a non-empty list.")
|
|
4903
|
+
normalized: list[str] = []
|
|
4904
|
+
seen: set[str] = set()
|
|
4905
|
+
for raw in paths:
|
|
4906
|
+
item = QuestService._normalize_workspace_relative_path(
|
|
4907
|
+
raw,
|
|
4908
|
+
field_name=field_name,
|
|
4909
|
+
allow_root=False,
|
|
4910
|
+
)
|
|
4911
|
+
if not item or item in seen:
|
|
4912
|
+
continue
|
|
4913
|
+
seen.add(item)
|
|
4914
|
+
normalized.append(item)
|
|
4915
|
+
if not normalized:
|
|
4916
|
+
raise ValueError(f"`{field_name}` must include at least one valid path.")
|
|
4917
|
+
return normalized
|
|
4918
|
+
|
|
4919
|
+
@staticmethod
|
|
4920
|
+
def _filter_nested_workspace_paths(paths: list[str]) -> list[str]:
|
|
4921
|
+
kept: list[str] = []
|
|
4922
|
+
for path in paths:
|
|
4923
|
+
if any(path == parent or path.startswith(f"{parent}/") for parent in kept):
|
|
4924
|
+
continue
|
|
4925
|
+
kept.append(path)
|
|
4926
|
+
return kept
|
|
4927
|
+
|
|
4928
|
+
def _workspace_entry_payload(self, workspace_root: Path, path: Path) -> dict:
|
|
4929
|
+
if path.is_dir():
|
|
4930
|
+
return self._directory_node(
|
|
4931
|
+
workspace_root,
|
|
4932
|
+
path=path,
|
|
4933
|
+
children=[],
|
|
4934
|
+
git_status={},
|
|
4935
|
+
changed_paths={},
|
|
4936
|
+
)
|
|
4937
|
+
payload = self._file_node(
|
|
4938
|
+
workspace_root,
|
|
4939
|
+
path=path,
|
|
4940
|
+
git_status={},
|
|
4941
|
+
changed_paths={},
|
|
4942
|
+
)
|
|
4943
|
+
if payload is None:
|
|
4944
|
+
raise FileNotFoundError(f"Unknown workspace entry `{path}`.")
|
|
4945
|
+
return payload
|
|
4946
|
+
|
|
4947
|
+
def create_workspace_folder(
|
|
4948
|
+
self,
|
|
4949
|
+
quest_id: str,
|
|
4950
|
+
*,
|
|
4951
|
+
name: str | None,
|
|
4952
|
+
parent_path: str | None = None,
|
|
4953
|
+
) -> dict:
|
|
4954
|
+
workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
|
|
4955
|
+
normalized_parent = self._normalize_workspace_relative_path(
|
|
4956
|
+
parent_path,
|
|
4957
|
+
field_name="parent_path",
|
|
4958
|
+
allow_root=True,
|
|
4959
|
+
)
|
|
4960
|
+
folder_name = self._normalize_workspace_entry_name(name, field_name="name")
|
|
4961
|
+
parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
|
|
4962
|
+
if not parent.exists() or not parent.is_dir():
|
|
4963
|
+
raise FileNotFoundError(
|
|
4964
|
+
f"Unknown destination folder `{normalized_parent or '.'}`."
|
|
4965
|
+
)
|
|
4966
|
+
target = resolve_within(parent, folder_name)
|
|
4967
|
+
if target.exists():
|
|
4968
|
+
raise FileExistsError(
|
|
4969
|
+
f"`{target.relative_to(workspace_root).as_posix()}` already exists."
|
|
4970
|
+
)
|
|
4971
|
+
ensure_dir(target)
|
|
4972
|
+
return {
|
|
4973
|
+
"ok": True,
|
|
4974
|
+
"quest_id": quest_id,
|
|
4975
|
+
"parent_path": normalized_parent,
|
|
4976
|
+
"item": self._workspace_entry_payload(workspace_root, target),
|
|
4977
|
+
"saved_at": utc_now(),
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
def upload_workspace_file(
|
|
4981
|
+
self,
|
|
4982
|
+
quest_id: str,
|
|
4983
|
+
*,
|
|
4984
|
+
file_name: str | None,
|
|
4985
|
+
content: bytes,
|
|
4986
|
+
mime_type: str | None = None,
|
|
4987
|
+
parent_path: str | None = None,
|
|
4988
|
+
) -> dict:
|
|
4989
|
+
workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
|
|
4990
|
+
normalized_parent = self._normalize_workspace_relative_path(
|
|
4991
|
+
parent_path,
|
|
4992
|
+
field_name="parent_path",
|
|
4993
|
+
allow_root=True,
|
|
4994
|
+
)
|
|
4995
|
+
safe_name = self._normalize_workspace_entry_name(file_name, field_name="file_name")
|
|
4996
|
+
parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
|
|
4997
|
+
if not parent.exists() or not parent.is_dir():
|
|
4998
|
+
raise FileNotFoundError(
|
|
4999
|
+
f"Unknown destination folder `{normalized_parent or '.'}`."
|
|
5000
|
+
)
|
|
5001
|
+
target = resolve_within(parent, safe_name)
|
|
5002
|
+
if target.exists():
|
|
5003
|
+
raise FileExistsError(
|
|
5004
|
+
f"`{target.relative_to(workspace_root).as_posix()}` already exists."
|
|
5005
|
+
)
|
|
5006
|
+
ensure_dir(target.parent)
|
|
5007
|
+
target.write_bytes(content)
|
|
5008
|
+
payload = self._workspace_entry_payload(workspace_root, target)
|
|
5009
|
+
guessed_mime = mimetypes.guess_type(target.name)[0] or mime_type or "application/octet-stream"
|
|
5010
|
+
payload["mime_type"] = guessed_mime
|
|
5011
|
+
return {
|
|
5012
|
+
"ok": True,
|
|
5013
|
+
"quest_id": quest_id,
|
|
5014
|
+
"parent_path": normalized_parent,
|
|
5015
|
+
"item": payload,
|
|
5016
|
+
"saved_at": utc_now(),
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
def rename_workspace_entry(
|
|
5020
|
+
self,
|
|
5021
|
+
quest_id: str,
|
|
5022
|
+
*,
|
|
5023
|
+
path: str | None,
|
|
5024
|
+
new_name: str | None,
|
|
5025
|
+
) -> dict:
|
|
5026
|
+
workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
|
|
5027
|
+
normalized_path = self._normalize_workspace_relative_path(
|
|
5028
|
+
path,
|
|
5029
|
+
field_name="path",
|
|
5030
|
+
allow_root=False,
|
|
5031
|
+
)
|
|
5032
|
+
source = resolve_within(workspace_root, normalized_path)
|
|
5033
|
+
if not source.exists():
|
|
5034
|
+
raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
|
|
5035
|
+
safe_name = self._normalize_workspace_entry_name(new_name, field_name="new_name")
|
|
5036
|
+
target = resolve_within(source.parent, safe_name)
|
|
5037
|
+
if target.exists() and target != source:
|
|
5038
|
+
raise FileExistsError(
|
|
5039
|
+
f"`{target.relative_to(workspace_root).as_posix()}` already exists."
|
|
5040
|
+
)
|
|
5041
|
+
if target != source:
|
|
5042
|
+
source.rename(target)
|
|
5043
|
+
payload = self._workspace_entry_payload(workspace_root, target)
|
|
5044
|
+
return {
|
|
5045
|
+
"ok": True,
|
|
5046
|
+
"quest_id": quest_id,
|
|
5047
|
+
"previous_path": normalized_path,
|
|
5048
|
+
"item": payload,
|
|
5049
|
+
"saved_at": utc_now(),
|
|
5050
|
+
}
|
|
5051
|
+
|
|
5052
|
+
def move_workspace_entries(
|
|
5053
|
+
self,
|
|
5054
|
+
quest_id: str,
|
|
5055
|
+
*,
|
|
5056
|
+
paths: Any,
|
|
5057
|
+
target_parent_path: str | None = None,
|
|
5058
|
+
) -> dict:
|
|
5059
|
+
workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
|
|
5060
|
+
normalized_paths = self._filter_nested_workspace_paths(
|
|
5061
|
+
self._normalize_workspace_path_list(paths, field_name="paths")
|
|
5062
|
+
)
|
|
5063
|
+
normalized_target_parent = self._normalize_workspace_relative_path(
|
|
5064
|
+
target_parent_path,
|
|
5065
|
+
field_name="target_parent_path",
|
|
5066
|
+
allow_root=True,
|
|
5067
|
+
)
|
|
5068
|
+
target_parent = (
|
|
5069
|
+
resolve_within(workspace_root, normalized_target_parent)
|
|
5070
|
+
if normalized_target_parent
|
|
5071
|
+
else workspace_root
|
|
5072
|
+
)
|
|
5073
|
+
if not target_parent.exists() or not target_parent.is_dir():
|
|
5074
|
+
raise FileNotFoundError(
|
|
5075
|
+
f"Unknown destination folder `{normalized_target_parent or '.'}`."
|
|
5076
|
+
)
|
|
5077
|
+
|
|
5078
|
+
moves: list[tuple[str, Path, Path]] = []
|
|
5079
|
+
destination_keys: set[str] = set()
|
|
5080
|
+
target_parent_resolved = target_parent.resolve()
|
|
5081
|
+
for normalized_path in normalized_paths:
|
|
5082
|
+
source = resolve_within(workspace_root, normalized_path)
|
|
5083
|
+
if not source.exists():
|
|
5084
|
+
raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
|
|
5085
|
+
source_resolved = source.resolve()
|
|
5086
|
+
if source_resolved == target_parent_resolved or source_resolved in target_parent_resolved.parents:
|
|
5087
|
+
raise ValueError(
|
|
5088
|
+
f"`{normalized_path}` cannot be moved into itself or one of its descendants."
|
|
5089
|
+
)
|
|
5090
|
+
destination = resolve_within(target_parent, source.name)
|
|
5091
|
+
if destination.exists() and destination.resolve() != source_resolved:
|
|
5092
|
+
raise FileExistsError(
|
|
5093
|
+
f"`{destination.relative_to(workspace_root).as_posix()}` already exists."
|
|
5094
|
+
)
|
|
5095
|
+
destination_key = str(destination.resolve())
|
|
5096
|
+
if destination_key in destination_keys and destination != source:
|
|
5097
|
+
raise FileExistsError(
|
|
5098
|
+
f"`{destination.relative_to(workspace_root).as_posix()}` would conflict with another moved entry."
|
|
5099
|
+
)
|
|
5100
|
+
destination_keys.add(destination_key)
|
|
5101
|
+
moves.append((normalized_path, source, destination))
|
|
5102
|
+
|
|
5103
|
+
items: list[dict] = []
|
|
5104
|
+
for _normalized_path, source, destination in moves:
|
|
5105
|
+
if destination != source:
|
|
5106
|
+
source.rename(destination)
|
|
5107
|
+
items.append(self._workspace_entry_payload(workspace_root, destination))
|
|
5108
|
+
return {
|
|
5109
|
+
"ok": True,
|
|
5110
|
+
"quest_id": quest_id,
|
|
5111
|
+
"target_parent_path": normalized_target_parent,
|
|
5112
|
+
"items": items,
|
|
5113
|
+
"saved_at": utc_now(),
|
|
5114
|
+
}
|
|
5115
|
+
|
|
5116
|
+
def delete_workspace_entries(
|
|
5117
|
+
self,
|
|
5118
|
+
quest_id: str,
|
|
5119
|
+
*,
|
|
5120
|
+
paths: Any,
|
|
5121
|
+
) -> dict:
|
|
5122
|
+
workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
|
|
5123
|
+
normalized_paths = self._filter_nested_workspace_paths(
|
|
5124
|
+
self._normalize_workspace_path_list(paths, field_name="paths")
|
|
5125
|
+
)
|
|
5126
|
+
sources: list[Path] = []
|
|
5127
|
+
items: list[dict] = []
|
|
5128
|
+
for normalized_path in normalized_paths:
|
|
5129
|
+
source = resolve_within(workspace_root, normalized_path)
|
|
5130
|
+
if not source.exists():
|
|
5131
|
+
raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
|
|
5132
|
+
sources.append(source)
|
|
5133
|
+
items.append(self._workspace_entry_payload(workspace_root, source))
|
|
5134
|
+
|
|
5135
|
+
for source in sorted(sources, key=lambda item: len(item.parts), reverse=True):
|
|
5136
|
+
if source.is_dir():
|
|
5137
|
+
shutil.rmtree(source)
|
|
5138
|
+
else:
|
|
5139
|
+
source.unlink()
|
|
5140
|
+
return {
|
|
5141
|
+
"ok": True,
|
|
5142
|
+
"quest_id": quest_id,
|
|
5143
|
+
"items": items,
|
|
5144
|
+
"saved_at": utc_now(),
|
|
5145
|
+
}
|
|
5146
|
+
|
|
4508
5147
|
def _revision_explorer(self, quest_id: str, *, revision: str, mode: str) -> dict:
|
|
4509
5148
|
quest_root = self._quest_root(quest_id)
|
|
4510
5149
|
if not self._git_revision_exists(quest_root, revision):
|
|
@@ -4697,6 +5336,8 @@ class QuestService:
|
|
|
4697
5336
|
}
|
|
4698
5337
|
|
|
4699
5338
|
def _initialize_runtime_files(self, quest_root: Path) -> None:
|
|
5339
|
+
if not self._quest_yaml_path(quest_root).exists():
|
|
5340
|
+
raise FileNotFoundError(f"Unknown quest `{quest_root.name}`.")
|
|
4700
5341
|
queue_path = self._message_queue_path(quest_root)
|
|
4701
5342
|
if not queue_path.exists():
|
|
4702
5343
|
write_json(queue_path, self._default_message_queue())
|
|
@@ -4828,10 +5469,11 @@ class QuestService:
|
|
|
4828
5469
|
if continuation_anchor is not _UNSET:
|
|
4829
5470
|
normalized_anchor = str(continuation_anchor or "").strip() or None
|
|
4830
5471
|
if normalized_anchor is not None:
|
|
4831
|
-
from ..prompts.builder import
|
|
5472
|
+
from ..prompts.builder import current_standard_skills
|
|
4832
5473
|
|
|
4833
|
-
|
|
4834
|
-
|
|
5474
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
5475
|
+
if normalized_anchor not in available_stage_skills:
|
|
5476
|
+
allowed = ", ".join(available_stage_skills)
|
|
4835
5477
|
raise ValueError(
|
|
4836
5478
|
f"Unsupported continuation anchor `{normalized_anchor}`. Allowed values: {allowed}."
|
|
4837
5479
|
)
|
|
@@ -5132,6 +5774,7 @@ class QuestService:
|
|
|
5132
5774
|
connector_hints: dict[str, Any] | None = None,
|
|
5133
5775
|
created_at: str | None = None,
|
|
5134
5776
|
counts_as_visible: bool = True,
|
|
5777
|
+
deliver_to_bound_conversations: bool | None = None,
|
|
5135
5778
|
) -> dict[str, Any]:
|
|
5136
5779
|
timestamp = created_at or utc_now()
|
|
5137
5780
|
payload = {
|
|
@@ -5148,6 +5791,11 @@ class QuestService:
|
|
|
5148
5791
|
"reply_mode": reply_mode,
|
|
5149
5792
|
"surface_actions": [dict(item) for item in (surface_actions or []) if isinstance(item, dict)],
|
|
5150
5793
|
"connector_hints": dict(connector_hints) if isinstance(connector_hints, dict) else {},
|
|
5794
|
+
"deliver_to_bound_conversations": (
|
|
5795
|
+
bool(deliver_to_bound_conversations)
|
|
5796
|
+
if deliver_to_bound_conversations is not None
|
|
5797
|
+
else None
|
|
5798
|
+
),
|
|
5151
5799
|
"created_at": timestamp,
|
|
5152
5800
|
}
|
|
5153
5801
|
append_jsonl(self._interaction_journal_path(quest_root), payload)
|
|
@@ -5397,6 +6045,12 @@ class QuestService:
|
|
|
5397
6045
|
"queued_message_count_after_delivery": len(queue_payload.get("pending") or []),
|
|
5398
6046
|
}
|
|
5399
6047
|
|
|
6048
|
+
@staticmethod
|
|
6049
|
+
def _document_resolution_root(quest_root: Path, workspace_root: Path, document_id: str) -> Path:
|
|
6050
|
+
if document_id.startswith(("questpath::", "memory::")):
|
|
6051
|
+
return quest_root
|
|
6052
|
+
return workspace_root
|
|
6053
|
+
|
|
5400
6054
|
@staticmethod
|
|
5401
6055
|
def _resolve_document(quest_root: Path, document_id: str) -> tuple[Path, bool, str, str]:
|
|
5402
6056
|
if document_id.startswith("memory::"):
|
|
@@ -5583,12 +6237,10 @@ class QuestService:
|
|
|
5583
6237
|
|
|
5584
6238
|
@staticmethod
|
|
5585
6239
|
def _read_git_bytes(quest_root: Path, revision: str, relative: str) -> bytes:
|
|
5586
|
-
result =
|
|
6240
|
+
result = run_command_bytes(
|
|
5587
6241
|
["git", "show", f"{revision}:{relative}"],
|
|
5588
|
-
cwd=
|
|
6242
|
+
cwd=quest_root,
|
|
5589
6243
|
check=False,
|
|
5590
|
-
text=False,
|
|
5591
|
-
capture_output=True,
|
|
5592
6244
|
)
|
|
5593
6245
|
if result.returncode != 0:
|
|
5594
6246
|
raise FileNotFoundError(f"File `{relative}` does not exist at `{revision}`.")
|