clouds-coder 2026.3.25__tar.gz → 2026.3.28__tar.gz

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.
@@ -27,6 +27,7 @@ import shlex
27
27
  import socket
28
28
  import subprocess
29
29
  import sys
30
+ import tarfile
30
31
  import threading
31
32
  import time
32
33
  import traceback
@@ -103,7 +104,7 @@ CODE_LIBRARY_DIRNAME = "Code_Library"
103
104
  CODE_ADMIN_PORT_OFFSET = 3
104
105
  RAG_CHUNK_CHARS = 1200
105
106
  RAG_CHUNK_OVERLAP = 180
106
- RAG_MAX_CHUNKS_PER_DOC = 200
107
+ RAG_MAX_CHUNKS_PER_DOC = 500
107
108
  CODE_CHUNK_CHARS = 1800
108
109
  CODE_CHUNK_OVERLAP = 120
109
110
  CODE_MAX_CHUNKS_PER_DOC = 260
@@ -152,7 +153,7 @@ MIN_AGENT_ROUNDS = 8
152
153
  MAX_AGENT_ROUNDS_CAP = 400
153
154
  REPEATED_TOOL_LOOP_THRESHOLD = 2
154
155
  BASH_READ_LOOP_THRESHOLD = 3
155
- HARD_BREAK_TOOL_ERROR_THRESHOLD = 3
156
+ HARD_BREAK_TOOL_ERROR_THRESHOLD = 20
156
157
  HARD_BREAK_RECOVERY_ROUND_THRESHOLD = 3
157
158
  FUSED_FAULT_BREAK_THRESHOLD = 3
158
159
  STALL_SEVERITY_ESCALATION_THRESHOLD = 5
@@ -454,7 +455,7 @@ PLAN_MODE_USER_CHOICES = ("auto", "on", "off")
454
455
  TASK_PHASES = ("research", "design", "implement", "test", "review", "deploy")
455
456
  TASK_PHASE_ROUTING = {
456
457
  "research": "explorer",
457
- "design": "explorer",
458
+ "design": "developer",
458
459
  "implement": "developer",
459
460
  "test": "developer",
460
461
  "review": "reviewer",
@@ -479,6 +480,8 @@ EXPLORER_STALL_THRESHOLD = 3 # consecutive same-target delegations before force
479
480
  DEVELOPER_EDIT_STALL_THRESHOLD = 3 # consecutive edit_file failures on same file before forced strategy change
480
481
  PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS = 4096
481
482
  PLAN_MODE_MAX_OPTIONS = 3
483
+ PLAN_FILE_RELATIVE_PATH = ".clouds_coder/plan.md"
484
+ PLAN_BUBBLE_MAX_CHARS = 3800 # margin under ASSISTANT_MESSAGE_EVENT_MAX_CHARS (4000)
482
485
  PLAN_MODE_RESEARCH_TOOL_ALLOWLIST = {
483
486
  "bash", "read_file", "context_recall", "task_get", "task_list",
484
487
  "check_background", "read_from_blackboard", "write_to_blackboard",
@@ -974,11 +977,214 @@ OFFLINE_JS_LIB_CATALOG: list[dict[str, object]] = [
974
977
  ],
975
978
  "match_tokens": ["cdn.tailwindcss.com", "@tailwindcss/browser", "tailwindcss"],
976
979
  },
980
+ {
981
+ "id": "mathjax",
982
+ "filename": "tex-mml-chtml.js",
983
+ "relative_path": "mathjax/es5/tex-mml-chtml.js",
984
+ "package_urls": [
985
+ "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz",
986
+ ],
987
+ "package_install_dir": "mathjax",
988
+ "package_required_paths": [
989
+ "package.json",
990
+ "es5/core.js",
991
+ "es5/loader.js",
992
+ "es5/startup.js",
993
+ "es5/tex-mml-chtml.js",
994
+ ],
995
+ "match_tokens": ["mathjax", "tex-mml-chtml.js", "/mathjax@"],
996
+ },
997
+ {
998
+ "id": "katex",
999
+ "filename": "katex.min.js",
1000
+ "relative_path": "katex/dist/katex.min.js",
1001
+ "package_urls": [
1002
+ "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
1003
+ ],
1004
+ "package_install_dir": "katex",
1005
+ "package_required_paths": [
1006
+ "package.json",
1007
+ "dist/katex.min.js",
1008
+ "dist/katex.min.css",
1009
+ "dist/contrib/auto-render.min.js",
1010
+ ],
1011
+ "match_tokens": ["katex", "katex.min.js", "/katex@"],
1012
+ },
1013
+ {
1014
+ "id": "katex_auto_render",
1015
+ "filename": "auto-render.min.js",
1016
+ "relative_path": "katex/dist/contrib/auto-render.min.js",
1017
+ "match_tokens": ["auto-render.min.js", "katex/contrib/auto-render", "katex-auto-render"],
1018
+ },
1019
+ {
1020
+ "id": "html2canvas",
1021
+ "filename": "html2canvas.min.js",
1022
+ "urls": [
1023
+ "https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js",
1024
+ "https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js",
1025
+ ],
1026
+ "match_tokens": ["html2canvas", "html2canvas.min.js"],
1027
+ },
1028
+ {
1029
+ "id": "jspdf",
1030
+ "filename": "jspdf.umd.min.js",
1031
+ "urls": [
1032
+ "https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js",
1033
+ "https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js",
1034
+ ],
1035
+ "match_tokens": ["jspdf", "jspdf.umd.min.js"],
1036
+ },
1037
+ {
1038
+ "id": "xlsx",
1039
+ "filename": "xlsx.full.min.js",
1040
+ "urls": [
1041
+ "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js",
1042
+ "https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js",
1043
+ ],
1044
+ "match_tokens": ["xlsx", "xlsx.full.min.js", "sheetjs"],
1045
+ },
1046
+ {
1047
+ "id": "jszip",
1048
+ "filename": "jszip.min.js",
1049
+ "relative_path": "node_modules/jszip/dist/jszip.min.js",
1050
+ "package_urls": [
1051
+ "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
1052
+ ],
1053
+ "package_install_dir": "node_modules/jszip",
1054
+ "package_required_paths": [
1055
+ "package.json",
1056
+ "dist/jszip.min.js",
1057
+ ],
1058
+ "package_postprocess": "jszip-main-to-dist",
1059
+ "match_tokens": ["jszip", "jszip.min.js"],
1060
+ },
1061
+ {
1062
+ "id": "pptxgenjs",
1063
+ "filename": "pptxgen.bundle.js",
1064
+ "relative_path": "pptxgenjs/dist/pptxgen.bundle.js",
1065
+ "package_urls": [
1066
+ "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
1067
+ ],
1068
+ "package_install_dir": "pptxgenjs",
1069
+ "package_required_paths": [
1070
+ "package.json",
1071
+ "dist/pptxgen.bundle.js",
1072
+ "dist/pptxgen.cjs.js",
1073
+ "dist/pptxgen.es.js",
1074
+ "dist/pptxgen.min.js",
1075
+ ],
1076
+ "match_tokens": ["pptxgenjs", "pptxgen.bundle.js", "pptxgen.cjs.js", "pptxgen.es.js", "pptxgen.min.js", "jszip.min.js"],
1077
+ },
1078
+ {
1079
+ "id": "pptxgenjs_bundle",
1080
+ "filename": "pptxgen.bundle.js",
1081
+ "relative_path": "pptxgenjs/dist/pptxgen.bundle.js",
1082
+ "match_tokens": ["pptxgenjs", "pptxgen.bundle.js", "pptxgen.bundle"],
1083
+ },
1084
+ {
1085
+ "id": "pptxgenjs_cjs",
1086
+ "filename": "pptxgen.cjs.js",
1087
+ "relative_path": "pptxgenjs/dist/pptxgen.cjs.js",
1088
+ "match_tokens": ["pptxgenjs", "pptxgen.cjs.js", "pptxgen.cjs"],
1089
+ },
1090
+ {
1091
+ "id": "pptxgenjs_es",
1092
+ "filename": "pptxgen.es.js",
1093
+ "relative_path": "pptxgenjs/dist/pptxgen.es.js",
1094
+ "match_tokens": ["pptxgenjs", "pptxgen.es.js", "pptxgen.es"],
1095
+ },
1096
+ {
1097
+ "id": "pptxgenjs_min",
1098
+ "filename": "pptxgen.min.js",
1099
+ "relative_path": "pptxgenjs/dist/pptxgen.min.js",
1100
+ "match_tokens": ["pptxgenjs", "pptxgen.min.js", "pptxgen.min"],
1101
+ },
977
1102
  ]
978
1103
  OFFLINE_JS_LIB_INDEX_FILE = "index.json"
979
1104
  OFFLINE_JS_LIB_README_FILE = "README.md"
980
1105
 
981
1106
 
1107
+ def _normalize_js_lib_asset_ref(value: str) -> str:
1108
+ raw = str(value or "").replace("\\", "/").strip()
1109
+ if not raw:
1110
+ return ""
1111
+ raw = raw.lstrip("/")
1112
+ pure = PurePosixPath(raw)
1113
+ parts: list[str] = []
1114
+ for part in pure.parts:
1115
+ if part in {"", "."}:
1116
+ continue
1117
+ if part == "..":
1118
+ return ""
1119
+ parts.append(part)
1120
+ return "/".join(parts)
1121
+
1122
+
1123
+ def _resolve_js_lib_asset_path(js_root: Path, asset_ref: str) -> Path | None:
1124
+ rel = _normalize_js_lib_asset_ref(asset_ref)
1125
+ if not rel:
1126
+ return None
1127
+ exact = (js_root / rel).resolve()
1128
+ try:
1129
+ if exact.is_file() and exact.is_relative_to(js_root):
1130
+ return exact
1131
+ except Exception:
1132
+ pass
1133
+ basename = _safe_js_filename(Path(rel).name, "lib.js")
1134
+ candidates: list[Path] = []
1135
+ try:
1136
+ for fp in js_root.rglob(basename):
1137
+ try:
1138
+ resolved = fp.resolve()
1139
+ except Exception:
1140
+ continue
1141
+ if resolved.is_file():
1142
+ try:
1143
+ if resolved.is_relative_to(js_root):
1144
+ candidates.append(resolved)
1145
+ except Exception:
1146
+ continue
1147
+ except Exception:
1148
+ return None
1149
+ if not candidates:
1150
+ return None
1151
+ candidates.sort(key=lambda p: (len(p.relative_to(js_root).parts), p.relative_to(js_root).as_posix()))
1152
+ return candidates[0]
1153
+
1154
+
1155
+ def _discover_extra_js_lib_files(js_root: Path, known_relative_paths: set[str]) -> list[dict]:
1156
+ rows: list[dict] = []
1157
+ if not js_root.exists():
1158
+ return rows
1159
+ seen: set[str] = set()
1160
+ for fp in sorted(js_root.rglob("*")):
1161
+ if not fp.is_file() or fp.name in {".DS_Store", OFFLINE_JS_LIB_INDEX_FILE, OFFLINE_JS_LIB_README_FILE}:
1162
+ continue
1163
+ if fp.suffix.lower() not in {".js", ".mjs", ".cjs"}:
1164
+ continue
1165
+ rel = fp.relative_to(js_root).as_posix()
1166
+ if rel in known_relative_paths or rel in seen:
1167
+ continue
1168
+ seen.add(rel)
1169
+ stem = fp.stem.replace(".min", "").replace(".umd", "").replace(".bundle", "")
1170
+ rows.append(
1171
+ {
1172
+ "id": f"local:{stem or fp.name}",
1173
+ "filename": fp.name,
1174
+ "relative_path": rel,
1175
+ "available": True,
1176
+ "size": int(fp.stat().st_size),
1177
+ "sha256": _sha256_file(fp),
1178
+ "source": "existing-local",
1179
+ "error": "",
1180
+ "match_tokens": [fp.name.lower(), stem.lower(), rel.lower()],
1181
+ "urls": [],
1182
+ "catalog": False,
1183
+ }
1184
+ )
1185
+ return rows
1186
+
1187
+
982
1188
  def normalize_ui_language(raw: str | None) -> str:
983
1189
  key = str(raw or "").strip()
984
1190
  if key in UI_LANGUAGE_LABELS:
@@ -1105,6 +1311,7 @@ def _detect_os_shell_instruction() -> str:
1105
1311
  "Package manager is 'brew'. "
1106
1312
  "Environment variables WORKSPACE_ROOT, SESSION_ROOT, and SKILLS_ROOT are available. "
1107
1313
  "Virtual aliases '/workspace/...' and '/skills/...' are supported in shell commands and rewritten to real paths before execution. "
1314
+ "Virtual alias '/js_lib/...' maps to the offline JS libraries root ($JS_LIB_ROOT) and is also supported in file tools. "
1108
1315
  "Do NOT assume Linux-specific paths like /proc or /etc/os-release exist. "
1109
1316
  "IMPORTANT: The workspace path contains spaces. Always use relative paths "
1110
1317
  "(e.g., 'ls uploaded/' not 'ls /full/absolute/path/uploaded/'). "
@@ -1253,6 +1460,28 @@ def extract_ui_style_setting(raw: object) -> str | None:
1253
1460
  return None
1254
1461
 
1255
1462
 
1463
+ def extract_js_lib_download_setting(raw: object) -> bool | None:
1464
+ """Read download_js_lib flag from config dict.
1465
+ Keys accepted: download_js_lib / js_lib_download / enable_js_lib_download
1466
+ Sections searched: top-level, then 'startup' / 'offline' / 'web_ui'.
1467
+ Returns True/False, or None if key absent (caller uses default=True).
1468
+ """
1469
+ if not isinstance(raw, dict):
1470
+ return None
1471
+ keys = ("download_js_lib", "js_lib_download", "enable_js_lib_download", "offline_js_download")
1472
+ for key in keys:
1473
+ if key in raw:
1474
+ return _to_bool_like(raw.get(key), default=True)
1475
+ for section_key in ("startup", "offline", "web_ui", "ui"):
1476
+ section = raw.get(section_key)
1477
+ if not isinstance(section, dict):
1478
+ continue
1479
+ for key in keys:
1480
+ if key in section:
1481
+ return _to_bool_like(section.get(key), default=True)
1482
+ return None
1483
+
1484
+
1256
1485
  def default_multimodal_capabilities() -> dict[str, bool]:
1257
1486
  return {
1258
1487
  "input_image": False,
@@ -1618,20 +1847,194 @@ def _download_http_bytes(url: str, timeout: float = 25.0) -> tuple[bytes, str]:
1618
1847
  def offline_js_lib_root(workdir: Path = WORKDIR) -> Path:
1619
1848
  return (workdir / "js_lib").resolve()
1620
1849
 
1850
+ def _offline_js_entry_relative_path(entry: dict[str, object], fallback_name: str) -> str:
1851
+ rel = _normalize_js_lib_asset_ref(str(entry.get("relative_path", "") or ""))
1852
+ if rel:
1853
+ return rel
1854
+ return _safe_js_filename(fallback_name, fallback_name)
1855
+
1856
+ def _archive_member_relative_path(name: str) -> str:
1857
+ raw = _normalize_js_lib_asset_ref(name)
1858
+ if not raw:
1859
+ return ""
1860
+ parts = [part for part in PurePosixPath(raw).parts if part not in {"", "."}]
1861
+ while parts and parts[0].lower() == "package":
1862
+ parts = parts[1:]
1863
+ if not parts:
1864
+ return ""
1865
+ return "/".join(parts)
1866
+
1867
+ def _path_size_bytes(target: Path) -> int:
1868
+ try:
1869
+ if target.is_file():
1870
+ return int(target.stat().st_size)
1871
+ if not target.exists():
1872
+ return 0
1873
+ total = 0
1874
+ for fp in target.rglob("*"):
1875
+ try:
1876
+ if fp.is_file():
1877
+ total += int(fp.stat().st_size)
1878
+ except Exception:
1879
+ continue
1880
+ return total
1881
+ except Exception:
1882
+ return 0
1883
+
1884
+ def _extract_archive_to_dir(raw: bytes, install_root: Path) -> list[str]:
1885
+ install_root.mkdir(parents=True, exist_ok=True)
1886
+ install_root_resolved = install_root.resolve()
1887
+ written: list[str] = []
1888
+
1889
+ def _write_bytes(rel: str, data: bytes):
1890
+ target = (install_root / rel).resolve()
1891
+ if not target.is_relative_to(install_root_resolved):
1892
+ raise ValueError(f"archive member escapes target dir: {rel}")
1893
+ target.parent.mkdir(parents=True, exist_ok=True)
1894
+ target.write_bytes(data)
1895
+ written.append(rel)
1896
+
1897
+ bio = io.BytesIO(raw)
1898
+ try:
1899
+ if zipfile.is_zipfile(bio):
1900
+ bio.seek(0)
1901
+ with zipfile.ZipFile(bio, "r") as zf:
1902
+ for info in zf.infolist():
1903
+ if info.is_dir():
1904
+ continue
1905
+ rel = _archive_member_relative_path(info.filename)
1906
+ if not rel:
1907
+ continue
1908
+ _write_bytes(rel, zf.read(info))
1909
+ return written
1910
+ except Exception:
1911
+ pass
1912
+
1913
+ with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tf:
1914
+ for member in tf.getmembers():
1915
+ if not member.isfile():
1916
+ continue
1917
+ rel = _archive_member_relative_path(member.name)
1918
+ if not rel:
1919
+ continue
1920
+ extracted = tf.extractfile(member)
1921
+ if extracted is None:
1922
+ continue
1923
+ _write_bytes(rel, extracted.read())
1924
+ return written
1925
+
1926
+ def _package_required_paths(entry: dict[str, object]) -> list[str]:
1927
+ rows: list[str] = []
1928
+ for item in entry.get("package_required_paths") or []:
1929
+ rel = _normalize_js_lib_asset_ref(str(item or ""))
1930
+ if rel:
1931
+ rows.append(rel)
1932
+ return rows
1933
+
1934
+ def _package_install_ready(install_root: Path, required_paths: list[str]) -> bool:
1935
+ if not install_root.exists():
1936
+ return False
1937
+ if required_paths:
1938
+ return all((install_root / rel).is_file() for rel in required_paths)
1939
+ try:
1940
+ return any(fp.is_file() for fp in install_root.rglob("*"))
1941
+ except Exception:
1942
+ return False
1943
+
1944
+ def _postprocess_offline_js_package(entry: dict[str, object], install_root: Path):
1945
+ action = str(entry.get("package_postprocess", "") or "").strip().lower()
1946
+ if action != "jszip-main-to-dist":
1947
+ return
1948
+ pkg_json = (install_root / "package.json").resolve()
1949
+ dist_fp = (install_root / "dist" / "jszip.min.js").resolve()
1950
+ if not pkg_json.exists():
1951
+ if not dist_fp.exists():
1952
+ return
1953
+ obj: dict[str, object] = {
1954
+ "name": "jszip",
1955
+ "version": "offline-local",
1956
+ "main": "./dist/jszip.min.js",
1957
+ "exports": {".": "./dist/jszip.min.js"},
1958
+ }
1959
+ else:
1960
+ try:
1961
+ raw = pkg_json.read_text(encoding="utf-8")
1962
+ loaded = json.loads(raw)
1963
+ if not isinstance(loaded, dict):
1964
+ return
1965
+ obj = loaded
1966
+ except Exception:
1967
+ return
1968
+ changed = False
1969
+ if obj.get("main") != "./dist/jszip.min.js":
1970
+ obj["main"] = "./dist/jszip.min.js"
1971
+ changed = True
1972
+ exports = obj.get("exports")
1973
+ desired_exports = {".": "./dist/jszip.min.js"}
1974
+ if exports != desired_exports:
1975
+ obj["exports"] = desired_exports
1976
+ changed = True
1977
+ if changed or (not pkg_json.exists()):
1978
+ pkg_json.parent.mkdir(parents=True, exist_ok=True)
1979
+ pkg_json.write_text(json_dumps(obj, indent=2), encoding="utf-8")
1980
+
1981
+ def _ensure_offline_js_package(root: Path, entry: dict[str, object], force: bool = False) -> tuple[bool, str, str, str]:
1982
+ package_urls = [str(x).strip() for x in (entry.get("package_urls") or []) if str(x).strip()]
1983
+ if not package_urls:
1984
+ return True, "", "", ""
1985
+ install_dir = _normalize_js_lib_asset_ref(str(entry.get("package_install_dir", "") or ""))
1986
+ if not install_dir:
1987
+ install_dir = _normalize_js_lib_asset_ref(str(entry.get("id", "") or "package"))
1988
+ if not install_dir:
1989
+ return False, "missing", "package install dir is empty", ""
1990
+ install_root = (root / install_dir).resolve()
1991
+ if not install_root.is_relative_to(root):
1992
+ return False, "missing", f"package install dir escapes js_lib: {install_dir}", install_dir
1993
+ required_paths = _package_required_paths(entry)
1994
+ if not force and install_root.exists():
1995
+ _postprocess_offline_js_package(entry, install_root)
1996
+ if _package_install_ready(install_root, required_paths):
1997
+ return True, "existing-package", "", install_dir
1998
+ try:
1999
+ if any(fp.is_file() for fp in install_root.rglob("*")):
2000
+ return True, "existing-package", "", install_dir
2001
+ except Exception:
2002
+ pass
2003
+ error = ""
2004
+ source = "missing"
2005
+ for url in package_urls:
2006
+ try:
2007
+ data, _ = _download_http_bytes(url, timeout=90.0)
2008
+ if len(data) < 200:
2009
+ error = f"archive too small from {url}"
2010
+ continue
2011
+ _extract_archive_to_dir(data, install_root)
2012
+ _postprocess_offline_js_package(entry, install_root)
2013
+ if _package_install_ready(install_root, required_paths):
2014
+ return True, url, "", install_dir
2015
+ error = f"package install incomplete from {url}"
2016
+ source = url
2017
+ except Exception as exc:
2018
+ error = trim(str(exc), 220)
2019
+ source = url
2020
+ return False, source, error, install_dir
2021
+
1621
2022
  def _render_offline_js_catalog_md() -> str:
1622
2023
  rows = [
1623
2024
  "# Offline JS Library Catalog",
1624
2025
  "",
1625
2026
  "Pre-fetched common JS libraries for offline HTML deliverables.",
2027
+ "Additional local `.js/.mjs/.cjs` files placed anywhere under `js_lib/` are auto-indexed in `index.json` and can be served by relative path.",
1626
2028
  "",
1627
- "| id | filename | source urls |",
1628
- "|---|---|---|",
2029
+ "| id | relative path | file urls | package urls |",
2030
+ "|---|---|---|---|",
1629
2031
  ]
1630
2032
  for row in OFFLINE_JS_LIB_CATALOG:
1631
2033
  lib_id = str(row.get("id", "") or "").strip()
1632
- filename = str(row.get("filename", "") or "").strip()
2034
+ filename = _offline_js_entry_relative_path(row, str(row.get("filename", "") or lib_id or "lib.js"))
1633
2035
  urls = [str(x).strip() for x in (row.get("urls") or []) if str(x).strip()]
1634
- rows.append(f"| `{lib_id}` | `{filename}` | {'<br>'.join(urls)} |")
2036
+ package_urls = [str(x).strip() for x in (row.get("package_urls") or []) if str(x).strip()]
2037
+ rows.append(f"| `{lib_id}` | `{filename}` | {'<br>'.join(urls)} | {'<br>'.join(package_urls)} |")
1635
2038
  return "\n".join(rows) + "\n"
1636
2039
 
1637
2040
  def load_offline_js_lib_index(js_root: Path) -> dict:
@@ -1645,26 +2048,78 @@ def load_offline_js_lib_index(js_root: Path) -> dict:
1645
2048
  except Exception:
1646
2049
  return {}
1647
2050
 
1648
- def ensure_offline_js_libs(workdir: Path = WORKDIR, force: bool = False) -> dict:
2051
+ def ensure_offline_js_libs(
2052
+ workdir: Path = WORKDIR,
2053
+ force: bool = False,
2054
+ verbose: bool = False,
2055
+ no_connection_deadline: float = 60.0,
2056
+ ) -> dict:
1649
2057
  root = offline_js_lib_root(workdir)
1650
2058
  root.mkdir(parents=True, exist_ok=True)
1651
2059
  fetched = 0
1652
2060
  available = 0
1653
2061
  missing = 0
2062
+ _dl_start = time.time()
2063
+ _deadline_hit = False
2064
+ total_catalog = len(OFFLINE_JS_LIB_CATALOG)
2065
+ if verbose:
2066
+ print(f"[js_lib] Starting JS library download ({total_catalog} files)...", flush=True)
1654
2067
  rows: list[dict] = []
2068
+ known_relative_paths: set[str] = set()
1655
2069
  for entry in OFFLINE_JS_LIB_CATALOG:
1656
2070
  lib_id = str(entry.get("id", "") or "").strip() or "lib"
1657
2071
  filename = _safe_js_filename(str(entry.get("filename", "") or f"{lib_id}.js"), f"{lib_id}.js")
2072
+ relative_path = _offline_js_entry_relative_path(entry, filename)
1658
2073
  urls = [str(x).strip() for x in (entry.get("urls") or []) if str(x).strip()]
2074
+ package_urls = [str(x).strip() for x in (entry.get("package_urls") or []) if str(x).strip()]
1659
2075
  match_tokens = [str(x).strip().lower() for x in (entry.get("match_tokens") or []) if str(x).strip()]
1660
- target = root / filename
2076
+ target = (root / relative_path).resolve()
2077
+ if not target.is_relative_to(root):
2078
+ rows.append(
2079
+ {
2080
+ "id": lib_id,
2081
+ "filename": filename,
2082
+ "available": False,
2083
+ "size": 0,
2084
+ "sha256": "",
2085
+ "source": "missing",
2086
+ "error": f"relative path escapes js_lib: {relative_path}",
2087
+ "match_tokens": match_tokens,
2088
+ "urls": urls,
2089
+ "package_urls": package_urls,
2090
+ "relative_path": relative_path,
2091
+ "package_install_dir": "",
2092
+ "package_required_paths": [],
2093
+ "package_available": not package_urls,
2094
+ "package_size": 0,
2095
+ "catalog": True,
2096
+ }
2097
+ )
2098
+ missing += 1
2099
+ continue
2100
+ known_relative_paths.add(relative_path)
1661
2101
  source = "existing"
1662
2102
  error = ""
1663
- ok = bool(target.exists() and target.is_file() and target.stat().st_size > 40 and (not force))
1664
- if not ok:
2103
+ effective_target = target
2104
+ file_ok = bool(target.exists() and target.is_file() and target.stat().st_size > 40 and (not force))
2105
+ if (not file_ok) and (not force):
2106
+ resolved_existing = _resolve_js_lib_asset_path(root, relative_path)
2107
+ if resolved_existing and resolved_existing.exists() and resolved_existing.is_file() and resolved_existing.stat().st_size > 40:
2108
+ effective_target = resolved_existing
2109
+ file_ok = True
2110
+ source = "existing-local"
2111
+ if not file_ok and urls:
2112
+ if fetched == 0 and (time.time() - _dl_start) > no_connection_deadline:
2113
+ _deadline_hit = True
2114
+ if verbose:
2115
+ print(f"[js_lib] No connection after {no_connection_deadline:.0f}s — skipping remaining downloads.", flush=True)
2116
+ break
2117
+ if verbose:
2118
+ print(f"[js_lib] Downloading {filename}...", flush=True)
1665
2119
  for url in urls:
1666
2120
  try:
1667
- data, _ = _download_http_bytes(url, timeout=35.0)
2121
+ _timeout = 12.0 if fetched == 0 else 35.0
2122
+ data, _ = _download_http_bytes(url, timeout=_timeout)
1668
2123
  if len(data) < 40:
1669
2124
  error = f"download too small from {url}"
1670
2125
  continue
@@ -1672,39 +2127,67 @@ def ensure_offline_js_libs(workdir: Path = WORKDIR, force: bool = False) -> dict
1672
2127
  if "<html" in probe and "<script" not in probe and "tailwind" not in probe:
1673
2128
  error = f"unexpected html payload from {url}"
1674
2129
  continue
2130
+ target.parent.mkdir(parents=True, exist_ok=True)
1675
2131
  target.write_bytes(data)
1676
- ok = True
2132
+ effective_target = target
2133
+ file_ok = True
1677
2134
  source = url
1678
2135
  fetched += 1
1679
2136
  break
1680
2137
  except Exception as exc:
1681
2138
  error = trim(str(exc), 220)
1682
- if not ok:
2139
+ if not file_ok:
1683
2140
  source = "missing"
2141
+ package_ok, package_source, package_error, package_install_dir = _ensure_offline_js_package(root, entry, force=force)
2142
+ if package_urls and package_source and package_source not in {"existing-package", "missing"}:
2143
+ fetched += 1
2144
+ if package_error:
2145
+ error = package_error if not error else f"{error}; {package_error}"
2146
+ ok = bool((file_ok or (not urls)) and package_ok and effective_target.exists() and effective_target.is_file() and effective_target.stat().st_size > 40)
1684
2147
  if ok:
1685
2148
  available += 1
1686
2149
  else:
1687
2150
  missing += 1
2151
+ if package_urls and package_source == "missing" and not source.strip():
2152
+ source = "missing"
2153
+ package_root = (root / package_install_dir).resolve() if package_install_dir else None
1688
2154
  rows.append(
1689
2155
  {
1690
2156
  "id": lib_id,
1691
2157
  "filename": filename,
1692
2158
  "available": ok,
1693
- "size": int(target.stat().st_size) if target.exists() else 0,
1694
- "sha256": _sha256_file(target) if target.exists() else "",
1695
- "source": source,
2159
+ "size": int(effective_target.stat().st_size) if effective_target.exists() else 0,
2160
+ "sha256": _sha256_file(effective_target) if effective_target.exists() else "",
2161
+ "source": source if source != "existing" or file_ok else package_source or source,
1696
2162
  "error": error,
1697
2163
  "match_tokens": match_tokens,
1698
2164
  "urls": urls,
2165
+ "package_urls": package_urls,
2166
+ "relative_path": relative_path,
2167
+ "resolved_path": effective_target.relative_to(root).as_posix() if effective_target.exists() else "",
2168
+ "package_install_dir": package_install_dir,
2169
+ "package_required_paths": _package_required_paths(entry),
2170
+ "package_available": package_ok,
2171
+ "package_size": _path_size_bytes(package_root) if package_root else 0,
2172
+ "package_source": package_source,
2173
+ "catalog": True,
1699
2174
  }
1700
2175
  )
2176
+ extra_rows = _discover_extra_js_lib_files(root, known_relative_paths)
2177
+ rows.extend(extra_rows)
2178
+ if verbose:
2179
+ _status = "skipped (no connection)" if _deadline_hit else f"{fetched} downloaded"
2180
+ print(f"[js_lib] Done: {available}/{len(rows)} available, {_status}.", flush=True)
1701
2181
  payload = {
1702
2182
  "generated_at": int(now_ts()),
1703
2183
  "js_lib_root": str(root),
1704
- "total": len(OFFLINE_JS_LIB_CATALOG),
2184
+ "total": len(rows),
1705
2185
  "available": available,
1706
2186
  "missing": missing,
1707
2187
  "fetched": fetched,
2188
+ "catalog_total": len(OFFLINE_JS_LIB_CATALOG),
2189
+ "catalog_missing": missing,
2190
+ "extra_local": len(extra_rows),
1708
2191
  "libs": rows,
1709
2192
  }
1710
2193
  (root / OFFLINE_JS_LIB_INDEX_FILE).write_text(json_dumps(payload, indent=2), encoding="utf-8")
@@ -3666,9 +4149,13 @@ class TodoManager:
3666
4149
  row["owner"] = owner
3667
4150
  if key:
3668
4151
  row["key"] = key
4152
+ # Preserve parent_step_id for subtask-to-plan-step linkage
4153
+ parent_step_id = trim(str(raw.get("parent_step_id", "") or ""), 20)
4154
+ if parent_step_id:
4155
+ row["parent_step_id"] = parent_step_id
3669
4156
  validated.append(row)
3670
- if len(validated) > 20:
3671
- raise ValueError("max 20 todos")
4157
+ if len(validated) > 40:
4158
+ raise ValueError("max 40 todos")
3672
4159
  if validated and not any(x["status"] == "in_progress" for x in validated):
3673
4160
  for row in validated:
3674
4161
  if row["status"] == "pending":
@@ -4311,6 +4798,7 @@ EMBEDDED_SKILLS_ARCHIVE_FILES = [
4311
4798
  "skills/generated/upload-office-parser/SKILL.md",
4312
4799
  "skills/generated/upload-parsers-capabilities.json",
4313
4800
  "skills/generated/upload-tabular-parser/SKILL.md",
4801
+ "skills/generated/upload-image-parser/SKILL.md",
4314
4802
  "skills/mcp-builder/SKILL.md",
4315
4803
  "skills/pdf/SKILL.md",
4316
4804
  "skills/skills_Gen/SKILL.md",
@@ -4469,6 +4957,36 @@ Use this skill when the user uploads Word/PowerPoint documents and needs content
4469
4957
  ## Notes
4470
4958
  - The backend automatically parses `.doc`, `.docx`, `.ppt`, `.pptx`.
4471
4959
  - If parser dependencies are unavailable, fallback extractor is used (may lose formatting).
4960
+ """
4961
+ image_skill = """---
4962
+ name: upload-image-parser
4963
+ description: Analyze uploaded image files (PNG/JPG/JPEG/WEBP/GIF/BMP) using model native vision capabilities as the primary method; no OCR tools needed for supported formats.
4964
+ ---
4965
+
4966
+ # Upload Image Parser
4967
+
4968
+ Use this skill when the user uploads image files and needs content description, analysis, extraction, or comparison.
4969
+
4970
+ ## Primary Approach: Model Vision (Multimodal)
4971
+ 1. Use `read_file` on the uploaded image path — the runtime automatically injects it as a native vision input to the model.
4972
+ 2. Analyze or describe the image directly with vision capabilities; no external tools required.
4973
+ 3. Uploaded image paths are under `files/uploaded/` in the session workspace. Check `Uploaded files context` in the system prompt for exact paths.
4974
+
4975
+ ## Format Notes
4976
+ - Native formats (sent directly, no conversion): `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`
4977
+ - Auto-converted formats (runtime handles via Pillow): `.bmp`, `.tiff`, `.tif`, `.heic`, `.heif`, `.avif`
4978
+ - If conversion fails, the runtime returns an error message; use bash fallback below.
4979
+ - `.svg` files: runtime returns the SVG markup as text — parse the XML/SVG source directly, do not treat as a raster image.
4980
+
4981
+ ## Fallback (only if runtime reports vision input unavailable)
4982
+ If the model cannot process the image natively (runtime message will say so):
4983
+ - OCR text extraction: `bash` → `tesseract <path> stdout`
4984
+ - Metadata / dimensions: `bash` → `identify <path>` (ImageMagick)
4985
+ - Pixel-level analysis: `bash` → `python3 -c "from PIL import Image; img=Image.open('<path>'); print(img.size, img.mode)"`
4986
+
4987
+ ## Notes
4988
+ - Never attempt text extraction (OCR) on images when vision input is available — use the model's native understanding instead.
4989
+ - For multi-image comparison tasks, load each image via `read_file` sequentially; the runtime accumulates them as pending media inputs for the next model call.
4472
4990
  """
4473
4991
  cap_json = json_dumps(
4474
4992
  {
@@ -4479,6 +4997,7 @@ Use this skill when the user uploads Word/PowerPoint documents and needs content
4479
4997
  )
4480
4998
  _write_text_if_changed(generated_root / "upload-tabular-parser" / "SKILL.md", tabular_skill)
4481
4999
  _write_text_if_changed(generated_root / "upload-office-parser" / "SKILL.md", office_skill)
5000
+ _write_text_if_changed(generated_root / "upload-image-parser" / "SKILL.md", image_skill)
4482
5001
  _write_text_if_changed(generated_root / "upload-parsers-capabilities.json", cap_json)
4483
5002
 
4484
5003
  def ensure_generated_image_coding_feedback_skill(skills_root: Path):
@@ -4501,12 +5020,12 @@ Use this skill when the task depends on image understanding in a coding workflow
4501
5020
  - Generated image(s): current output from app, script, or model generation pipeline.
4502
5021
  - Code scope: file paths, rendering command, and runtime constraints.
4503
5022
 
4504
- ## Capability Gate
4505
- 1. Check active model/image pipeline capability first.
4506
- 2. If image input is supported, use direct vision reasoning for detailed comparison.
4507
- 3. If image input is unavailable, use fallback checks:
5023
+ ## Image Analysis: Vision First
5024
+ 1. Load all reference and generated images via `read_file` — the runtime injects them as native vision inputs automatically.
5025
+ 2. Analyze images directly with model vision capabilities; do not use OCR or pixel heuristics when vision input is available.
5026
+ 3. Fallback (only if the runtime explicitly reports vision input unavailable):
4508
5027
  - deterministic metadata checks (size/aspect/background),
4509
- - text checks (OCR if available),
5028
+ - text checks via OCR tools (e.g., `tesseract`),
4510
5029
  - simple pixel-region checks from locally rendered output.
4511
5030
  4. Always report confidence level (`high|medium|low`) based on signal quality.
4512
5031
 
@@ -5904,6 +6423,7 @@ def ensure_generated_runtime_skills_manifest(skills_root: Path):
5904
6423
  "skills_Gen/knowledge_snapshot.json",
5905
6424
  "generated/upload-tabular-parser/SKILL.md",
5906
6425
  "generated/upload-office-parser/SKILL.md",
6426
+ "generated/upload-image-parser/SKILL.md",
5907
6427
  "generated/image-coding-feedback-loop/SKILL.md",
5908
6428
  "generated/execution-degradation-recovery/SKILL.md",
5909
6429
  "generated/deep-research-orchestrator/SKILL.md",
@@ -9043,7 +9563,12 @@ TOOLS = [
9043
9563
  ),
9044
9564
  tool_def(
9045
9565
  "query_knowledge_library",
9046
- "Read current global knowledge-library status or query the TF-Graph_IDF RAG library for grounded document references.",
9566
+ (
9567
+ "Query the RAG knowledge library for grounded document references and background knowledge. "
9568
+ "Call this BEFORE answering questions that require domain expertise, factual grounding, "
9569
+ "or synthesis from imported documents. "
9570
+ "Pass an empty query to check library status only."
9571
+ ),
9047
9572
  {
9048
9573
  "query": {"type": "string"},
9049
9574
  "top_k": {"type": "integer"},
@@ -9374,6 +9899,8 @@ class SessionState:
9374
9899
  self.runtime_task_judgement = ""
9375
9900
  self.runtime_task_type = ""
9376
9901
  self.runtime_task_complexity = ""
9902
+ self.runtime_complexity_floor = ""
9903
+ self.runtime_task_level_floor = 0
9377
9904
  self.runtime_scale_preference = "balanced"
9378
9905
  self.runtime_direct_objective = ""
9379
9906
  self.runtime_reclassify_goal = ""
@@ -10454,6 +10981,12 @@ class SessionState:
10454
10981
  self.runtime_reclassify_required = bool(
10455
10982
  raw.get("runtime_reclassify_required", self.runtime_reclassify_required)
10456
10983
  )
10984
+ self.runtime_complexity_floor = str(
10985
+ raw.get("runtime_complexity_floor", self.runtime_complexity_floor) or ""
10986
+ )
10987
+ self.runtime_task_level_floor = int(
10988
+ raw.get("runtime_task_level_floor", self.runtime_task_level_floor) or 0
10989
+ )
10457
10990
  self.runtime_goal_reset_pending = bool(
10458
10991
  raw.get("runtime_goal_reset_pending", self.runtime_goal_reset_pending)
10459
10992
  )
@@ -10616,6 +11149,8 @@ class SessionState:
10616
11149
  "runtime_direct_objective": trim(str(self.runtime_direct_objective or ""), 800),
10617
11150
  "runtime_reclassify_goal": trim(str(self.runtime_reclassify_goal or ""), 4000),
10618
11151
  "runtime_reclassify_required": bool(self.runtime_reclassify_required),
11152
+ "runtime_complexity_floor": str(self.runtime_complexity_floor or ""),
11153
+ "runtime_task_level_floor": int(self.runtime_task_level_floor or 0),
10619
11154
  "runtime_goal_reset_pending": bool(self.runtime_goal_reset_pending),
10620
11155
  "runtime_plan_mode_needed": bool(self.runtime_plan_mode_needed),
10621
11156
  "runtime_plan_approved": bool(self.runtime_plan_approved),
@@ -10780,6 +11315,39 @@ class SessionState:
10780
11315
  self.runtime_code_reference_meta = {}
10781
11316
  return removed_hints
10782
11317
 
11318
+ def _reset_blackboard_plan_state_locked(self) -> None:
11319
+ """Clear plan/todo/skills state from a completed run so the next run starts fresh.
11320
+
11321
+ Called from submit_user_message when a new user request arrives after a
11322
+ previous run finished (running=False, not awaiting plan choice).
11323
+ Prevents the manager from seeing status=COMPLETED + all todos done and
11324
+ immediately routing to 'finish' again on the very first round.
11325
+ """
11326
+ bb = self._ensure_blackboard()
11327
+ bb["project_todos"] = []
11328
+ bb["plan_steps"] = []
11329
+ bb["plan_step_cursor"] = 0
11330
+ bb["plan_step_total"] = 0
11331
+ bb["status"] = ""
11332
+ bb["approval"] = ""
11333
+ bb["plan_findings"] = ""
11334
+ bb["plan_proposal"] = ""
11335
+ bb["plan_risks"] = ""
11336
+ bb["loaded_skills"] = {}
11337
+ bb["loaded_skills_goal_sig"] = ""
11338
+ self.blackboard = bb
11339
+ try:
11340
+ self.todo.items = []
11341
+ except Exception:
11342
+ pass
11343
+ # Clean up stale plan.md file from previous run
11344
+ try:
11345
+ _pf = self._plan_file_path()
11346
+ if _pf.exists():
11347
+ _pf.unlink()
11348
+ except Exception:
11349
+ pass
11350
+
10783
11351
  def _event_payload_with_agent_role(self, kind: str, data: dict | None) -> dict:
10784
11352
  payload = dict(data or {})
10785
11353
  if self._sanitize_agent_bubble_role(payload.get("agent_role", "")):
@@ -11133,7 +11701,10 @@ class SessionState:
11133
11701
  skill_desc = str(skill_row.get("description", "-")).strip()
11134
11702
  inject_msg = (
11135
11703
  f"<loaded-skill name=\"{skill_key}\">\n"
11136
- f"A skill has been loaded. Follow its instructions precisely.\n"
11704
+ f"A skill has been loaded. IMPORTANT: This skill's workflow, tools, and commands "
11705
+ f"OVERRIDE the plan's implementation approach for any step where it applies. "
11706
+ f"Read the full instructions below and follow them exactly — do NOT substitute a "
11707
+ f"different tool, library, or language unless the skill explicitly allows it.\n"
11137
11708
  f"{trim(skill_text, 12000)}\n"
11138
11709
  f"</loaded-skill>"
11139
11710
  )
@@ -11594,14 +12165,28 @@ class SessionState:
11594
12165
  )
11595
12166
  return (
11596
12167
  f"ACTIVE SKILLS: {names}. "
11597
- "Follow loaded skill instructions precisely. "
11598
- f"If you encounter a step requiring a workflow you don't know, call load_skill ({skill_count} available). "
12168
+ "Follow the loaded skill instructions for the current step. "
12169
+ f"When moving to a different step that needs a DIFFERENT skill, call load_skill to switch "
12170
+ f"(or unload the current one first if it's no longer needed). "
12171
+ f"{skill_count} skills available total. "
11599
12172
  )
11600
12173
  return (
11601
12174
  f"SKILL SYSTEM: {skill_count} skills available. "
11602
- "Use list_skills to discover, load_skill to activate. "
11603
- "If unsure how to produce professional output (docs, slides, analysis), check skills first. "
11604
- )
12175
+ "Skills are loaded ON-DEMAND — decide when you need one based on the CURRENT step, not upfront. "
12176
+ "For specialized output (reports, slides/PPT, deep research, code review, PDF analysis): "
12177
+ "call list_skills to discover options, then load_skill to activate the right one. "
12178
+ "Load a skill AT THE MOMENT you begin the step that requires it. "
12179
+ "Unload it (via unload_skill) when moving to a different step that needs a different skill. "
12180
+ "For simple tasks, direct questions, and multimodal analysis, do NOT load skills. "
12181
+ )
12182
+
12183
+ def _skills_awareness_block(self, for_role: str = "developer") -> str:
12184
+ """Canonical skills-awareness block shared by single, sync, and plan-mode.
12185
+ Returns: loaded-skills hint + newline + 'Skills:\\n<catalog>'
12186
+ Keeps all three modes in sync — change here propagates everywhere.
12187
+ """
12188
+ hint = self._loaded_skills_prompt_hint(for_role=for_role)
12189
+ return f"{hint}\nSkills:\n{self.skills.descriptions()}\n"
11605
12190
 
11606
12191
  def _refresh_runtime_code_reference(self, text: str):
11607
12192
  cb = getattr(self, "reference_prepare_callback", None)
@@ -11663,10 +12248,40 @@ class SessionState:
11663
12248
  header += " [" + ", ".join(tags) + "]"
11664
12249
  return (
11665
12250
  f"{header}:\n"
11666
- "This is the global TF-Graph_IDF RAG knowledge library for imported documents and research material. "
11667
- "It lives at the workspace root, not inside the current session files directory and not inside `.clouds_coder`. "
11668
- "Do not infer knowledge-library readiness by inspecting `session/files`, `uploads`, or `.clouds_coder/long_output`. "
11669
- "Use `query_knowledge_library` to check readiness or retrieve grounded references from the global library."
12251
+ "Global TF-Graph_IDF RAG library for imported documents, PDFs, and research material. "
12252
+ "IMPORTANT: When the task involves a topic you may have documents for research, analysis, "
12253
+ "fact-checking, synthesis FIRST call query_knowledge_library(query='<topic>', top_k=8) "
12254
+ "to retrieve grounded references BEFORE generating your answer. "
12255
+ "Use route='hybrid' for best recall on broad topics; route='fast' for keyword lookups. "
12256
+ "Do not infer readiness from session/files or uploads — query the library directly."
12257
+ )
12258
+
12259
+ def _multimodal_capability_block(self) -> str:
12260
+ """Return a brief system-prompt note about native multimodal capabilities.
12261
+
12262
+ Injected into system prompt so the model knows it can directly analyze
12263
+ images/audio/video rather than falling back to text-only workarounds.
12264
+ Returns empty string when the active model has no multimodal input caps.
12265
+ """
12266
+ try:
12267
+ caps = self._capabilities_from_profile()
12268
+ except Exception:
12269
+ return ""
12270
+ types = []
12271
+ if caps.get("input_image"):
12272
+ types.append("images")
12273
+ if caps.get("input_audio"):
12274
+ types.append("audio")
12275
+ if caps.get("input_video"):
12276
+ types.append("video")
12277
+ if not types:
12278
+ return ""
12279
+ joined = "/".join(types)
12280
+ return (
12281
+ f"MULTIMODAL: This model supports native {joined} analysis. "
12282
+ f"When {joined} are attached or loaded via read_file, analyze them DIRECTLY "
12283
+ "using your built-in perception capabilities — do not describe them as "
12284
+ "inaccessible or attempt text-only workarounds. "
11670
12285
  )
11671
12286
 
11672
12287
  def _code_library_prompt_block(self) -> str:
@@ -11747,8 +12362,14 @@ class SessionState:
11747
12362
  plan_ctx = self._plan_steps_context_for_manager()
11748
12363
  if plan_ctx:
11749
12364
  plan_steps_block = f"{plan_ctx}\n"
12365
+ mm_block = self._multimodal_capability_block()
12366
+ mm_hint = f"{mm_block}\n" if mm_block else ""
11750
12367
  return (
11751
- f"You are a coding agent. Workspace: {self.files_root}. "
12368
+ f"You are a coding agent. Workspace: \"{self.files_root}\" ($SESSION_ROOT). "
12369
+ f"Offline JS libraries root: $JS_LIB_ROOT. "
12370
+ f"Structure: flat .js files at $JS_LIB_ROOT/<name>.min.js; "
12371
+ f"pptxgenjs at $JS_LIB_ROOT/pptxgenjs/dist/pptxgen.cjs.js (CommonJS require) or pptxgen.bundle.js (browser). "
12372
+ f"Do NOT look in node_modules — libs are installed directly under $JS_LIB_ROOT. "
11752
12373
  f"Task level={runtime_level}, mode={runtime_mode}, "
11753
12374
  f"budget={'unlimited' if budget <= 0 else budget}. "
11754
12375
  f"Context limit ~{self.context_token_upper_bound} tokens. "
@@ -11756,6 +12377,7 @@ class SessionState:
11756
12377
  "Use tools to inspect, edit, and execute. "
11757
12378
  "Call finish_current_task when done. "
11758
12379
  f"{skill_hint}"
12380
+ f"{mm_hint}"
11759
12381
  f"{plan_steps_block}"
11760
12382
  f"{html_block}"
11761
12383
  f"{research_block}"
@@ -11911,7 +12533,7 @@ class SessionState:
11911
12533
  o.pop("cons", None)
11912
12534
  o.pop("risk", None)
11913
12535
  elif tier >= 3:
11914
- # Minimal: only phase, chosen, steps
12536
+ # Minimal: only phase, chosen, steps — but preserve project_todos
11915
12537
  phase = plan.get("phase", "")
11916
12538
  chosen = plan.get("chosen", "")
11917
12539
  steps = plan.get("steps", [])
@@ -11927,7 +12549,10 @@ class SessionState:
11927
12549
  o for o in options if isinstance(o, dict) and o.get("id") == chosen_id
11928
12550
  ]
11929
12551
  if tier >= 3:
11930
- self.runtime_plan_proposal = {}
12552
+ # Only clear proposal if plan is fully done
12553
+ plan_phase = str(bb.get("plan", {}).get("phase", ""))
12554
+ if plan_phase not in ("executing", "awaiting_choice"):
12555
+ self.runtime_plan_proposal = {}
11931
12556
 
11932
12557
  def _apply_auto_compact_if_needed(self, reason: str = "auto") -> bool:
11933
12558
  metrics = self._context_budget_metrics()
@@ -12329,16 +12954,13 @@ class SessionState:
12329
12954
  meta = {"filtered": False, "reason": "", "original_chars": len(raw)}
12330
12955
  if not clean:
12331
12956
  return raw, meta
12332
- if (
12333
- not tool_calls
12334
- and len(clean) >= int(RAW_TOOLCALL_TEXT_FILTER_THRESHOLD)
12335
- and self._looks_like_raw_toolcall_blob(clean)
12336
- ):
12957
+ # Filter raw <tool_call> XML regardless of size — any size is invalid display content
12958
+ if not tool_calls and self._looks_like_raw_toolcall_blob(clean):
12337
12959
  note = (
12338
- "[toolcall payload omitted: detected oversized inline <toolcall> text, "
12339
- "likely truncated. Please regenerate a compact structured tool call.]"
12960
+ "[toolcall payload omitted: detected inline <toolcall> text. "
12961
+ "Please regenerate a compact structured tool call.]"
12340
12962
  )
12341
- meta.update({"filtered": True, "reason": "oversized_raw_toolcall"})
12963
+ meta.update({"filtered": True, "reason": "raw_toolcall"})
12342
12964
  return note, meta
12343
12965
  if len(raw) > int(ASSISTANT_TEXT_PERSIST_MAX_CHARS):
12344
12966
  clipped = trim(raw, int(ASSISTANT_TEXT_PERSIST_MAX_CHARS))
@@ -13540,6 +14162,11 @@ class SessionState:
13540
14162
  if low.startswith("/workspace/"):
13541
14163
  rel = raw[len("/workspace/") :].lstrip("/")
13542
14164
  return rel or "."
14165
+ if low in {"/js_lib", "/js_lib/"}:
14166
+ return ".__js_lib__"
14167
+ if low.startswith("/js_lib/"):
14168
+ rel = raw[len("/js_lib/") :].lstrip("/")
14169
+ return f".__js_lib__/{rel}" if rel else ".__js_lib__"
13543
14170
  if low in {SKILLS_VIRTUAL_PREFIX, f"{SKILLS_VIRTUAL_PREFIX}/"}:
13544
14171
  return ".__skills__"
13545
14172
  if low.startswith(f"{SKILLS_VIRTUAL_PREFIX}/"):
@@ -13563,9 +14190,10 @@ class SessionState:
13563
14190
  return ""
13564
14191
  low = txt.lower()
13565
14192
  if (
13566
- low in {"/workspace", "/workspace/", SKILLS_VIRTUAL_PREFIX, f"{SKILLS_VIRTUAL_PREFIX}/"}
14193
+ low in {"/workspace", "/workspace/", SKILLS_VIRTUAL_PREFIX, f"{SKILLS_VIRTUAL_PREFIX}/", "/js_lib", "/js_lib/"}
13567
14194
  or low.startswith("/workspace/")
13568
14195
  or low.startswith(f"{SKILLS_VIRTUAL_PREFIX}/")
14196
+ or low.startswith("/js_lib/")
13569
14197
  ):
13570
14198
  return ""
13571
14199
  return (
@@ -13578,6 +14206,9 @@ class SessionState:
13578
14206
  if normalized == ".__skills__" or normalized.startswith(".__skills__/"):
13579
14207
  rel = normalized[len(".__skills__") :].lstrip("/")
13580
14208
  return safe_path(rel or ".", self.skills.skills_root)
14209
+ if normalized == ".__js_lib__" or normalized.startswith(".__js_lib__/"):
14210
+ rel = normalized[len(".__js_lib__") :].lstrip("/")
14211
+ return safe_path(rel or ".", self.js_lib_root)
13581
14212
  return safe_path(normalized, self.files_root)
13582
14213
 
13583
14214
  def _session_rel(self, path: Path) -> str:
@@ -13585,6 +14216,13 @@ class SessionState:
13585
14216
  root = self.files_root.resolve()
13586
14217
  if target.is_relative_to(root):
13587
14218
  return target.relative_to(root).as_posix()
14219
+ try:
14220
+ js_lib_root = self.js_lib_root.resolve()
14221
+ if target.is_relative_to(js_lib_root):
14222
+ rel = target.relative_to(js_lib_root).as_posix()
14223
+ return f"/js_lib/{rel}".replace("//", "/")
14224
+ except Exception:
14225
+ pass
13588
14226
  try:
13589
14227
  skills_root = self.skills.skills_root.resolve()
13590
14228
  if target.is_relative_to(skills_root):
@@ -13962,8 +14600,20 @@ class SessionState:
13962
14600
  }
13963
14601
 
13964
14602
  def _safe_upload_name(self, filename: str) -> str:
14603
+ # Use Path().name to strip any directory component first.
13965
14604
  raw = Path(str(filename or "upload.bin")).name
13966
- safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw).strip("._")
14605
+ # Only remove characters that are genuinely illegal or dangerous on
14606
+ # filesystems (path separators, null byte, control chars, Windows
14607
+ # reserved chars). Unicode letters/CJK/etc. are left untouched so
14608
+ # filenames like "我的数据.xlsx" remain readable.
14609
+ safe = re.sub(r'[/\\\x00-\x1f\x7f:*?"<>|]', "_", raw)
14610
+ safe = safe.strip(". ") # avoid hidden files (leading dot) and trailing issues
14611
+ # Enforce a byte-length ceiling safe across all filesystems (255 bytes max).
14612
+ # Trim the stem if needed while preserving the extension.
14613
+ if len(safe.encode("utf-8", errors="replace")) > 240:
14614
+ ext = Path(safe).suffix # e.g. ".xlsx"
14615
+ stem = safe[: max(1, 200 - len(ext))]
14616
+ safe = stem + ext
13967
14617
  return safe or f"upload_{int(now_ts())}.bin"
13968
14618
 
13969
14619
  def _decode_text_bytes(self, data: bytes) -> str:
@@ -15964,15 +16614,22 @@ class SessionState:
15964
16614
  skills_root = str(self.skills.skills_root.resolve())
15965
16615
  except Exception:
15966
16616
  skills_root = str(self.skills.skills_root)
16617
+ try:
16618
+ js_lib_root = str(self.js_lib_root.resolve())
16619
+ except Exception:
16620
+ js_lib_root = str(self.js_lib_root)
15967
16621
  mappings = [
15968
16622
  ("/workspace", workspace_root),
15969
16623
  (SKILLS_VIRTUAL_PREFIX, skills_root),
16624
+ ("/js_lib", js_lib_root),
15970
16625
  ]
15971
16626
  # Auto-quote raw absolute paths that contain spaces (prevents word-splitting)
15972
16627
  if " " in workspace_root and workspace_root not in ("/workspace",):
15973
16628
  mappings.append((workspace_root, workspace_root))
15974
16629
  if " " in skills_root and skills_root != SKILLS_VIRTUAL_PREFIX:
15975
16630
  mappings.append((skills_root, skills_root))
16631
+ if " " in js_lib_root and js_lib_root != "/js_lib":
16632
+ mappings.append((js_lib_root, js_lib_root))
15976
16633
  out: list[str] = []
15977
16634
  mode = "plain"
15978
16635
  i = 0
@@ -16313,6 +16970,7 @@ class SessionState:
16313
16970
  proc_env["SESSION_FILES_ROOT"] = str(self.files_root)
16314
16971
  proc_env["SKILLS_ROOT"] = str(self.skills.skills_root)
16315
16972
  proc_env["CLOUDS_CODER_ROOT"] = str(self.root)
16973
+ proc_env["JS_LIB_ROOT"] = str(self.js_lib_root)
16316
16974
  popen_kwargs = {
16317
16975
  "shell": True,
16318
16976
  "cwd": cwd,
@@ -16430,10 +17088,31 @@ class SessionState:
16430
17088
  def _run_bash(self, command: str) -> str:
16431
17089
  return self._run_shell_meta(command, self.files_root, 120)["output"]
16432
17090
 
17091
+ def _fuzzy_resolve_path(self, fp: Path) -> Path:
17092
+ """If fp doesn't exist, try stripping spaces from the filename to find a close match.
17093
+ Handles the common model error of hallucinating spaces in Chinese/mixed filenames.
17094
+ Returns the resolved Path if found, otherwise the original fp unchanged."""
17095
+ if fp.exists():
17096
+ return fp
17097
+ stripped = fp.name.replace(" ", "")
17098
+ if stripped != fp.name:
17099
+ candidate = fp.parent / stripped
17100
+ if candidate.exists():
17101
+ return candidate
17102
+ try:
17103
+ query = fp.name.replace(" ", "").lower()
17104
+ for sibling in fp.parent.iterdir():
17105
+ if sibling.name.replace(" ", "").lower() == query:
17106
+ return sibling
17107
+ except Exception:
17108
+ pass
17109
+ return fp
17110
+
16433
17111
  def _run_read(self, path: str, limit: int | None = None, offset: int | None = None) -> str:
16434
17112
  try:
16435
17113
  rel = self._normalize_tool_path_text(path)
16436
- fp = self._session_path(rel)
17114
+ fp = self._fuzzy_resolve_path(self._session_path(rel))
17115
+ rel = str(fp.relative_to(self.files_root)) if fp.is_relative_to(self.files_root) else rel
16437
17116
  # Multimodal: detect image/audio/video files and handle natively
16438
17117
  ext = fp.suffix.lower() if fp.suffix else ""
16439
17118
  if ext in IMAGE_EXTS:
@@ -16680,7 +17359,8 @@ class SessionState:
16680
17359
  def _run_write(self, path: str, content: str) -> str:
16681
17360
  try:
16682
17361
  rel = self._normalize_tool_path_text(path)
16683
- fp = self._session_path(rel)
17362
+ fp = self._fuzzy_resolve_path(self._session_path(rel))
17363
+ rel = str(fp.relative_to(self.files_root)) if fp.is_relative_to(self.files_root) else rel
16684
17364
  fp.parent.mkdir(parents=True, exist_ok=True)
16685
17365
  fp.write_text(content, encoding="utf-8")
16686
17366
  return f"Wrote {len(content)} bytes to {rel}"
@@ -16690,7 +17370,8 @@ class SessionState:
16690
17370
  def _run_edit(self, path: str, old_text: str, new_text: str) -> str:
16691
17371
  try:
16692
17372
  rel = self._normalize_tool_path_text(path)
16693
- fp = self._session_path(rel)
17373
+ fp = self._fuzzy_resolve_path(self._session_path(rel))
17374
+ rel = str(fp.relative_to(self.files_root)) if fp.is_relative_to(self.files_root) else rel
16694
17375
  content = fp.read_text(encoding="utf-8")
16695
17376
  if old_text not in content:
16696
17377
  diag = self._edit_mismatch_diagnostic(content, old_text)
@@ -18445,7 +19126,7 @@ class SessionState:
18445
19126
  board["project_todos"] = []
18446
19127
  else:
18447
19128
  clean_todos = []
18448
- for pt in bb_src_todos[:20]:
19129
+ for pt in bb_src_todos[:40]:
18449
19130
  if not isinstance(pt, dict):
18450
19131
  continue
18451
19132
  clean_todos.append({
@@ -18514,6 +19195,15 @@ class SessionState:
18514
19195
  goal_preview = trim(str(src.get("loaded_skills_goal_preview", "") or ""), 240)
18515
19196
  if goal_preview:
18516
19197
  board["loaded_skills_goal_preview"] = goal_preview
19198
+ # Preserve step_files registry across normalization
19199
+ raw_step_files = src.get("step_files")
19200
+ if isinstance(raw_step_files, dict):
19201
+ clean_sf: dict[str, list] = {}
19202
+ for sf_key, sf_entries in raw_step_files.items():
19203
+ if isinstance(sf_entries, list):
19204
+ clean_sf[str(sf_key)] = sf_entries[-30:]
19205
+ if clean_sf:
19206
+ board["step_files"] = clean_sf
18517
19207
  return board
18518
19208
 
18519
19209
  def _normalize_failure_ledger(self, raw: dict) -> dict:
@@ -18941,9 +19631,17 @@ class SessionState:
18941
19631
  self.blackboard["loaded_skills"] = preserved_skills
18942
19632
  self.blackboard["loaded_skills_goal_sig"] = preserved_skills_sig
18943
19633
  self.blackboard["loaded_skills_goal_preview"] = trim(str(goal or ""), 240)
18944
- # Restore plan state if plan is in executing phase
18945
- if isinstance(preserved_plan, dict) and preserved_plan.get("phase") == "executing":
18946
- self.blackboard["plan"] = preserved_plan
19634
+ # Restore plan state if plan is active (any phase) or todos have pending work
19635
+ has_active_plan = (
19636
+ isinstance(preserved_plan, dict)
19637
+ and preserved_plan.get("phase") in ("executing", "research", "synthesis", "awaiting_choice")
19638
+ )
19639
+ has_active_todos = isinstance(preserved_todos, list) and any(
19640
+ t.get("status") != "completed" for t in preserved_todos if isinstance(t, dict)
19641
+ )
19642
+ if has_active_plan or has_active_todos:
19643
+ if isinstance(preserved_plan, dict):
19644
+ self.blackboard["plan"] = preserved_plan
18947
19645
  if isinstance(preserved_todos, list) and preserved_todos:
18948
19646
  self.blackboard["project_todos"] = preserved_todos
18949
19647
  if preserved_cursor is not None:
@@ -19440,35 +20138,36 @@ class SessionState:
19440
20138
  if not current:
19441
20139
  return False
19442
20140
  text = (str(instruction or "") + " " + str(reason or "")).lower()
19443
- # Patterns that indicate step completion
20141
+ # Patterns that indicate step completion — only BACKWARD-looking signals
20142
+ # (agent/manager explicitly says a step is done, NOT forward-looking dispatch instructions)
19444
20143
  step_done_patterns = (
19445
20144
  "审查通过", "通过审查", "已通过", "已完成", "完成了",
19446
- "进入 step", "进入step", "enter step", "move to step",
19447
20145
  "step completed", "step done", "step passed",
19448
- "现在进入", "开始 step", "开始step",
19449
20146
  "阶段完成", "阶段通过", "phase complete",
19450
- # extended: manager says "now do step X.Y" implying prior step is done
19451
- "现在执行步骤", "执行步骤 1.2", "执行步骤 1.3", "执行步骤 2",
19452
- "进入下一步", "next step", "proceed to step",
19453
- "步骤 1.1 已完成", "步骤 1.2 已完成",
20147
+ "步骤 1.1 已完成", "步骤 1.2 已完成", "步骤 1.3 已完成",
20148
+ "步骤 1.4 已完成", "步骤 1.5 已完成",
19454
20149
  )
19455
- # Also detect "Step N 通过" or "进入 Step N+1" patterns
20150
+ # NOTE: intentionally excluded forward-looking dispatch patterns:
20151
+ # "现在执行步骤", "执行步骤 1.2", "执行步骤 1.3", "进入下一步", "next step",
20152
+ # "proceed to step", "进入 step", "开始 step" — these are manager dispatch
20153
+ # instructions, NOT evidence that the current step was completed.
19456
20154
  import re
19457
20155
  current_idx = int(current.get("plan_step_index", 0) or 0)
19458
- # "Step 2 已通过" / "Step 2 完成" / "进入 Step 3"
20156
+ # Only advance when an agent explicitly says "进入 Step N" where N > current+1
20157
+ # (skipping ahead), NOT when manager dispatches the very next step.
19459
20158
  next_step_pattern = re.search(
19460
20159
  r'(?:进入|enter|move\s+to|start|proceed\s+to)\s*(?:step\s*)?(\d+)',
19461
20160
  text, re.IGNORECASE
19462
20161
  )
19463
20162
  if next_step_pattern:
19464
20163
  mentioned_step = int(next_step_pattern.group(1))
19465
- if mentioned_step > current_idx + 1:
20164
+ if mentioned_step > current_idx + 2: # must skip at least 2 ahead to be meaningful
19466
20165
  return True
19467
- # Pattern: "继续步骤 1.2" / "完成步骤 1.1,开始 1.2"
19468
- step_ref = re.search(r'步骤\s*1\.(\d+)', text)
20166
+ # "完成步骤 1.1,开始 1.2" — only if explicitly marking current step done
20167
+ step_ref = re.search(r'(?:完成|finished|done)\s*步骤\s*1\.(\d+)', text)
19469
20168
  if step_ref:
19470
20169
  ref_sub = int(step_ref.group(1))
19471
- if ref_sub > (current_idx + 1):
20170
+ if ref_sub == current_idx + 1: # explicitly says current step (1-based) is done
19472
20171
  return True
19473
20172
  return any(pat in text for pat in step_done_patterns)
19474
20173
 
@@ -19505,14 +20204,19 @@ class SessionState:
19505
20204
  })
19506
20205
  self.blackboard = bb
19507
20206
  self._blackboard_touch()
19508
- # 步骤推进时清除 in_progress/pending worker 子任务,防止跨步骤堆积
19509
- # 保留 completed 的 worker 项,让 UI 保持已完成记录可见
20207
+ # Persist step status change to plan.md
20208
+ try:
20209
+ self._update_plan_file_step_status()
20210
+ except Exception:
20211
+ pass # Plan file update is best-effort
20212
+ # 步骤推进时按 parent_step_id 清除对应步骤的 worker 子任务
20213
+ # 保留 completed 的 worker 项和不属于当前步骤的 worker 项
20214
+ completed_step_id = str(current.get("id", "") or "")
19510
20215
  try:
19511
20216
  _snap = self.todo.snapshot()
19512
- _worker_owners = {"developer", "explorer", "reviewer"}
19513
20217
  _clean = [
19514
20218
  r for r in _snap
19515
- if str(r.get("owner", "") or "").lower() not in _worker_owners
20219
+ if str(r.get("parent_step_id", "") or "") != completed_step_id
19516
20220
  or str(r.get("status", "") or "").lower() == "completed"
19517
20221
  ]
19518
20222
  if len(_clean) < len(_snap):
@@ -19524,11 +20228,87 @@ class SessionState:
19524
20228
  self._sync_todos_from_blackboard(reason=f"plan-step-advanced:{cursor + 1}", board=bb)
19525
20229
  if next_step:
19526
20230
  try:
19527
- self._refresh_loaded_skills_for_execution_focus(trigger="plan-step-advanced")
20231
+ pass # Skills are loaded on-demand by the model via load_skill
19528
20232
  except Exception:
19529
20233
  pass
19530
20234
  return True
19531
20235
 
20236
+ def _post_execution_plan_step_check(self, route: dict, worker_step: dict):
20237
+ """After worker execution, check if current plan step should advance based on evidence."""
20238
+ bb = self._ensure_blackboard()
20239
+ current = next(
20240
+ (t for t in bb.get("project_todos", [])
20241
+ if t.get("category") == "plan_step" and t.get("status") == "in_progress"),
20242
+ None,
20243
+ )
20244
+ if not current:
20245
+ return
20246
+ # 1. Manager explicitly requested advancement
20247
+ manager_requested = bool(route.get("advance_plan_step_requested", False))
20248
+ # 2. Worker produced concrete tool outputs
20249
+ worker_produced_output = self._worker_step_has_evidence(worker_step)
20250
+ # 3. All subtasks for this step are completed
20251
+ subtasks_all_done = self._step_subtasks_all_completed(current)
20252
+ # Advance only when evidence confirms step completion:
20253
+ # - Manager requested AND worker produced output, OR
20254
+ # - All subtasks completed AND worker produced output
20255
+ has_strong_evidence = worker_produced_output and (
20256
+ manager_requested or subtasks_all_done
20257
+ )
20258
+ if has_strong_evidence:
20259
+ evidence = self._collect_step_evidence(current, worker_step)
20260
+ self._advance_plan_step(
20261
+ evidence=evidence,
20262
+ actor=str(route.get("target", "developer") or "developer"),
20263
+ )
20264
+
20265
+ def _worker_step_has_evidence(self, step: dict) -> bool:
20266
+ """Check if worker step produced concrete tool outputs."""
20267
+ results = step.get("tool_results", []) or []
20268
+ return any(
20269
+ r.get("ok", False) and str(r.get("name", "")) in (
20270
+ "write_file", "edit_file", "bash", "read_file",
20271
+ "write_to_blackboard", "finish_current_task",
20272
+ )
20273
+ for r in results
20274
+ if isinstance(r, dict)
20275
+ )
20276
+
20277
+ def _step_subtasks_all_completed(self, plan_step: dict) -> bool:
20278
+ """Check if all worker subtasks linked to this plan step are completed."""
20279
+ step_id = str(plan_step.get("id", "") or "")
20280
+ if not step_id:
20281
+ return False
20282
+ snap = self.todo.snapshot()
20283
+ worker_owners = {"developer", "explorer", "reviewer"}
20284
+ worker_items = [
20285
+ r for r in snap
20286
+ if str(r.get("owner", "") or "").lower() in worker_owners
20287
+ and str(r.get("parent_step_id", "") or "") == step_id
20288
+ ]
20289
+ if not worker_items:
20290
+ return False
20291
+ return all(str(r.get("status", "")).lower() == "completed" for r in worker_items)
20292
+
20293
+ def _collect_step_evidence(self, plan_step: dict, worker_step: dict) -> str:
20294
+ """Collect evidence summary from worker step for plan step completion."""
20295
+ parts = []
20296
+ results = worker_step.get("tool_results", []) or []
20297
+ for r in results:
20298
+ if not isinstance(r, dict) or not r.get("ok", False):
20299
+ continue
20300
+ name = str(r.get("name", ""))
20301
+ if name in ("write_file", "edit_file"):
20302
+ path = str(r.get("args", {}).get("path", "") or "")
20303
+ parts.append(f"{name}: {path}")
20304
+ elif name == "bash":
20305
+ cmd = trim(str(r.get("args", {}).get("command", "") or ""), 80)
20306
+ parts.append(f"bash: {cmd}")
20307
+ elif name == "read_file":
20308
+ path = str(r.get("args", {}).get("path", "") or "")
20309
+ parts.append(f"read: {path}")
20310
+ return trim("; ".join(parts) or "post-execution evidence", 200)
20311
+
19532
20312
  def _single_agent_plan_step_check(self, tool_results: list[dict]):
19533
20313
  """In single-agent mode, check if current plan step should be advanced based on tool results."""
19534
20314
  bb = self._ensure_blackboard()
@@ -19602,6 +20382,7 @@ class SessionState:
19602
20382
  _step_label = f"Step {_step_idx}" + (f"/{_total}" if _total else "")
19603
20383
  _hint = (
19604
20384
  f"[plan-step-advance] Previous step completed. Now at {_step_label}: {_step_text}\n"
20385
+ f"Read updated plan: read_file {PLAN_FILE_RELATIVE_PATH}\n"
19605
20386
  "Call TodoWrite to set your task breakdown for this step "
19606
20387
  "(3-5 subtask items, one marked in_progress) before proceeding."
19607
20388
  )
@@ -19659,8 +20440,13 @@ class SessionState:
19659
20440
  if is_system_key or owner == "manager":
19660
20441
  continue
19661
20442
  non_system_rows.append(dict(row))
19662
- remaining_cap = max(0, 20 - len(system_rows) - len(worker_rows))
19663
- merged = list(system_rows) + worker_rows + non_system_rows[:remaining_cap]
20443
+ # Smart trim: keep all active (in_progress/pending) system rows,
20444
+ # but only recent 3 completed system rows to save capacity for worker subtasks
20445
+ active_system = [r for r in system_rows if r.get("status") != "completed"]
20446
+ completed_system = [r for r in system_rows if r.get("status") == "completed"]
20447
+ trimmed_system = active_system + completed_system[-3:]
20448
+ remaining_cap = max(0, 40 - len(trimmed_system) - len(worker_rows))
20449
+ merged = list(trimmed_system) + worker_rows + non_system_rows[:remaining_cap]
19664
20450
  try:
19665
20451
  todo_out = self.todo.update(merged)
19666
20452
  except Exception:
@@ -20606,7 +21392,7 @@ class SessionState:
20606
21392
  f"budget={int(self.runtime_round_budget or 0)}.\n\n"
20607
21393
  f"{dims_ctx}"
20608
21394
  f"{skills_ctx}"
20609
- f"Workspace root: {self.files_root}\n"
21395
+ f"Workspace root: \"{self.files_root}\" ($SESSION_ROOT)\n"
20610
21396
  "Infer scale_preference by semantics (fast/balanced/thorough). "
20611
21397
  "When user preference is clear, prioritize it over your default plan. "
20612
21398
  "Remember: budget controls internal thought depth/round compactness, not early stop messaging. "
@@ -20815,7 +21601,8 @@ class SessionState:
20815
21601
  todos = bb.get("project_todos", [])
20816
21602
  if not todos or not any(t.get("category") == "plan_step" for t in todos):
20817
21603
  return ""
20818
- lines = ["APPROVED PLAN STEPS:"]
21604
+ lines = [f"PLAN FILE: {PLAN_FILE_RELATIVE_PATH} (read_file for full plan with live status)",
21605
+ "APPROVED PLAN STEPS:"]
20819
21606
  for t in todos:
20820
21607
  if t.get("category") != "plan_step":
20821
21608
  continue
@@ -20827,10 +21614,14 @@ class SessionState:
20827
21614
  lines.append(f" {mark} Step {idx}: {trim(str(t.get('content', '') or ''), 160)}{phase_tag}")
20828
21615
  lines.append("Execute steps IN ORDER. Do NOT skip ahead. Mark current step done before advancing. ")
20829
21616
  lines.append(
20830
- "STEP COMPLETION RULE: Set advance_plan_step=true when the worker has provided concrete evidence "
20831
- "that the current step is done e.g., research_notes populated for a read/analyze step, "
20832
- "code_artifacts created for an implement step, or all worker TodoWrite subtasks marked completed. "
20833
- "Do NOT keep re-delegating the same step once this evidence exists. "
21617
+ "STEP COMPLETION RULE: Set advance_plan_step=true ONLY when:\n"
21618
+ " 1. The worker has ALREADY executed tools and produced verifiable output "
21619
+ "(NOT when dispatching the work for the first time), AND\n"
21620
+ " 2. At least ONE of: research_notes populated, code_artifacts created, "
21621
+ "bash execution succeeded, or all worker TodoWrite subtasks (with parent_step_id) completed.\n"
21622
+ " NEVER set advance_plan_step=true in the SAME delegation that assigns the work. "
21623
+ "The step must have been executed FIRST.\n"
21624
+ "COMPLEXITY LOCK: Do NOT change complexity or task_level below the plan-approved levels. "
20834
21625
  )
20835
21626
  lines.append("MANDATORY: Your delegation instruction MUST reference the current plan step. "
20836
21627
  "Do NOT reinterpret or replace plan steps with your own objectives. ")
@@ -20859,12 +21650,15 @@ class SessionState:
20859
21650
  def _plan_step_phase_hint(self, step_content: str) -> str:
20860
21651
  """Infer the task phase from a plan step's content."""
20861
21652
  c = str(step_content or "").lower()
21653
+ # implement keywords take priority — if step produces output, it's implement not design/research
21654
+ if any(kw in c for kw in ("实现", "编写", "创建", "开发", "绘制", "生成", "写入",
21655
+ "implement", "write", "create", "build", "develop", "code",
21656
+ "generate", "draw", "scaffold", "mkdir", ".f90", ".py", ".cpp", ".md")):
21657
+ return "implement"
20862
21658
  if any(kw in c for kw in ("研究", "分析", "调研", "探索", "research", "analyze", "investigate", "explore", "inspect")):
20863
21659
  return "research"
20864
21660
  if any(kw in c for kw in ("设计", "架构", "规划", "design", "architect", "plan", "interface", "接口")):
20865
21661
  return "design"
20866
- if any(kw in c for kw in ("实现", "编写", "创建", "开发", "implement", "write", "create", "build", "develop", "code")):
20867
- return "implement"
20868
21662
  if any(kw in c for kw in ("测试", "验证", "检查", "test", "verify", "check", "validate", "compile")):
20869
21663
  return "test"
20870
21664
  if any(kw in c for kw in ("审查", "评审", "review", "audit", "inspect code")):
@@ -20927,11 +21721,24 @@ class SessionState:
20927
21721
  completed_w = [r for r in worker_todos if str(r.get("status", "")).lower() == "completed"]
20928
21722
  pending_w = [r for r in worker_todos if str(r.get("status", "")).lower() not in ("completed",)]
20929
21723
  if not pending_w and completed_w:
20930
- worker_hint = (
20931
- f"Worker subtasks: all {len(completed_w)} completed "
20932
- f"({', '.join(trim(str(r.get('content','') or ''), 40) for r in completed_w[:3])}). "
20933
- "→ Worker has finished. Set advance_plan_step=true NOW. "
20934
- )
21724
+ # Verify actual work evidence before suggesting advance
21725
+ bb_now = self._ensure_blackboard()
21726
+ has_artifacts = bool(bb_now.get("code_artifacts"))
21727
+ has_research = bool(bb_now.get("research_notes"))
21728
+ has_shell_output = bool(bb_now.get("execution_logs"))
21729
+ has_evidence = has_artifacts or has_research or has_shell_output
21730
+ if has_evidence:
21731
+ worker_hint = (
21732
+ f"Worker subtasks: all {len(completed_w)} completed "
21733
+ f"({', '.join(trim(str(r.get('content','') or ''), 40) for r in completed_w[:3])}). "
21734
+ "Blackboard has concrete outputs. \u2192 Set advance_plan_step=true NOW. "
21735
+ )
21736
+ else:
21737
+ worker_hint = (
21738
+ f"Worker subtasks: all {len(completed_w)} marked completed "
21739
+ "but blackboard has NO concrete outputs (no code_artifacts, research_notes, or execution_logs). "
21740
+ "\u2192 Do NOT advance. Re-delegate to verify actual work was done. "
21741
+ )
20935
21742
  elif completed_w or pending_w:
20936
21743
  worker_hint = (
20937
21744
  f"Worker subtasks: {len(completed_w)} done, {len(pending_w)} pending. "
@@ -22185,7 +22992,7 @@ class SessionState:
22185
22992
  prompt = (
22186
22993
  "Read the blackboard and delegate one next short timeslice. "
22187
22994
  "Return only one route_to_next_agent call.\n\n"
22188
- f"{self._blackboard_read_state_markdown(max_items=6)}"
22995
+ f"{self._blackboard_read_state_markdown(max_items=10)}"
22189
22996
  )
22190
22997
  self._append_manager_context({"role": "user", "content": prompt, "ts": now_ts()})
22191
22998
  self._microcompact_agent_messages(self.manager_context)
@@ -22384,22 +23191,12 @@ class SessionState:
22384
23191
  "round_budget": int(round_budget),
22385
23192
  "remaining_rounds": int(remaining_rounds),
22386
23193
  }
22387
- # advance_plan_step: 当前 plan step 完成,推进到下一步
22388
- should_advance = _to_bool_like(route.get("advance_plan_step", False), default=False)
22389
- # Auto-detect step advancement from instruction semantics
22390
- if not should_advance:
22391
- should_advance = self._instruction_implies_step_advance(
22392
- str(route.get("instruction", "") or ""),
22393
- str(route.get("reason", "") or ""),
22394
- )
22395
- if should_advance:
22396
- self._advance_plan_step(
22397
- evidence=trim(str(route.get("instruction", "") or ""), 200),
22398
- actor=str(route.get("target", "developer") or "developer"),
22399
- )
22400
- # CRITICAL: re-anchor board to the updated blackboard so the
22401
- # self.blackboard = board at line ~22388 does NOT overwrite the advance.
22402
- board = self.blackboard
23194
+ # advance_plan_step: REMOVED pre-execution advancement.
23195
+ # Step advancement now happens AFTER worker execution via _post_execution_plan_step_check.
23196
+ # Store the manager's advance request flag for post-execution validation.
23197
+ route_row["advance_plan_step_requested"] = _to_bool_like(
23198
+ route.get("advance_plan_step", False), default=False
23199
+ )
22403
23200
  self.manager_routes.append(route_row)
22404
23201
  self.manager_routes = self.manager_routes[-240:]
22405
23202
  # Failure ledger: persist route and record delegation
@@ -22416,7 +23213,11 @@ class SessionState:
22416
23213
  self.blackboard = board
22417
23214
  self._ledger_record_delegation(target, instruction)
22418
23215
  profile = self._ensure_blackboard_task_profile(board)
22419
- profile["task_level"] = int(task_level)
23216
+ # Complexity / task_level floor protection: prevent manager downgrade during plan execution
23217
+ effective_level = int(task_level)
23218
+ if int(self.runtime_task_level_floor or 0) > 0:
23219
+ effective_level = max(effective_level, int(self.runtime_task_level_floor))
23220
+ profile["task_level"] = effective_level
22420
23221
  profile["execution_mode"] = execution_mode
22421
23222
  profile["participants"] = list(participants)
22422
23223
  profile["assigned_expert"] = assigned_expert
@@ -22426,6 +23227,9 @@ class SessionState:
22426
23227
  if task_type in TASK_PROFILE_TYPES:
22427
23228
  profile["task_type"] = task_type
22428
23229
  if complexity in TASK_COMPLEXITY_LEVELS:
23230
+ # Floor protection: if plan mode set a floor, do not allow downgrade
23231
+ if self.runtime_complexity_floor == "complex" and complexity == "simple":
23232
+ complexity = "complex"
22429
23233
  profile["complexity"] = complexity
22430
23234
  profile["scale_preference"] = scale_preference if scale_preference in TASK_SCALE_PREFERENCES else "balanced"
22431
23235
  if objective:
@@ -22633,7 +23437,7 @@ class SessionState:
22633
23437
  "Do NOT repeat previous failed fixes.\n"
22634
23438
  "</compile-error-context>\n"
22635
23439
  )
22636
- board_md = self._blackboard_read_state_markdown(max_items=5)
23440
+ board_md = self._blackboard_read_state_markdown(max_items=10)
22637
23441
  # Include loaded skills hint in delegation
22638
23442
  loaded_skills_note = self._loaded_skills_prompt_hint(for_role=role_key)
22639
23443
  # Include current plan step in delegation so agents stay on track
@@ -22653,11 +23457,46 @@ class SessionState:
22653
23457
  # developer 被调用时有活跃 plan step,要求在开始前调用 TodoWrite 刷新子任务
22654
23458
  todo_update_note = ""
22655
23459
  if role_key == "developer" and current_plan_step_note:
23460
+ # Extract step_id from the matched plan step for parent_step_id linkage
23461
+ _active_step_id = ""
23462
+ if isinstance(plan_todos, list):
23463
+ for _pt in plan_todos:
23464
+ if isinstance(_pt, dict) and _pt.get("category") == "plan_step" and _pt.get("status") == "in_progress":
23465
+ _active_step_id = str(_pt.get("id", "") or "")
23466
+ break
22656
23467
  todo_update_note = (
22657
- "TODO UPDATE: Call TodoWrite at the start of your work to set your task breakdown "
22658
- "for this step (3-5 subtask items, one marked in_progress). "
22659
- "Mark each subtask completed as you finish it.\n"
22660
- )
23468
+ f"TODO UPDATE: At the START of your work, call TodoWrite to set subtasks for this step.\n"
23469
+ f"Each subtask MUST include parent_step_id='{_active_step_id}' to link it to this plan step.\n"
23470
+ f"Format: 3-5 items, one marked in_progress, others pending.\n"
23471
+ f"Mark each subtask completed as you finish it. When ALL subtasks are done, the step auto-advances.\n"
23472
+ )
23473
+ # Build step_files context note for cross-agent file visibility
23474
+ step_files_note = ""
23475
+ if current_plan_step_note:
23476
+ try:
23477
+ _sf_bb = self._ensure_blackboard()
23478
+ _sf_data = _sf_bb.get("step_files", {})
23479
+ _sf_step_id = ""
23480
+ if isinstance(plan_todos, list):
23481
+ for _sfpt in plan_todos:
23482
+ if isinstance(_sfpt, dict) and _sfpt.get("category") == "plan_step" and _sfpt.get("status") == "in_progress":
23483
+ _sf_step_id = str(_sfpt.get("id", "") or "")
23484
+ break
23485
+ if _sf_step_id and isinstance(_sf_data, dict):
23486
+ _sf_entries = _sf_data.get(_sf_step_id, [])
23487
+ if _sf_entries:
23488
+ _sf_paths = list(dict.fromkeys(
23489
+ str(e.get("path", "") or "")
23490
+ for e in _sf_entries[-20:]
23491
+ if isinstance(e, dict) and e.get("path")
23492
+ ))
23493
+ if _sf_paths:
23494
+ step_files_note = (
23495
+ "FILES ACCESSED IN THIS STEP:\n"
23496
+ + "\n".join(f"- {p}" for p in _sf_paths[:15]) + "\n"
23497
+ )
23498
+ except Exception:
23499
+ pass
22661
23500
  payload = (
22662
23501
  "<manager-delegate>\n"
22663
23502
  f"target={role_key}\n"
@@ -22672,6 +23511,7 @@ class SessionState:
22672
23511
  f"{loaded_skills_note}"
22673
23512
  f"{current_plan_step_note}"
22674
23513
  f"{todo_update_note}"
23514
+ f"{step_files_note}"
22675
23515
  f"{error_section}"
22676
23516
  "</manager-delegate>\n"
22677
23517
  "<blackboard-state>\n"
@@ -22779,6 +23619,32 @@ class SessionState:
22779
23619
  role_key,
22780
23620
  f"tool_error {name}: {output}",
22781
23621
  )
23622
+ # Track file operations in step_files registry for cross-agent context
23623
+ if name in ("read_file", "write_file", "edit_file"):
23624
+ file_path = trim(str(args.get("path", "") or "").strip(), 240)
23625
+ if file_path:
23626
+ try:
23627
+ bb = self._ensure_blackboard()
23628
+ current_step = next(
23629
+ (t for t in bb.get("project_todos", [])
23630
+ if t.get("category") == "plan_step" and t.get("status") == "in_progress"),
23631
+ None,
23632
+ )
23633
+ if current_step:
23634
+ step_id = str(current_step.get("id", "") or "")
23635
+ if step_id:
23636
+ sf = bb.setdefault("step_files", {})
23637
+ entries = sf.setdefault(step_id, [])
23638
+ entries.append({
23639
+ "path": file_path,
23640
+ "role": role_key,
23641
+ "op": name,
23642
+ "ts": float(now_ts()),
23643
+ })
23644
+ if len(entries) > 30:
23645
+ sf[step_id] = entries[-30:]
23646
+ except Exception:
23647
+ pass
22782
23648
 
22783
23649
  def _blackboard_update_from_worker_step(self, role: str, step: dict):
22784
23650
  role_key = self._sanitize_agent_role(role)
@@ -23391,19 +24257,32 @@ class SessionState:
23391
24257
 
23392
24258
  def _agent_role_system_prompt(self, role: str) -> str:
23393
24259
  role_key = self._sanitize_agent_role(role) or "developer"
23394
- skills_note = self._loaded_skills_prompt_hint(for_role=role_key)
24260
+ skills_block = self._skills_awareness_block(for_role=role_key)
23395
24261
  code_note = self._runtime_code_reference_prompt_block(max_chars=2600)
23396
24262
  base = (
23397
24263
  f"You are {self._agent_display_name(role_key)} in a multi-agent coding system. "
23398
- f"Workspace: {self.files_root}. Use relative paths. "
24264
+ f"Workspace: \"{self.files_root}\" ($SESSION_ROOT). Use relative paths or $SESSION_ROOT in bash. "
24265
+ f"Offline JS libraries root: $JS_LIB_ROOT. "
24266
+ f"Structure: flat .js files at $JS_LIB_ROOT/<name>.min.js; "
24267
+ f"pptxgenjs at $JS_LIB_ROOT/pptxgenjs/dist/pptxgen.cjs.js (CommonJS) or pptxgen.bundle.js (browser). "
24268
+ f"Do NOT look in node_modules — libs are installed directly under $JS_LIB_ROOT. "
23399
24269
  "Use blackboard for shared state, ask_colleague for inter-agent communication. "
23400
24270
  "Keep outputs concise and action-oriented. "
23401
- f"{skills_note}{code_note + ' ' if code_note else ''}"
24271
+ f"{code_note + ' ' if code_note else ''}"
23402
24272
  f"{_detect_os_shell_instruction()} "
23403
24273
  f"{model_language_instruction(self.ui_language)} "
23404
24274
  )
24275
+ mm_note = self._multimodal_capability_block()
24276
+ if mm_note:
24277
+ base = base + mm_note
24278
+ base = base + skills_block
23405
24279
  if role_key == "explorer":
23406
- return base + "Role: analyze goals, inspect codebase, produce research notes. Prefer read/search. "
24280
+ return base + (
24281
+ "Role: analyze goals, inspect codebase, produce research notes. "
24282
+ "For factual or background questions on any topic, FIRST call "
24283
+ "query_knowledge_library(query='<topic>', top_k=8, route='hybrid') to retrieve relevant documents. "
24284
+ "Prefer read/search tools. "
24285
+ )
23407
24286
  if role_key == "reviewer":
23408
24287
  if bool(self.reviewer_debug_mode):
23409
24288
  debug_ctx = trim(str(self.reviewer_debug_context or ""), 500)
@@ -23436,6 +24315,11 @@ class SessionState:
23436
24315
  )
23437
24316
  return base + (
23438
24317
  "Role: implement code changes, execute tools, record progress to blackboard. "
24318
+ "SKILL PRIORITY (critical): When ACTIVE SKILLS are listed above, find the "
24319
+ "<loaded-skill> messages in your context and READ them before starting any step. "
24320
+ "The skill's workflow, tools, and file structure OVERRIDE the plan's implementation "
24321
+ "approach — if the plan says 'use python-pptx' but the skill says 'use PptxGenJS', "
24322
+ "use PptxGenJS. The skill defines HOW to implement; the plan defines WHAT to do. "
23439
24323
  "TODO TRACKING (mandatory): "
23440
24324
  "After completing each logical step, call TodoWrite to update progress — "
23441
24325
  "mark completed items as 'completed' and set the next item to 'in_progress'. "
@@ -24389,7 +25273,8 @@ class SessionState:
24389
25273
  isinstance(it, dict) and str(it.get("status", it.get("state", ""))).lower() in {"completed", "done", "finished", "finish"}
24390
25274
  for it in new_items
24391
25275
  ):
24392
- self._refresh_loaded_skills_for_execution_focus(trigger="step-completed")
25276
+ self._refresh_loaded_skills_for_execution_focus(trigger="step-completed") # noqa: removed
25277
+ pass # Skills are loaded on-demand by the model
24393
25278
  except Exception:
24394
25279
  pass
24395
25280
  return result
@@ -24397,7 +25282,7 @@ class SessionState:
24397
25282
  result = self._todo_write_rescue(args)
24398
25283
  # Also recheck skills on rescue write (likely a recovery situation)
24399
25284
  try:
24400
- self._refresh_loaded_skills_for_execution_focus(trigger="todo-rescue")
25285
+ pass # Skills are loaded on-demand by the model via load_skill
24401
25286
  except Exception:
24402
25287
  pass
24403
25288
  return result
@@ -24848,8 +25733,6 @@ class SessionState:
24848
25733
  "</live-user-adjustment>"
24849
25734
  )
24850
25735
  self.messages.append({"role": "user", "content": payload, "ts": now_ts()})
24851
- # Merge user feedback with plan direction
24852
- self._merge_user_feedback_with_plan(content)
24853
25736
  self.runtime_reclassify_goal = trim(content, 4000)
24854
25737
  # Only trigger reclassification in auto mode (no user override)
24855
25738
  if int(getattr(self, 'user_task_level_override', 0) or 0) > 0:
@@ -24863,6 +25746,7 @@ class SessionState:
24863
25746
  "weight": weight,
24864
25747
  "priority": priority,
24865
25748
  "applied": applied,
25749
+ "content": content,
24866
25750
  }
24867
25751
  )
24868
25752
  row["applied_count"] = applied
@@ -24875,6 +25759,8 @@ class SessionState:
24875
25759
  self.updated_at = now_ts()
24876
25760
  self._persist()
24877
25761
  for item in injected:
25762
+ # Merge user feedback with plan direction (outside lock — may do LLM call)
25763
+ self._merge_user_feedback_with_plan(item["content"])
24878
25764
  self._emit(
24879
25765
  "status",
24880
25766
  {
@@ -24887,6 +25773,49 @@ class SessionState:
24887
25773
  )
24888
25774
  return len(injected)
24889
25775
 
25776
+ def _user_feedback_conflict_score(self, user_text: str, step_desc: str = "") -> float:
25777
+ """Score 0.0–1.0 via LLM semantic analysis: how strongly user feedback conflicts with current plan.
25778
+ Falls back to 0.5 on error."""
25779
+ if not str(user_text or "").strip():
25780
+ return 0.0
25781
+ try:
25782
+ ctx = [
25783
+ {"role": "system", "content": (
25784
+ "You are a semantic conflict analyzer. "
25785
+ "Given a user's mid-execution feedback and the current task step, "
25786
+ "output ONLY a JSON object: {\"score\": <float 0.0-1.0>, \"reason\": \"<brief>\"}. "
25787
+ "score=0.0 means fully aligned (minor tweak/clarification). "
25788
+ "score=1.0 means direct contradiction/override (user wants opposite direction). "
25789
+ "No other text."
25790
+ ), "ts": now_ts()},
25791
+ {"role": "user", "content": (
25792
+ f"Current step: {trim(step_desc, 300) or 'unknown'}\n"
25793
+ f"User feedback: {trim(user_text, 500)}\n"
25794
+ "Output JSON only."
25795
+ ), "ts": now_ts()},
25796
+ ]
25797
+ resp = self._chat_with_same_model_retry(
25798
+ ctx,
25799
+ tools=None,
25800
+ system=None,
25801
+ max_tokens=80,
25802
+ think=False,
25803
+ stream_thinking=False,
25804
+ context_label="feedback-conflict-score",
25805
+ retries=1,
25806
+ )
25807
+ text = str(resp.get("content", "") or "").strip()
25808
+ # Extract JSON from response
25809
+ import re as _re
25810
+ m = _re.search(r'\{[^}]+\}', text)
25811
+ if m:
25812
+ parsed = parse_json_object(m.group(0), {})
25813
+ score = float(parsed.get("score", 0.5) or 0.5)
25814
+ return max(0.0, min(1.0, score))
25815
+ except Exception:
25816
+ pass
25817
+ return 0.5
25818
+
24890
25819
  def _merge_user_feedback_with_plan(self, user_text: str):
24891
25820
  """When user provides feedback during execution, inject plan-aware merge note into manager context."""
24892
25821
  bb = self._ensure_blackboard()
@@ -24900,22 +25829,41 @@ class SessionState:
24900
25829
  break
24901
25830
  step_desc = trim(str(current_step.get("content", "") if current_step else "none"), 200)
24902
25831
  is_plan_executing = plan.get("phase") == "executing"
25832
+ conflict_score = self._user_feedback_conflict_score(user_text, step_desc)
25833
+ if conflict_score >= 0.8:
25834
+ conflict_level = "HIGH"
25835
+ directive = (
25836
+ "USER OVERRIDE: This feedback DIRECTLY CONFLICTS with the current plan step. "
25837
+ "You MUST prioritize the user's instruction over the original plan. "
25838
+ "Adjust the current step's approach immediately to comply with user's requirement. "
25839
+ "Do NOT continue the original approach."
25840
+ )
25841
+ elif conflict_score >= 0.5:
25842
+ conflict_level = "MEDIUM"
25843
+ directive = (
25844
+ "This feedback modifies the current approach. "
25845
+ "Re-evaluate the current step and adjust delegation to incorporate user's requirement. "
25846
+ "User's direction takes precedence over plan details."
25847
+ )
25848
+ else:
25849
+ conflict_level = "LOW"
25850
+ directive = (
25851
+ "Minor feedback — integrate with current work direction. "
25852
+ "Adjust approach if needed but maintain progress."
25853
+ )
24903
25854
  if is_plan_executing:
24904
25855
  merge_note = (
24905
- f"<user-feedback-merge>\n"
25856
+ f"<user-feedback-merge conflict=\"{conflict_level}\" score=\"{conflict_score:.2f}\">\n"
24906
25857
  f"User provided new input during plan execution: {trim(user_text, 500)}\n"
24907
25858
  f"Current plan step: {step_desc}\n"
24908
- f"Re-evaluate: Does this feedback change the current step's approach? "
24909
- f"If yes, adjust delegation accordingly. If it's a new requirement, "
24910
- f"integrate it into the current or next step. Do NOT restart from scratch.\n"
25859
+ f"{directive}\n"
24911
25860
  f"</user-feedback-merge>"
24912
25861
  )
24913
25862
  else:
24914
25863
  merge_note = (
24915
- f"<user-feedback-merge>\n"
25864
+ f"<user-feedback-merge conflict=\"{conflict_level}\" score=\"{conflict_score:.2f}\">\n"
24916
25865
  f"User provided new input: {trim(user_text, 500)}\n"
24917
- f"Integrate this feedback with current work direction. "
24918
- f"Adjust approach if needed but maintain progress.\n"
25866
+ f"{directive}\n"
24919
25867
  f"</user-feedback-merge>"
24920
25868
  )
24921
25869
  if self._is_multi_agent_mode():
@@ -24925,6 +25873,13 @@ class SessionState:
24925
25873
  "ts": now_ts(),
24926
25874
  "agent_role": "manager",
24927
25875
  })
25876
+ else:
25877
+ # single mode: inject directly into message history so the model sees it
25878
+ self.messages.append({
25879
+ "role": "user",
25880
+ "content": merge_note,
25881
+ "ts": now_ts(),
25882
+ })
24928
25883
 
24929
25884
  def _is_restart_scenario(self) -> bool:
24930
25885
  """Check if current state is a restart after finished/aborted task."""
@@ -25006,6 +25961,15 @@ class SessionState:
25006
25961
  if _awaiting_plan_choice:
25007
25962
  # Restore plan proposal so choice can be parsed
25008
25963
  self.runtime_plan_mode_needed = True
25964
+ # Reset completed plan/todo/skills blackboard state so the manager
25965
+ # does not see status=COMPLETED on the very first round and immediately finish.
25966
+ # But preserve plan state if user is continuing an existing task.
25967
+ if not _awaiting_plan_choice:
25968
+ clean_goal_pre = trim(str(content or "").strip(), 4000)
25969
+ if self._is_continuation_input(clean_goal_pre):
25970
+ pass # Preserve plan state for continuation
25971
+ else:
25972
+ self._reset_blackboard_plan_state_locked()
25009
25973
  self.run_generation = int(self.run_generation) + 1
25010
25974
  clean_goal = trim(str(content or "").strip(), 4000)
25011
25975
  self._refresh_runtime_code_reference(clean_goal or content)
@@ -25525,6 +26489,7 @@ class SessionState:
25525
26489
  if self.stall_escalation_triggered:
25526
26490
  self._emit("status", {"summary": "sync loop break: stall escalated to plan mode"})
25527
26491
  break
26492
+ self._inject_pending_user_inputs()
25528
26493
  self._apply_auto_compact_if_needed("auto:multi-sync")
25529
26494
  # Periodic checkpoint in multi-agent sync loop
25530
26495
  if rounds_used % CHECKPOINT_INTERVAL_ROUNDS == 0:
@@ -25611,6 +26576,8 @@ class SessionState:
25611
26576
  media_inputs_round=role_media_inputs,
25612
26577
  )
25613
26578
  self._blackboard_update_from_worker_step(role, step)
26579
+ # Post-execution plan step advancement (replaces pre-execution advancement)
26580
+ self._post_execution_plan_step_check(route, step if isinstance(step, dict) else {})
25614
26581
  # ── Agent turn 结束后的终止检测:结论性回复 + 无待办 + 无错误 → 自动 finish ──
25615
26582
  agent_text = self._latest_agent_assistant_text(role)
25616
26583
  if (
@@ -25890,7 +26857,7 @@ class SessionState:
25890
26857
 
25891
26858
  # Auto-discover and load relevant skills before research
25892
26859
  try:
25893
- self._refresh_loaded_skills_for_execution_focus(trigger="plan-mode-start")
26860
+ pass # Skills are loaded on-demand by the model via load_skill
25894
26861
  except Exception:
25895
26862
  pass
25896
26863
 
@@ -25900,12 +26867,22 @@ class SessionState:
25900
26867
  for r in range(PLAN_MODE_EXPLORER_MAX_ROUNDS):
25901
26868
  if self.cancel_requested:
25902
26869
  return
26870
+ self._inject_pending_user_inputs()
25903
26871
  step = self._plan_mode_explorer_turn(pinned_selection, round_idx=r)
25904
26872
  if step.get("status") in ("no-tools", "skip", "interrupted"):
25905
26873
  break
25906
26874
  self._plan_mode_update_findings(step)
25907
26875
 
25908
26876
  # Phase 2: Manager 综合分析
26877
+ # Inject pending user inputs before synthesis
26878
+ self._inject_pending_user_inputs()
26879
+ # Check if user sent a substantive goal change during research
26880
+ if self._has_pending_goal_change():
26881
+ self._emit("status", {"summary": "plan-mode: user changed task during research, restarting"})
26882
+ self.runtime_plan_mode_needed = False
26883
+ self.runtime_plan_approved = False
26884
+ return
26885
+
25909
26886
  self._emit("status", {"summary": "plan-mode: synthesizing proposals"})
25910
26887
  bb = self._ensure_blackboard()
25911
26888
  if not isinstance(bb.get("plan"), dict):
@@ -25913,7 +26890,17 @@ class SessionState:
25913
26890
  bb["plan"]["phase"] = "synthesis"
25914
26891
  self.blackboard = bb
25915
26892
 
25916
- proposal = self._plan_mode_synthesize_proposal(pinned_selection)
26893
+ # Synthesis with retry (up to 2 attempts) + minimal fallback
26894
+ proposal = None
26895
+ for _synth_attempt in range(2):
26896
+ proposal = self._plan_mode_synthesize_proposal(pinned_selection)
26897
+ if proposal and proposal.get("options"):
26898
+ break
26899
+ if _synth_attempt == 0:
26900
+ self._emit("status", {"summary": "plan-mode: synthesis retry"})
26901
+ if not proposal or not proposal.get("options"):
26902
+ # Last resort: minimal fallback with simpler prompt and higher token budget
26903
+ proposal = self._synthesis_minimal_fallback(pinned_selection)
25917
26904
  if not proposal or not proposal.get("options"):
25918
26905
  self._emit("status", {"summary": "plan-mode: synthesis failed, falling back to direct execution"})
25919
26906
  self.runtime_plan_mode_needed = False
@@ -25929,16 +26916,23 @@ class SessionState:
25929
26916
  bb["plan"]["proposal"] = proposal
25930
26917
  self.blackboard = bb
25931
26918
 
25932
- plan_text = self._format_plan_proposal_markdown(proposal)
26919
+ # Write full plan to file for model consumption
26920
+ try:
26921
+ self._write_plan_file(self._format_plan_file_preselection(proposal))
26922
+ except Exception:
26923
+ pass
26924
+
26925
+ # Condensed bubble for UI (under PLAN_BUBBLE_MAX_CHARS)
26926
+ bubble_text = self._format_plan_bubble_preselection(proposal)
25933
26927
  self.messages.append({
25934
26928
  "role": "assistant",
25935
- "content": plan_text,
26929
+ "content": bubble_text,
25936
26930
  "ts": now_ts(),
25937
26931
  "agent_role": "planner",
25938
26932
  })
25939
26933
  self._emit("message", {
25940
26934
  "role": "assistant",
25941
- "text": trim(plan_text, int(ASSISTANT_MESSAGE_EVENT_MAX_CHARS)),
26935
+ "text": trim(bubble_text, int(ASSISTANT_MESSAGE_EVENT_MAX_CHARS)),
25942
26936
  "summary": "plan-mode proposal",
25943
26937
  "agent_role": "planner",
25944
26938
  })
@@ -25973,33 +26967,43 @@ class SessionState:
25973
26967
  f"## User Request\n{goal}\n\n"
25974
26968
  f"{skills_section}"
25975
26969
  f"## Instructions\n"
25976
- f"1. List all uploaded/workspace files with `ls uploaded/` or `ls` to know what inputs are available\n"
25977
- f"2. Read uploaded files (.parsed.md preferred over .pdf) to understand their content and structure\n"
25978
- f"3. If skills are loaded, analyze their <loaded-skill> content to identify concrete workflow steps, "
25979
- f"scripts, tools, and file paths each skill requires\n"
25980
- f"4. Identify key technical details, data points, and structure needed for the output\n"
25981
- f"5. Assess risks and note any ambiguities that need user input\n"
25982
- f"6. DO NOT write, edit, or create any files. Read-only analysis only.\n"
25983
- f"7. Write your findings to the blackboard under 'plan_findings'. Include:\n"
26970
+ f"1. Call `list_skills` FIRST to discover available skills identify which skills are relevant "
26971
+ f"to this task and note their names and capabilities in your findings.\n"
26972
+ f"2. List all uploaded/workspace files with `ls uploaded/` or `ls` to know what inputs are available\n"
26973
+ f"3. Read uploaded files (.parsed.md preferred over .pdf) to understand their content and structure\n"
26974
+ f"4. If relevant skills exist, call `load_skill` to load the most relevant one and analyze its "
26975
+ f"workflow steps, scripts, tools, and file paths\n"
26976
+ f"5. Identify key technical details, data points, and structure needed for the output\n"
26977
+ f"6. Assess risks and note any ambiguities that need user input\n"
26978
+ f"7. DO NOT write, edit, or create any files. Read-only analysis only.\n"
26979
+ f"8. Write your findings to the blackboard under 'plan_findings'. Include:\n"
26980
+ f" - Relevant skills found (names, what they do, how to invoke them)\n"
25984
26981
  f" - File inventory (uploaded files, their types, sizes, key content)\n"
25985
- f" - Skill workflow breakdown (concrete tools, scripts, paths for each loaded skill)\n"
25986
- f" - Content analysis (key themes, structure, data points extracted from inputs)\n\n"
25987
- f"Workspace: {self.files_root}\n"
26982
+ f" - Skill workflow breakdown (concrete tools, scripts, paths for each relevant skill)\n"
26983
+ f" - Content analysis (key themes, structure, data points extracted from inputs)\n"
26984
+ f"9. For coding tasks, identify the test strategy:\n"
26985
+ f" - What build/compilation commands are available? (Makefile, npm, cargo, cmake, etc.)\n"
26986
+ f" - What test frameworks/suites exist? (pytest, jest, go test, etc.)\n"
26987
+ f" - What are the critical paths that must be tested?\n"
26988
+ f" - Record these in plan_findings under 'test_strategy'.\n\n"
26989
+ f"Workspace: \"{self.files_root}\" ($SESSION_ROOT)\n"
25988
26990
  f"{os_note}\n"
25989
26991
  f"{lang_note}"
25990
26992
  )
25991
26993
 
25992
26994
  def _seed_plan_mode_explorer_context(self, research_prompt: str):
25993
26995
  os_note = _detect_os_shell_instruction()
25994
- skills_hint = self._loaded_skills_prompt_hint(for_role="explorer")
26996
+ skills_block = self._skills_awareness_block(for_role="explorer")
25995
26997
  self._append_agent_context_message("explorer", {
25996
26998
  "role": "system",
25997
26999
  "content": (
25998
27000
  "You are Explorer in plan-mode (read-only research). "
25999
27001
  "Analyze the codebase to understand the task scope. "
26000
27002
  "Do NOT modify any files. Use read_file, bash (read-only commands), "
26001
- "and blackboard tools only. "
26002
- f"{skills_hint}"
27003
+ "list_skills, load_skill, and blackboard tools only. "
27004
+ f"{skills_block}"
27005
+ "IMPORTANT: If the task requires specialized output (PPTX, reports, deep research, code review), "
27006
+ "call list_skills first to discover relevant skills, then note in plan_findings which skills to use. "
26003
27007
  f"{os_note} "
26004
27008
  f"{model_language_instruction(self.ui_language)}"
26005
27009
  ),
@@ -26030,16 +27034,16 @@ class SessionState:
26030
27034
  self.current_phase = f"plan-mode:explorer:round-{round_idx}"
26031
27035
  self.current_tool_name = ""
26032
27036
  self.active_agent_role = "explorer"
26033
- # Build loaded-skills hint for system prompt
26034
- skills_hint = self._loaded_skills_prompt_hint(for_role="explorer")
27037
+ # Build skills awareness block (same as sync/single mode)
27038
+ skills_block = self._skills_awareness_block(for_role="explorer")
26035
27039
  response = self._chat_with_same_model_retry(
26036
27040
  ctx,
26037
27041
  tools=filtered_tools,
26038
27042
  system=(
26039
27043
  "You are Explorer in plan-mode research. Read-only analysis. "
26040
27044
  "Do NOT create, write, or edit files. "
26041
- f"Workspace: {self.files_root}. "
26042
- f"{skills_hint}"
27045
+ f"Workspace: \"{self.files_root}\" ($SESSION_ROOT). "
27046
+ f"{skills_block}"
26043
27047
  f"{_detect_os_shell_instruction()} "
26044
27048
  f"{model_language_instruction(self.ui_language)}"
26045
27049
  ),
@@ -26443,12 +27447,22 @@ class SessionState:
26443
27447
  f"- Each step should be completable in 1-3 tool calls\n"
26444
27448
  f"- Group related substeps under numbered headings (e.g., '2.1 Read report 1', '2.2 Read report 2')\n"
26445
27449
  f"Make options meaningfully different (e.g. different approaches, scope levels, or trade-offs).\n"
26446
- f"{model_language_instruction(self.ui_language)}"
26447
- )
27450
+ "\nVERIFICATION & TESTING:\n"
27451
+ "Judge from the task content and research findings whether the task involves writing, "
27452
+ "modifying, or generating code/scripts/configurations. If it does:\n"
27453
+ "- Include compile/build/lint verification steps after implementation steps.\n"
27454
+ "- Include a dedicated testing step with specific commands before final review.\n"
27455
+ "- For large plans (10+ steps), insert intermediate test checkpoints.\n"
27456
+ "- If the task modifies existing code, include a regression test step.\n"
27457
+ "If the task is pure research, analysis, or document generation with no executable code, "
27458
+ "skip compile/test steps — use your judgement.\n"
27459
+ )
27460
+ synthesis_prompt += f"{model_language_instruction(self.ui_language)}"
26448
27461
  synthesis_ctx = [
26449
27462
  {"role": "system", "content": (
26450
27463
  "You are a technical architect synthesizing research into actionable plans. "
26451
- "When loaded skills are available, incorporate their capabilities and best practices into plan options."
27464
+ "When skills are referenced in the findings, incorporate their actual workflow steps into plan options. "
27465
+ f"{self._skills_awareness_block(for_role='developer')}"
26452
27466
  ), "ts": now_ts()},
26453
27467
  {"role": "user", "content": synthesis_prompt, "ts": now_ts()},
26454
27468
  ]
@@ -26472,6 +27486,63 @@ class SessionState:
26472
27486
  return dict(args)
26473
27487
  return {}
26474
27488
 
27489
+ def _synthesis_minimal_fallback(self, pinned_selection: str) -> dict:
27490
+ """Last-resort: ask model for a single simple plan with higher max_tokens."""
27491
+ goal = trim(str(self.runtime_reclassify_goal or self._latest_user_goal_text() or ""), 2000)
27492
+ bb = self._ensure_blackboard()
27493
+ findings = bb.get("plan", {}).get("findings", []) if isinstance(bb.get("plan"), dict) else []
27494
+ findings_text = "\n".join(
27495
+ trim(str(f.get("content", "") if isinstance(f, dict) else ""), 600)
27496
+ for f in (findings[:5] if isinstance(findings, list) else [])
27497
+ )
27498
+ prompt = (
27499
+ f"Generate ONE simple plan for this task. Call submit_plan_proposal with exactly 1 option.\n\n"
27500
+ f"Task: {goal}\n\nFindings: {trim(findings_text, 3000)}\n\n"
27501
+ f"Return a single option with id='A', title, summary, and 5-10 concrete steps.\n"
27502
+ f"{model_language_instruction(self.ui_language)}"
27503
+ )
27504
+ ctx = [
27505
+ {"role": "system", "content": "You must call submit_plan_proposal tool.", "ts": now_ts()},
27506
+ {"role": "user", "content": prompt, "ts": now_ts()},
27507
+ ]
27508
+ try:
27509
+ response = self._chat_with_same_model_retry(
27510
+ ctx,
27511
+ tools=self._plan_mode_synthesis_tools(),
27512
+ system="Call submit_plan_proposal now.",
27513
+ max_tokens=6000,
27514
+ think=False,
27515
+ stream_thinking=False,
27516
+ on_thinking_chunk=self._append_live_thinking,
27517
+ pinned_selection=pinned_selection,
27518
+ context_label="plan-mode minimal fallback",
27519
+ retries=2,
27520
+ )
27521
+ for tc in response.get("tool_calls", []):
27522
+ if tc.get("function", {}).get("name") == "submit_plan_proposal":
27523
+ args = tc["function"].get("arguments", {})
27524
+ if isinstance(args, dict) and args.get("options"):
27525
+ return dict(args)
27526
+ except Exception:
27527
+ pass
27528
+ return {}
27529
+
27530
+ def _has_pending_goal_change(self) -> bool:
27531
+ """Check if user sent a substantive goal change (not just plan choice or continuation)."""
27532
+ with self.lock:
27533
+ for row in self.pending_user_inputs:
27534
+ content = str(row.get("content", "") or "").strip().lower()
27535
+ if not content:
27536
+ continue
27537
+ if content in {
27538
+ "继续", "continue", "go on", "接着", "a", "b", "c",
27539
+ "方案a", "方案b", "方案c", "keep going", "proceed",
27540
+ }:
27541
+ continue
27542
+ if len(content) > 10:
27543
+ return True
27544
+ return False
27545
+
26475
27546
  def _plan_mode_synthesis_tools(self) -> list:
26476
27547
  return [tool_def(
26477
27548
  "submit_plan_proposal",
@@ -26499,6 +27570,185 @@ class SessionState:
26499
27570
  ["context", "options", "recommended"],
26500
27571
  )]
26501
27572
 
27573
+ # ── Plan MD File helpers ──────────────────────────────────────────
27574
+
27575
+ def _plan_file_path(self) -> Path:
27576
+ """Plan MD file path (inside files_root sandbox, accessible via read_file)."""
27577
+ return self.files_root / ".clouds_coder" / "plan.md"
27578
+
27579
+ def _write_plan_file(self, content: str) -> bool:
27580
+ """Atomically write plan.md: write tmp → os.replace."""
27581
+ import uuid as _uuid_mod
27582
+ target = self._plan_file_path()
27583
+ target.parent.mkdir(parents=True, exist_ok=True)
27584
+ tmp = target.with_suffix(f".{_uuid_mod.uuid4().hex[:8]}.tmp")
27585
+ try:
27586
+ tmp.write_text(content, encoding="utf-8")
27587
+ os.replace(str(tmp), str(target))
27588
+ return True
27589
+ except Exception:
27590
+ try:
27591
+ tmp.unlink(missing_ok=True)
27592
+ except Exception:
27593
+ pass
27594
+ return False
27595
+
27596
+ def _read_plan_file(self) -> str:
27597
+ """Read plan.md; returns empty string if file does not exist."""
27598
+ try:
27599
+ p = self._plan_file_path()
27600
+ return p.read_text(encoding="utf-8") if p.exists() else ""
27601
+ except Exception:
27602
+ return ""
27603
+
27604
+ def _format_plan_file_preselection(self, proposal: dict) -> str:
27605
+ """Full MD content with ALL options for model review (no char limit)."""
27606
+ lines = ["# Execution Plan Proposals\n"]
27607
+ context = str(proposal.get("context", "") or "").strip()
27608
+ if context:
27609
+ lines.append(f"## Background\n{context}\n")
27610
+ recommended = str(proposal.get("recommended", "") or "").strip()
27611
+ options = proposal.get("options", [])
27612
+ if not isinstance(options, list):
27613
+ options = []
27614
+ for opt in options[:PLAN_MODE_MAX_OPTIONS]:
27615
+ if not isinstance(opt, dict):
27616
+ continue
27617
+ opt_id = str(opt.get("id", "") or "").strip()
27618
+ title = str(opt.get("title", "") or "").strip()
27619
+ header = f"## Option {opt_id}: {title}"
27620
+ if opt_id == recommended:
27621
+ header += " [RECOMMENDED]"
27622
+ lines.append("---\n")
27623
+ lines.append(header)
27624
+ summary = str(opt.get("summary", "") or "").strip()
27625
+ if summary:
27626
+ lines.append(summary)
27627
+ steps = opt.get("steps", [])
27628
+ if isinstance(steps, list) and steps:
27629
+ lines.append("\n### Steps")
27630
+ for i, s in enumerate(steps):
27631
+ lines.append(f"{i + 1}. {s}")
27632
+ pros = str(opt.get("pros", "") or "").strip()
27633
+ if pros:
27634
+ lines.append(f"\n**Pros:** {pros}")
27635
+ cons = str(opt.get("cons", "") or "").strip()
27636
+ if cons:
27637
+ lines.append(f"**Cons:** {cons}")
27638
+ risk = str(opt.get("risk", "") or "").strip()
27639
+ if risk:
27640
+ lines.append(f"**Risk:** {risk}")
27641
+ lines.append("")
27642
+ lines.append("---")
27643
+ lines.append("> Awaiting user choice.")
27644
+ return "\n".join(lines)
27645
+
27646
+ def _format_plan_file_execution(self, choice_id: str) -> str:
27647
+ """Render execution-phase plan.md with live step statuses from blackboard."""
27648
+ bb = self._ensure_blackboard()
27649
+ proposal = self.runtime_plan_proposal or {}
27650
+ chosen = next(
27651
+ (o for o in proposal.get("options", [])
27652
+ if isinstance(o, dict) and o.get("id") == choice_id),
27653
+ None,
27654
+ )
27655
+ title = str((chosen or {}).get("title", "") or choice_id).strip()
27656
+ summary = str((chosen or {}).get("summary", "") or "").strip()
27657
+ todos = bb.get("project_todos", [])
27658
+ plan_todos = [t for t in todos if t.get("category") == "plan_step"]
27659
+ total = len(plan_todos)
27660
+ completed = sum(1 for t in plan_todos if t.get("status") == "completed")
27661
+ current_idx = completed + 1
27662
+
27663
+ lines = [f"# Active Plan: {title}\n"]
27664
+ lines.append(f"> Status: EXECUTING | Step {current_idx}/{total}")
27665
+ lines.append(f"> Chosen: Option {choice_id}")
27666
+ from datetime import datetime as _dt_cls
27667
+ lines.append(f"> Updated: {_dt_cls.now().isoformat(timespec='seconds')}\n")
27668
+ if summary:
27669
+ lines.append(f"## Summary\n{summary}\n")
27670
+ lines.append("## Steps\n")
27671
+ for t in plan_todos:
27672
+ idx = int(t.get("plan_step_index", 0) or 0) + 1
27673
+ text = str(t.get("content", "") or "").strip()
27674
+ status = str(t.get("status", "pending") or "pending")
27675
+ if status == "completed":
27676
+ actor = str(t.get("completed_by", "") or "")
27677
+ evidence = str(t.get("evidence", "") or "")
27678
+ lines.append(f"- [x] Step {idx}: {text}")
27679
+ meta_parts = []
27680
+ if actor:
27681
+ meta_parts.append(f"Completed by: {actor}")
27682
+ if evidence:
27683
+ meta_parts.append(f"Evidence: {evidence}")
27684
+ if meta_parts:
27685
+ lines.append(f" > {' | '.join(meta_parts)}")
27686
+ elif status == "in_progress":
27687
+ lines.append(f"- [>] Step {idx}: {text} <-- CURRENT")
27688
+ else:
27689
+ lines.append(f"- [ ] Step {idx}: {text}")
27690
+ return "\n".join(lines) + "\n"
27691
+
27692
+ def _update_plan_file_step_status(self) -> bool:
27693
+ """Re-render execution-phase plan.md from current blackboard state and write atomically."""
27694
+ choice_id = str(self.runtime_plan_choice or "")
27695
+ if not choice_id:
27696
+ bb = self._ensure_blackboard()
27697
+ plan_data = bb.get("plan", {})
27698
+ choice_id = str(plan_data.get("chosen", "") if isinstance(plan_data, dict) else "")
27699
+ if not choice_id:
27700
+ return False
27701
+ content = self._format_plan_file_execution(choice_id)
27702
+ return self._write_plan_file(content)
27703
+
27704
+ def _format_plan_bubble_preselection(self, proposal: dict) -> str:
27705
+ """Condensed bubble for UI (under PLAN_BUBBLE_MAX_CHARS). No full step listing."""
27706
+ lines = ["## 📋 执行方案\n"]
27707
+ context = str(proposal.get("context", "") or "").strip()
27708
+ if context:
27709
+ lines.append(f"**背景:** {trim(context, 300)}\n")
27710
+ recommended = str(proposal.get("recommended", "") or "").strip()
27711
+ options = proposal.get("options", [])
27712
+ if not isinstance(options, list):
27713
+ options = []
27714
+ for opt in options[:PLAN_MODE_MAX_OPTIONS]:
27715
+ if not isinstance(opt, dict):
27716
+ continue
27717
+ opt_id = str(opt.get("id", "") or "").strip()
27718
+ title = str(opt.get("title", "") or "").strip()
27719
+ is_rec = opt_id == recommended
27720
+ header = f"### 方案 {opt_id}: {title}"
27721
+ if is_rec:
27722
+ header += " ⭐推荐"
27723
+ lines.append(header)
27724
+ summary = str(opt.get("summary", "") or "").strip()
27725
+ if summary:
27726
+ lines.append(trim(summary, 200))
27727
+ steps = opt.get("steps", [])
27728
+ step_count = len(steps) if isinstance(steps, list) else 0
27729
+ risk = str(opt.get("risk", "") or "").strip()
27730
+ meta = f"步骤数: {step_count}"
27731
+ if risk:
27732
+ meta += f" | 风险: {risk}"
27733
+ lines.append(meta)
27734
+ lines.append("")
27735
+ lines.append("---")
27736
+ lines.append(f"完整方案详见: `{PLAN_FILE_RELATIVE_PATH}`")
27737
+ lines.append('请回复选择(如"方案A"、"A"、"选1"),或输入修改意见。')
27738
+ return trim("\n".join(lines), PLAN_BUBBLE_MAX_CHARS)
27739
+
27740
+ def _plan_file_read_instruction(self) -> str:
27741
+ """Short instruction for models: read the plan file instead of embedding full plan text."""
27742
+ return (
27743
+ f"[plan-file] The approved execution plan is at `{PLAN_FILE_RELATIVE_PATH}`.\n"
27744
+ f"Use: read_file {PLAN_FILE_RELATIVE_PATH} to review full steps and live status.\n"
27745
+ "The plan file is the authoritative source for step ordering and completion status.\n"
27746
+ "Execute steps IN ORDER. Do NOT skip ahead. Mark current step done before advancing.\n"
27747
+ "If a step references a skill or workflow, call load_skill to load it before proceeding."
27748
+ )
27749
+
27750
+ # ── (legacy) _format_plan_proposal_markdown ──────────────────────
27751
+
26502
27752
  def _format_plan_proposal_markdown(self, proposal: dict) -> str:
26503
27753
  lines = ["## 📋 执行方案\n"]
26504
27754
  context = str(proposal.get("context", "") or "").strip()
@@ -26624,16 +27874,9 @@ class SessionState:
26624
27874
  )
26625
27875
  if not chosen:
26626
27876
  return
26627
- steps_text = "\n".join(f"{i+1}. {s}" for i, s in enumerate(chosen.get("steps", [])))
26628
- plan_msg = (
26629
- f"[approved-plan] Execute the following plan:\n"
26630
- f"## {chosen.get('title', '')}\n"
26631
- f"{chosen.get('summary', '')}\n\n"
26632
- f"### Steps:\n{steps_text}\n\n"
26633
- f"Follow these steps. Use tools to implement each step concretely.\n"
26634
- f"If a step references a skill or workflow you don't fully understand, "
26635
- f"call load_skill to load it and read its instructions before proceeding."
26636
- )
27877
+ # Write execution-phase plan file (detailed, for model read_file)
27878
+ # Must happen BEFORE blackboard todos are created so the file reflects initial state
27879
+ plan_msg = self._plan_file_read_instruction()
26637
27880
  self.messages.append({
26638
27881
  "role": "system",
26639
27882
  "content": plan_msg,
@@ -26651,6 +27894,9 @@ class SessionState:
26651
27894
  bb["plan"] = {"phase": "executing", "chosen": choice_id, "steps": chosen.get("steps", [])}
26652
27895
  self.blackboard = bb
26653
27896
  self._blackboard_history("manager", f"plan approved: option {choice_id} — {chosen.get('title', '')}")
27897
+ # Lock complexity/level floor to prevent manager downgrade during plan execution
27898
+ self.runtime_complexity_floor = str(self.runtime_task_complexity or "complex")
27899
+ self.runtime_task_level_floor = int(self.runtime_task_level or 4)
26654
27900
  # Auto-create todos from plan steps → write into bb["project_todos"]
26655
27901
  steps = chosen.get("steps", [])
26656
27902
  if steps and isinstance(steps, list):
@@ -26671,7 +27917,7 @@ class SessionState:
26671
27917
  "evidence": "",
26672
27918
  })
26673
27919
  if plan_todos:
26674
- bb["project_todos"] = plan_todos[:20]
27920
+ bb["project_todos"] = plan_todos[:40]
26675
27921
  bb["plan_step_cursor"] = 0
26676
27922
  bb["plan_step_total"] = len(plan_todos)
26677
27923
  self.blackboard = bb
@@ -26686,12 +27932,13 @@ class SessionState:
26686
27932
  "status": t["status"],
26687
27933
  "activeForm": f"Working on: {t['content']}" if t["status"] == "in_progress" else f"Pending: {t['content']}",
26688
27934
  }
26689
- for t in plan_todos[:20]
27935
+ for t in plan_todos[:40]
26690
27936
  ])
26691
27937
  except Exception:
26692
27938
  pass
27939
+ # Write execution-phase plan file (now with todos populated)
26693
27940
  try:
26694
- self._refresh_loaded_skills_for_execution_focus(trigger="plan-approved")
27941
+ self._write_plan_file(self._format_plan_file_execution(choice_id))
26695
27942
  except Exception:
26696
27943
  pass
26697
27944
  # Pre-load skills explicitly mentioned in plan steps
@@ -26755,13 +28002,13 @@ class SessionState:
26755
28002
  )
26756
28003
  },
26757
28004
  )
26758
- # ── Auto-discover and load relevant skills BEFORE classification ──
28005
+ # ── Skills are loaded on-demand by the model via load_skill ──
26759
28006
  try:
26760
28007
  self._emit(
26761
28008
  "status",
26762
- {"summary": "initial skill discovery started"},
28009
+ {"summary": "skills available on-demand"},
26763
28010
  )
26764
- self._refresh_loaded_skills_for_execution_focus(trigger="pre-classify")
28011
+ pass # No automatic pre-classify skill discovery
26765
28012
  except Exception:
26766
28013
  pass
26767
28014
  initial_policy_media_inputs = self._recent_multimodal_inputs()
@@ -29781,6 +31028,9 @@ h3{font-size:.96rem;margin:10px 0 6px}
29781
31028
  .todo-item,.task-item{border:1px solid #e4ebf4;border-left-width:4px;border-radius:10px;padding:8px 10px;background:#fcfdff}
29782
31029
  .todo-item.st-pending,.task-item.st-pending{border-left-color:#7b8798}
29783
31030
  .todo-item.st-in_progress,.task-item.st-in_progress{border-left-color:#1f6feb;background:#eef5ff}
31031
+ .todo-group-label{font-size:.72rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin:6px 0 2px 2px}
31032
+ .todo-subtask{margin-left:16px;border-left-width:3px;border-radius:8px;padding:6px 10px;font-size:.9em}
31033
+ .todo-subtask::before{content:"↳ ";color:var(--muted);font-size:.85em}
29784
31034
  .todo-item.st-completed,.task-item.st-completed{border-left-color:#13b8a6;background:#edfcf7}
29785
31035
  .todo-item.st-blocked,.task-item.st-blocked{border-left-color:#b96b00;background:#fff6ea}
29786
31036
  .todo-item.st-deleted,.task-item.st-deleted{border-left-color:#a0a6b0;background:#f7f8fa}
@@ -29939,7 +31189,8 @@ const I18N={
29939
31189
  llm_fill_config:'Fill LLM Config',llm_provider:'Provider',llm_confirm:'Confirm',llm_import_config:'Import config',
29940
31190
  llm_thinking_stream:'Thinking Stream',llm_enabled:'Enabled',llm_disabled:'Disabled',
29941
31191
  llm_model:'Model',llm_scan:'Scan',llm_scan_hint:'Click Scan to detect models from Ollama',llm_scan_first:'Scan models first',
29942
- llm_scanning:'Scanning...',llm_scan_found:'Found {n} model(s)',llm_scan_empty:'No models found',llm_scan_error:'Scan failed'
31192
+ llm_scanning:'Scanning...',llm_scan_found:'Found {n} model(s)',llm_scan_empty:'No models found',llm_scan_error:'Scan failed',
31193
+ todo_plan_steps:'Plan Steps',todo_subtasks:'Subtasks'
29943
31194
  },
29944
31195
  'zh-CN':{
29945
31196
  app_title:'Clouds Coder',app_subtitle:'WebUI 驱动的会话式集成编程 Agent 平台',powered_by:'Powered By Fona',
@@ -29974,7 +31225,8 @@ const I18N={
29974
31225
  llm_fill_config:'填写 LLM 配置',llm_provider:'供应商',llm_confirm:'确认',llm_import_config:'导入配置',
29975
31226
  llm_thinking_stream:'思维流',llm_enabled:'启用',llm_disabled:'禁用',
29976
31227
  llm_model:'模型',llm_scan:'扫描',llm_scan_hint:'点击扫描检测 Ollama 可用模型',llm_scan_first:'请先扫描模型',
29977
- llm_scanning:'扫描中...',llm_scan_found:'发现 {n} 个模型',llm_scan_empty:'未发现模型',llm_scan_error:'扫描失败'
31228
+ llm_scanning:'扫描中...',llm_scan_found:'发现 {n} 个模型',llm_scan_empty:'未发现模型',llm_scan_error:'扫描失败',
31229
+ todo_plan_steps:'计划步骤',todo_subtasks:'子任务'
29978
31230
  },
29979
31231
  'zh-TW':{
29980
31232
  app_title:'Clouds Coder',app_subtitle:'WebUI 驅動的會話式整合程式 Agent 平台',powered_by:'Powered By Fona',
@@ -30009,7 +31261,8 @@ const I18N={
30009
31261
  llm_fill_config:'填寫 LLM 設定',llm_provider:'供應商',llm_confirm:'確認',llm_import_config:'匯入設定',
30010
31262
  llm_thinking_stream:'思維流',llm_enabled:'啟用',llm_disabled:'停用',
30011
31263
  llm_model:'模型',llm_scan:'掃描',llm_scan_hint:'點擊掃描偵測 Ollama 可用模型',llm_scan_first:'請先掃描模型',
30012
- llm_scanning:'掃描中...',llm_scan_found:'發現 {n} 個模型',llm_scan_empty:'未發現模型',llm_scan_error:'掃描失敗'
31264
+ llm_scanning:'掃描中...',llm_scan_found:'發現 {n} 個模型',llm_scan_empty:'未發現模型',llm_scan_error:'掃描失敗',
31265
+ todo_plan_steps:'計劃步驟',todo_subtasks:'子任務'
30013
31266
  },
30014
31267
  'ja':{
30015
31268
  app_title:'Clouds Coder',app_subtitle:'WebUI 駆動の対話型コーディング Agent プラットフォーム',powered_by:'Powered By Fona',
@@ -30044,7 +31297,8 @@ const I18N={
30044
31297
  llm_fill_config:'LLM設定入力',llm_provider:'プロバイダー',llm_confirm:'確認',llm_import_config:'設定をインポート',
30045
31298
  llm_thinking_stream:'シンキングストリーム',llm_enabled:'有効',llm_disabled:'無効',
30046
31299
  llm_model:'モデル',llm_scan:'スキャン',llm_scan_hint:'スキャンをクリックしてOllamaモデルを検出',llm_scan_first:'先にモデルをスキャン',
30047
- llm_scanning:'スキャン中...',llm_scan_found:'{n}個のモデルを検出',llm_scan_empty:'モデルが見つかりません',llm_scan_error:'スキャン失敗'
31300
+ llm_scanning:'スキャン中...',llm_scan_found:'{n}個のモデルを検出',llm_scan_empty:'モデルが見つかりません',llm_scan_error:'スキャン失敗',
31301
+ todo_plan_steps:'計画ステップ',todo_subtasks:'サブタスク'
30048
31302
  }
30049
31303
  };
30050
31304
  function currentLang(){const fromSnap=String(S.snap?.ui_language||'').trim();if(fromSnap&&I18N[fromSnap])return fromSnap;const fromCfg=String(S.config?.language||'').trim();if(fromCfg&&I18N[fromCfg])return fromCfg;return 'zh-CN'}
@@ -30581,16 +31835,32 @@ function _mathRunTypeset(root,key=''){
30581
31835
  if(!root)return;
30582
31836
  const k=String(key||'').trim();
30583
31837
  if(k&&root.getAttribute('data-math-key')===k)return;
31838
+ const mathJaxCandidates=[
31839
+ '/assets/js_lib/tex-mml-chtml.js',
31840
+ '/assets/js_lib/mathjax/tex-mml-chtml.js',
31841
+ '/assets/js_lib/es5/tex-mml-chtml.js',
31842
+ '/assets/js_lib/mathjax/es5/tex-mml-chtml.js',
31843
+ 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'
31844
+ ];
31845
+ const loadMathJax=(idx=0)=>{
31846
+ const src=String(mathJaxCandidates[idx]||'').trim();
31847
+ if(!src)return;
31848
+ const s=document.createElement('script');
31849
+ s.src=src;
31850
+ s.async=true;
31851
+ s.dataset.mathjaxCandidate=String(idx);
31852
+ s.onerror=()=>{
31853
+ if(idx+1<mathJaxCandidates.length)loadMathJax(idx+1);
31854
+ };
31855
+ document.head.appendChild(s);
31856
+ };
30584
31857
  const run=(retry)=>{
30585
31858
  const mj=window.MathJax;
30586
31859
  if(!mj||typeof mj.typesetPromise!=='function'){
30587
31860
  // Lazy-load MathJax on first actual math demand
30588
31861
  if(!window._mjaxLoading){
30589
31862
  window._mjaxLoading=true;
30590
- const s=document.createElement('script');
30591
- s.src='https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
30592
- s.async=true;
30593
- document.head.appendChild(s);
31863
+ loadMathJax(0);
30594
31864
  }
30595
31865
  if(retry<20)setTimeout(()=>run(retry+1),200);
30596
31866
  return;
@@ -32339,7 +33609,54 @@ function statusClass(status){return `st-${normalizeStatus(status)}`}
32339
33609
  function statusLabel(status){const s=normalizeStatus(status);if(s==='in_progress')return t('status_in_progress');if(s==='completed')return t('status_completed');if(s==='blocked')return t('status_blocked');if(s==='deleted')return t('status_deleted');return t('status_pending')}
32340
33610
  function cleanWorkText(text,status=''){let s=String(text??'').replace(/\\s+/g,' ').trim();if(!s)return '';s=s.replace(/^\\[[ x>\\-]\\]\\s*/i,'');s=s.replace(/^(pending|in[_\\-\\s]?progress|completed|done|blocked)\\s*[·:\\-\\]]\\s*/i,'');if(status){const st=String(status).replace('_','[_\\\\-\\\\s]?');s=s.replace(new RegExp(`\\\\s*[—-]\\\\s*${st}\\\\s*$`,'i'),'')}s=s.replace(/\\s*[—-]\\s*(pending|in[_\\-\\s]?progress|completed|done|blocked)\\s*$/i,'');return s.trim()||String(text??'').trim()}
32341
33611
  function formatTs(ts){const v=Number(ts||0);if(!v)return '';try{return new Date(v*1000).toLocaleString()}catch(_){return ''}}
32342
- function renderTodoBoard(items){const todos=Array.isArray(items)?items:[];if(!todos.length)return `<div class=\"mono\">${esc(t('no_todos'))}</div>`;const done=todos.filter(t=>normalizeStatus(t?.status)==='completed').length;const open=todos.length-done;const cards=todos.map((t,idx)=>{const status=normalizeStatus(t?.status);const content=cleanWorkText(t?.content,status)||'(empty todo)';const active=String(t?.activeForm||'').trim();const meta=status==='in_progress'&&active?`<div class=\"todo-meta\">${esc(cleanWorkText(active,status))}</div>`:'';return `<div class=\"todo-item ${statusClass(status)}\"><div class=\"todo-head\"><span class=\"status-badge ${statusClass(status)}\">${esc(statusLabel(status))}</span><span class=\"mono todo-index\">#${idx+1}</span></div><div class=\"todo-content\">${esc(content)}</div>${meta}</div>`}).join('');return `<div class=\"board-summary\"><span>${esc(open)} ${esc(t('open'))}</span><span>${esc(done)}/${esc(todos.length)} ${esc(t('completed'))}</span></div><div class=\"todo-list\">${cards}</div>`}
33612
+ function renderTodoBoard(items){
33613
+ const todos=Array.isArray(items)?items:[];
33614
+ if(!todos.length)return `<div class="mono">${esc(t('no_todos'))}</div>`;
33615
+ const done=todos.filter(x=>normalizeStatus(x?.status)==='completed').length;
33616
+ const open=todos.length-done;
33617
+ function todoCard(item,idx,extraClass=''){
33618
+ const status=normalizeStatus(item?.status);
33619
+ const content=cleanWorkText(item?.content,status)||'(empty todo)';
33620
+ const active=String(item?.activeForm||'').trim();
33621
+ const meta=status==='in_progress'&&active?`<div class="todo-meta">${esc(cleanWorkText(active,status))}</div>`:'';
33622
+ return `<div class="todo-item ${statusClass(status)}${extraClass?(' '+extraClass):''}"><div class="todo-head"><span class="status-badge ${statusClass(status)}">${esc(statusLabel(status))}</span><span class="mono todo-index">#${idx+1}</span></div><div class="todo-content">${esc(content)}</div>${meta}</div>`;
33623
+ }
33624
+ // Split into plan steps (bb:proj:) and worker subtasks
33625
+ const planSteps=todos.filter(x=>String(x?.key||'').startsWith('bb:proj:'));
33626
+ const workerTodos=todos.filter(x=>!String(x?.key||'').startsWith('bb:proj:'));
33627
+ let html='';
33628
+ if(planSteps.length&&workerTodos.length){
33629
+ // Build parent_step_id index: map step key suffix to its subtasks
33630
+ const stepIdFromKey=(key)=>{const k=String(key||'');return k.startsWith('bb:proj:')?k.slice(8):''};
33631
+ const subtasksByStep={};
33632
+ const unlinked=[];
33633
+ workerTodos.forEach(sub=>{
33634
+ const pid=String(sub?.parent_step_id||'').trim();
33635
+ if(pid){(subtasksByStep[pid]=subtasksByStep[pid]||[]).push(sub)}
33636
+ else{unlinked.push(sub)}
33637
+ });
33638
+ // Find active plan step for unlinked subtasks fallback
33639
+ const activeStepIdx=planSteps.findIndex(x=>normalizeStatus(x?.status)==='in_progress');
33640
+ html+=`<div class="todo-group-label">${esc(t('todo_plan_steps'))}</div><div class="todo-list">`;
33641
+ planSteps.forEach((step,i)=>{
33642
+ html+=todoCard(step,i);
33643
+ const sid=stepIdFromKey(step?.key);
33644
+ // Show subtasks linked to this step via parent_step_id
33645
+ const linked=sid?subtasksByStep[sid]||[]:[];
33646
+ // Also attach unlinked subtasks under the active plan step (backward compat)
33647
+ const subs=i===activeStepIdx?linked.concat(unlinked):linked;
33648
+ if(subs.length){
33649
+ html+=`<div class="todo-group-label" style="margin-left:16px">${esc(t('todo_subtasks'))}</div>`;
33650
+ subs.forEach((sub,j)=>{html+=todoCard(sub,j,'todo-subtask')});
33651
+ }
33652
+ });
33653
+ html+=`</div>`;
33654
+ } else {
33655
+ // No grouping needed — flat list
33656
+ html+=`<div class="todo-list">${todos.map((x,i)=>todoCard(x,i)).join('')}</div>`;
33657
+ }
33658
+ return `<div class="board-summary"><span>${esc(open)} ${esc(t('open'))}</span><span>${esc(done)}/${esc(todos.length)} ${esc(t('completed'))}</span></div>${html}`;
33659
+ }
32343
33660
  function renderTaskBoard(items){const tasks=Array.isArray(items)?items:[];if(!tasks.length)return `<div class=\"mono\">${esc(t('no_tasks'))}</div>`;const completed=tasks.filter(row=>normalizeStatus(row?.status,'pending')==='completed').length;const blocked=tasks.filter(row=>normalizeStatus(row?.status,'pending')==='blocked').length;const cards=tasks.map(row=>{const status=normalizeStatus(row?.status,'pending');const id=Number(row?.id||0)||'-';const subject=cleanWorkText(row?.subject,status)||'(empty task)';const owner=String(row?.owner||'').trim();const blockedBy=Array.isArray(row?.blockedBy)&&row.blockedBy.length?`blocked_by=${row.blockedBy.map(x=>`#${x}`).join(', ')}`:'';const blocks=Array.isArray(row?.blocks)&&row.blocks.length?`blocks=${row.blocks.map(x=>`#${x}`).join(', ')}`:'';const timeTxt=formatTs(row?.updated_at||row?.created_at);const meta=[owner?`owner=@${owner}`:t('owner_unassigned'),blockedBy,blocks,timeTxt].filter(Boolean).join(' · ');return `<div class=\"task-item ${statusClass(status)}\"><div class=\"task-head\"><span class=\"mono task-id\">#${esc(id)}</span><span class=\"status-badge ${statusClass(status)}\">${esc(statusLabel(status))}</span></div><div class=\"task-subject\">${esc(subject)}</div><div class=\"task-meta\">${esc(meta)}</div></div>`}).join('');return `<div class=\"board-summary\"><span>${esc(tasks.length-completed)} ${esc(t('open'))}</span><span>${esc(completed)} ${esc(t('completed'))} · ${esc(blocked)} ${esc(t('blocked'))}</span></div><div class=\"task-list\">${cards}</div>`}
32344
33661
  function ensureFileExplorerState(sessionId){const sid=String(sessionId||S.activeId||'').trim();if(!sid)return null;if(!S.fileExplorerBySession)S.fileExplorerBySession={};if(!S.fileExplorerBySession[sid]||typeof S.fileExplorerBySession[sid]!=='object'){S.fileExplorerBySession[sid]={tree:null,root:'',nodeCount:0,truncated:false,maxNodes:0,fetchedAt:0,inflight:false,selected:'',expanded:{'':true}}}const st=S.fileExplorerBySession[sid];if(!st.expanded||typeof st.expanded!=='object')st.expanded={'':true};st.expanded['']=true;return st}
32345
33662
  function _fePath(sessionId){const sid=encodeURIComponent(String(sessionId||'').trim());return `/api/sessions/${sid}/files-tree`}
@@ -34349,7 +35666,7 @@ class RAGContentParser:
34349
35666
 
34350
35667
  text = extract_text(str(pdf_path))
34351
35668
  if text and text.strip():
34352
- return trim(text.strip(), 150_000)
35669
+ return trim(text.strip(), 800_000)
34353
35670
  except ImportError:
34354
35671
  pass
34355
35672
  except Exception:
@@ -34364,7 +35681,7 @@ class RAGContentParser:
34364
35681
  timeout=60,
34365
35682
  )
34366
35683
  if r.returncode == 0 and r.stdout.strip():
34367
- return trim(r.stdout.strip(), 150_000)
35684
+ return trim(r.stdout.strip(), 800_000)
34368
35685
  except Exception:
34369
35686
  pass
34370
35687
  try:
@@ -36525,7 +37842,7 @@ class RAGLibraryStore:
36525
37842
  backup_path.write_bytes(raw_bytes)
36526
37843
  elif source_fp and source_fp.exists():
36527
37844
  shutil.copy2(source_fp, backup_path)
36528
- semantic_text = trim(str(parse_result.get("text", "") or ""), 160_000)
37845
+ semantic_text = trim(str(parse_result.get("text", "") or ""), 800_000)
36529
37846
  multimodal_row = dict(multimodal or {})
36530
37847
  mm_summary = trim(str(multimodal_row.get("summary", "") or ""), 2400)
36531
37848
  mm_tags = [str(x).strip() for x in (multimodal_row.get("tags", []) or []) if str(x).strip()]
@@ -39952,20 +41269,12 @@ class AppContext:
39952
41269
  self.base_url = base_url
39953
41270
  self.model = model
39954
41271
  self.thinking = False
39955
- self.js_lib_root = offline_js_lib_root(self.workspace)
41272
+ self.js_lib_root = offline_js_lib_root(SCRIPT_DIR)
39956
41273
  self.offline_js_summary: dict = {}
39957
41274
  try:
39958
- self.offline_js_summary = ensure_offline_js_libs(self.workspace, force=False)
39959
- except Exception as exc:
39960
- self.offline_js_summary = {
39961
- "generated_at": int(now_ts()),
39962
- "js_lib_root": str(self.js_lib_root),
39963
- "total": len(OFFLINE_JS_LIB_CATALOG),
39964
- "available": 0,
39965
- "missing": len(OFFLINE_JS_LIB_CATALOG),
39966
- "fetched": 0,
39967
- "error": trim(str(exc), 220),
39968
- }
41275
+ self.offline_js_summary = load_offline_js_lib_index(self.js_lib_root)
41276
+ except Exception:
41277
+ self.offline_js_summary = {}
39969
41278
  self.default_language = normalize_ui_language(default_language)
39970
41279
  self.ui_style = normalize_ui_style(ui_style)
39971
41280
  self.context_token_limit = max(
@@ -40264,16 +41573,7 @@ class AppContext:
40264
41573
  return CODE_ADMIN_JS
40265
41574
 
40266
41575
  def rag_js_lib_asset_path(self, filename: str) -> Path | None:
40267
- safe = _safe_js_filename(str(filename or "").strip(), "lib.js")
40268
- fp = (self.js_lib_root / safe).resolve()
40269
- try:
40270
- if not fp.is_relative_to(self.js_lib_root):
40271
- return None
40272
- except Exception:
40273
- return None
40274
- if not fp.exists() or (not fp.is_file()):
40275
- return None
40276
- return fp
41576
+ return _resolve_js_lib_asset_path(self.js_lib_root, str(filename or "").strip())
40277
41577
 
40278
41578
  def rag_three_asset_info(self) -> dict:
40279
41579
  picks = [
@@ -41071,7 +42371,7 @@ class AppContext:
41071
42371
  f"{str(row.get('title', '') or '').strip()} "
41072
42372
  f"score={str(row.get('score', 0) or 0)}"
41073
42373
  )
41074
- snippet = trim(str(row.get("text", "") or ""), 320)
42374
+ snippet = trim(str(row.get("text", "") or ""), 800)
41075
42375
  if snippet:
41076
42376
  lines.append(snippet)
41077
42377
  return "\n".join(lines)
@@ -43150,8 +44450,8 @@ class RagAdminHandler(BaseHTTPRequestHandler):
43150
44450
  if path == "/assets/rag-admin.js":
43151
44451
  return self._send_text(self.app.web_ui_rag_admin_js(), "application/javascript; charset=utf-8")
43152
44452
  if path.startswith("/assets/js_lib/"):
43153
- filename = path.rsplit("/", 1)[-1]
43154
- fp = self.app.rag_js_lib_asset_path(filename)
44453
+ asset_ref = path[len("/assets/js_lib/"):]
44454
+ fp = self.app.rag_js_lib_asset_path(asset_ref)
43155
44455
  if not fp:
43156
44456
  return self._send_json({"error": "asset not found"}, status=404)
43157
44457
  try:
@@ -43159,7 +44459,7 @@ class RagAdminHandler(BaseHTTPRequestHandler):
43159
44459
  except Exception as exc:
43160
44460
  return self._send_json({"error": str(exc)}, status=500)
43161
44461
  content_type = guess_mime_from_name(fp.name, "application/javascript")
43162
- if fp.suffix.lower() == ".js":
44462
+ if fp.suffix.lower() in {".js", ".mjs", ".cjs"}:
43163
44463
  content_type = "application/javascript; charset=utf-8"
43164
44464
  return self._send_inline_bytes(data, content_type)
43165
44465
  if path == "/api/health":
@@ -43307,8 +44607,8 @@ class CodeAdminHandler(BaseHTTPRequestHandler):
43307
44607
  if path == "/assets/code-admin.js":
43308
44608
  return self._send_text(self.app.web_ui_code_admin_js(), "application/javascript; charset=utf-8")
43309
44609
  if path.startswith("/assets/js_lib/"):
43310
- filename = path.rsplit("/", 1)[-1]
43311
- fp = self.app.rag_js_lib_asset_path(filename)
44610
+ asset_ref = path[len("/assets/js_lib/"):]
44611
+ fp = self.app.rag_js_lib_asset_path(asset_ref)
43312
44612
  if not fp:
43313
44613
  return self._send_json({"error": "asset not found"}, status=404)
43314
44614
  try:
@@ -43316,7 +44616,7 @@ class CodeAdminHandler(BaseHTTPRequestHandler):
43316
44616
  except Exception as exc:
43317
44617
  return self._send_json({"error": str(exc)}, status=500)
43318
44618
  content_type = guess_mime_from_name(fp.name, "application/javascript")
43319
- if fp.suffix.lower() == ".js":
44619
+ if fp.suffix.lower() in {".js", ".mjs", ".cjs"}:
43320
44620
  content_type = "application/javascript; charset=utf-8"
43321
44621
  return self._send_inline_bytes(data, content_type)
43322
44622
  if path == "/api/health":
@@ -43938,6 +45238,17 @@ def main():
43938
45238
  except Exception as exc:
43939
45239
  print(f"[web-agent] failed to apply --config: {exc}")
43940
45240
  sys.exit(2)
45241
+ # JS lib download (default on; set download_js_lib: false in --config to disable)
45242
+ _js_dl_enabled = extract_js_lib_download_setting(external_config)
45243
+ if _js_dl_enabled is None:
45244
+ _js_dl_enabled = True
45245
+ if _js_dl_enabled:
45246
+ try:
45247
+ app.offline_js_summary = ensure_offline_js_libs(
45248
+ app.workspace, force=False, verbose=True, no_connection_deadline=60.0
45249
+ )
45250
+ except Exception as _js_exc:
45251
+ print(f"[js_lib] download error: {_js_exc}")
43941
45252
  web_ui_state = app.configure_web_ui(
43942
45253
  config_path=str(web_ui_config_path),
43943
45254
  ui_dir=resolved_web_ui_dir,