wydcode 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +100 -0
- package/NOTICE +5 -0
- package/README.md +434 -0
- package/SECURITY.md +64 -0
- package/SOCRA_SOURCE.md +36 -0
- package/TRADEMARKS.md +12 -0
- package/bin/wydcode.js +74 -0
- package/examples/behavior-preference-card.json +12 -0
- package/examples/exact-recurrence-card.json +11 -0
- package/examples/heldout-gene-evidence.json +6 -0
- package/examples/humaneval-ornith.md +29 -0
- package/examples/large_context_json_diagnostic.py +187 -0
- package/fixtures/failing-node-repo/package.json +9 -0
- package/fixtures/failing-node-repo/src/math.js +3 -0
- package/fixtures/failing-node-repo/test/math.test.js +8 -0
- package/package.json +55 -0
- package/pyproject.toml +24 -0
- package/socra_harness/__init__.py +3 -0
- package/socra_harness/attempts.py +76 -0
- package/socra_harness/central_traces.py +627 -0
- package/socra_harness/cli.py +265 -0
- package/socra_harness/eval/__init__.py +1 -0
- package/socra_harness/eval/humaneval.py +388 -0
- package/socra_harness/events.py +39 -0
- package/socra_harness/init.py +206 -0
- package/socra_harness/job/__init__.py +2 -0
- package/socra_harness/job/events.py +132 -0
- package/socra_harness/job/lock.py +90 -0
- package/socra_harness/job/manager.py +3785 -0
- package/socra_harness/job/planner.py +253 -0
- package/socra_harness/job/state.py +106 -0
- package/socra_harness/mumpix_trace_bridge.cjs +32 -0
- package/socra_harness/product.py +533 -0
- package/socra_harness/recurrence.py +263 -0
- package/socra_harness/scan/__init__.py +1 -0
- package/socra_harness/scan/repo.py +85 -0
- package/socra_harness/serve/__init__.py +1 -0
- package/socra_harness/serve/local_proxy.py +293 -0
- package/socra_harness/start.py +331 -0
- package/socra_harness/tui/__init__.py +1 -0
- package/socra_harness/tui/app.py +1114 -0
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import webbrowser
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..events import read_events
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
MAX_AGENT_LOOP_PASSES = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CHAT_SYSTEM_PROMPT = """You are Socra Harness, a local-first coding partner.
|
|
28
|
+
The user is vibe coding inside the selected project folder.
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
- Use the project context and scanned candidates when helpful.
|
|
32
|
+
- Keep answers concise and practical.
|
|
33
|
+
- Do not include hidden reasoning.
|
|
34
|
+
- For normal questions, answer normally.
|
|
35
|
+
- When the user asks you to create or modify project files, return ONLY this JSON:
|
|
36
|
+
{"message":"short summary","files":[{"path":"relative/project/path","content":"complete file content"}]}
|
|
37
|
+
- File paths must be relative to the project root. Never use absolute paths or .. segments.
|
|
38
|
+
- Include complete file contents, not patches, for every file you want Socra to write.
|
|
39
|
+
- If the user asks for a website, include at least index.html and any CSS/JS files it needs.
|
|
40
|
+
- If sandbox network/install access is enabled and you need to download a repo,
|
|
41
|
+
fetch software, or install dependencies, include commands in the same JSON:
|
|
42
|
+
{"commands":[{"argv":["git","clone","https://example/repo.git"],"cwd":"."}]}
|
|
43
|
+
- Commands run only inside Socra's disposable sandbox, never directly in the project root.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def now_iso() -> str:
|
|
48
|
+
return dt.datetime.now(dt.UTC).isoformat()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_json(path: Path) -> dict[str, Any] | None:
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def append_jsonl(path: Path, row: dict[str, Any]) -> None:
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
63
|
+
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_chat(path: Path, limit: int = 10) -> list[dict[str, Any]]:
|
|
67
|
+
if limit <= 0:
|
|
68
|
+
return []
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return []
|
|
71
|
+
rows: list[dict[str, Any]] = []
|
|
72
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
73
|
+
try:
|
|
74
|
+
row = json.loads(line)
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
continue
|
|
77
|
+
if not isinstance(row, dict):
|
|
78
|
+
continue
|
|
79
|
+
if row.get("role") in {"user", "assistant", "system"}:
|
|
80
|
+
rows.append(row)
|
|
81
|
+
return rows[-limit:]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pct(value: float) -> str:
|
|
85
|
+
return f"{value * 100:.1f}%"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def safe_addstr(stdscr: Any, y: int, x: int, text: str, attr: int = 0) -> None:
|
|
89
|
+
h, w = stdscr.getmaxyx()
|
|
90
|
+
if y < 0 or y >= h or x >= w:
|
|
91
|
+
return
|
|
92
|
+
text = text.replace("\n", " ")
|
|
93
|
+
try:
|
|
94
|
+
stdscr.addstr(y, x, text[: max(0, w - x - 1)], attr)
|
|
95
|
+
except curses.error:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def draw_bar(stdscr: Any, y: int, x: int, width: int, fraction: float, label: str) -> None:
|
|
100
|
+
fraction = max(0.0, min(1.0, fraction))
|
|
101
|
+
fill = int(width * fraction)
|
|
102
|
+
bar = "[" + "#" * fill + "-" * (width - fill) + "]"
|
|
103
|
+
safe_addstr(stdscr, y, x, f"{bar} {label}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def latest_eval_progress(events: list[dict[str, Any]]) -> tuple[int, int, int]:
|
|
107
|
+
total = 0
|
|
108
|
+
done = 0
|
|
109
|
+
passed = 0
|
|
110
|
+
for event in events:
|
|
111
|
+
if event.get("event") == "eval_start":
|
|
112
|
+
total = int(event.get("total") or 0)
|
|
113
|
+
done = 0
|
|
114
|
+
passed = 0
|
|
115
|
+
elif event.get("event") == "eval_task":
|
|
116
|
+
done = max(done, int(event.get("index") or 0))
|
|
117
|
+
total = max(total, int(event.get("total") or 0))
|
|
118
|
+
if event.get("passed"):
|
|
119
|
+
passed += 1
|
|
120
|
+
elif event.get("event") == "eval_complete":
|
|
121
|
+
total = int(event.get("total") or total)
|
|
122
|
+
done = total
|
|
123
|
+
passed = int(event.get("passed") or passed)
|
|
124
|
+
return done, total, passed
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def candidate_summary(candidates: dict[str, Any], limit: int = 6) -> str:
|
|
128
|
+
rows = []
|
|
129
|
+
for item in (candidates.get("candidates") or [])[:limit]:
|
|
130
|
+
rows.append(
|
|
131
|
+
f"- {item.get('family')}: {item.get('file_count')} files, "
|
|
132
|
+
f"score {item.get('score_estimate')}, samples {item.get('samples') or []}"
|
|
133
|
+
)
|
|
134
|
+
return "\n".join(rows) if rows else "No repeatable candidates found yet."
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sandbox_policy(session: dict[str, Any]) -> dict[str, Any]:
|
|
138
|
+
sandbox = session.get("sandbox") if isinstance(session.get("sandbox"), dict) else {}
|
|
139
|
+
return {
|
|
140
|
+
"network": bool(sandbox.get("network")),
|
|
141
|
+
"installs": bool(sandbox.get("installs")),
|
|
142
|
+
"root": str(sandbox.get("root") or ""),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def chat_system_prompt(project: Path, candidates: dict[str, Any], session: dict[str, Any]) -> str:
|
|
147
|
+
policy = sandbox_policy(session)
|
|
148
|
+
sandbox_text = (
|
|
149
|
+
f"\nSandbox command access: network={policy['network']} installs={policy['installs']} "
|
|
150
|
+
f"root={policy['root'] or 'not configured'}.\n"
|
|
151
|
+
)
|
|
152
|
+
if not policy["network"]:
|
|
153
|
+
sandbox_text += "Do not request network/download/install commands; this session has not enabled sandbox network.\n"
|
|
154
|
+
return (
|
|
155
|
+
CHAT_SYSTEM_PROMPT
|
|
156
|
+
+ f"\nProject root: {project}\n\nScanned repeatable project patterns:\n"
|
|
157
|
+
+ candidate_summary(candidates)
|
|
158
|
+
+ sandbox_text
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def chat_endpoint(server: dict[str, Any]) -> str | None:
|
|
163
|
+
endpoint = server.get("endpoint")
|
|
164
|
+
if not endpoint:
|
|
165
|
+
return None
|
|
166
|
+
return str(endpoint).rstrip("/") + "/chat/completions"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def prompt_wants_files(text: str) -> bool:
|
|
170
|
+
lowered = text.lower()
|
|
171
|
+
triggers = [
|
|
172
|
+
"create",
|
|
173
|
+
"make",
|
|
174
|
+
"build",
|
|
175
|
+
"write",
|
|
176
|
+
"generate",
|
|
177
|
+
"add",
|
|
178
|
+
"app",
|
|
179
|
+
"website",
|
|
180
|
+
"page",
|
|
181
|
+
"file",
|
|
182
|
+
"component",
|
|
183
|
+
"script",
|
|
184
|
+
"download",
|
|
185
|
+
"install",
|
|
186
|
+
"clone",
|
|
187
|
+
"repo",
|
|
188
|
+
"dependency",
|
|
189
|
+
"dependencies",
|
|
190
|
+
]
|
|
191
|
+
return any(trigger in lowered for trigger in triggers)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def prompt_wants_website(text: str) -> bool:
|
|
195
|
+
lowered = text.lower()
|
|
196
|
+
return any(word in lowered for word in ["website", "webpage", "web page", "html", "landing page", "game"])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def post_chat(server: dict[str, Any], messages: list[dict[str, str]], json_mode: bool = False) -> str:
|
|
200
|
+
endpoint = chat_endpoint(server)
|
|
201
|
+
if not endpoint:
|
|
202
|
+
raise RuntimeError("Socra endpoint is not running yet.")
|
|
203
|
+
model = str(server.get("model_alias") or server.get("model") or "local-model")
|
|
204
|
+
payload = {
|
|
205
|
+
"model": model,
|
|
206
|
+
"messages": messages,
|
|
207
|
+
"temperature": 0.2,
|
|
208
|
+
"max_tokens": 4096,
|
|
209
|
+
}
|
|
210
|
+
if json_mode:
|
|
211
|
+
payload["response_format"] = {"type": "json_object"}
|
|
212
|
+
data = json.dumps(payload).encode("utf-8")
|
|
213
|
+
req = urllib.request.Request(endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
|
214
|
+
try:
|
|
215
|
+
with urllib.request.urlopen(req, timeout=900) as res:
|
|
216
|
+
body = json.loads(res.read().decode("utf-8"))
|
|
217
|
+
except urllib.error.HTTPError as exc:
|
|
218
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
219
|
+
raise RuntimeError(f"chat failed: HTTP {exc.code}: {detail[:500]}") from exc
|
|
220
|
+
choices = body.get("choices") or []
|
|
221
|
+
if not choices:
|
|
222
|
+
raise RuntimeError("chat failed: no choices returned")
|
|
223
|
+
message = choices[0].get("message") or {}
|
|
224
|
+
content = message.get("content")
|
|
225
|
+
if isinstance(content, str) and content.strip():
|
|
226
|
+
return content.strip()
|
|
227
|
+
raise RuntimeError("chat failed: empty assistant content")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run_command(command: list[str], cwd: Path, timeout: int = 20) -> tuple[bool, str]:
|
|
231
|
+
try:
|
|
232
|
+
proc = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout)
|
|
233
|
+
except FileNotFoundError:
|
|
234
|
+
return True, f"skipped; command not found: {command[0]}"
|
|
235
|
+
except subprocess.TimeoutExpired:
|
|
236
|
+
return False, f"timed out: {' '.join(command)}"
|
|
237
|
+
output = (proc.stdout + proc.stderr).strip()
|
|
238
|
+
return proc.returncode == 0, output
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def command_sandbox_root(project: Path, state_dir: Path, session: dict[str, Any]) -> Path:
|
|
242
|
+
policy = sandbox_policy(session)
|
|
243
|
+
configured = str(policy.get("root") or "").strip()
|
|
244
|
+
if configured:
|
|
245
|
+
root = Path(configured).expanduser()
|
|
246
|
+
else:
|
|
247
|
+
root = state_dir / "run-sandbox" / "command-workspace"
|
|
248
|
+
root = root.resolve()
|
|
249
|
+
fallback = (state_dir / "run-sandbox" / "command-workspace").resolve()
|
|
250
|
+
if fallback != root and fallback not in root.parents:
|
|
251
|
+
root = fallback
|
|
252
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
return root
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def safe_sandbox_cwd(root: Path, relative: str) -> Path:
|
|
257
|
+
raw = Path(relative or ".")
|
|
258
|
+
if raw.is_absolute() or any(part == ".." for part in raw.parts):
|
|
259
|
+
raise ValueError(f"unsafe sandbox cwd: {relative}")
|
|
260
|
+
cwd = (root / raw).resolve()
|
|
261
|
+
if cwd != root and root not in cwd.parents:
|
|
262
|
+
raise ValueError(f"sandbox cwd escapes root: {relative}")
|
|
263
|
+
cwd.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
return cwd
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def command_needs_network(argv: list[str]) -> bool:
|
|
268
|
+
if not argv:
|
|
269
|
+
return False
|
|
270
|
+
tool = Path(argv[0]).name
|
|
271
|
+
if tool in {"curl", "wget"}:
|
|
272
|
+
return True
|
|
273
|
+
if tool == "git" and len(argv) > 1 and argv[1] in {"clone", "fetch", "pull", "submodule"}:
|
|
274
|
+
return True
|
|
275
|
+
if tool in {"npm", "pnpm", "yarn", "bun", "pip", "pip3"} and any(part in {"install", "add", "create", "dlx"} for part in argv[1:3]):
|
|
276
|
+
return True
|
|
277
|
+
if tool in {"npx"}:
|
|
278
|
+
return True
|
|
279
|
+
return any(part.startswith(("http://", "https://", "git@")) for part in argv)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def command_is_install(argv: list[str]) -> bool:
|
|
283
|
+
if not argv:
|
|
284
|
+
return False
|
|
285
|
+
tool = Path(argv[0]).name
|
|
286
|
+
if tool in {"npm", "pnpm", "yarn", "bun", "pip", "pip3", "npx"}:
|
|
287
|
+
return any(part in {"install", "add", "create", "dlx"} for part in argv[1:3]) or tool == "npx"
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def command_has_shell_metacharacters(argv: list[str]) -> bool:
|
|
292
|
+
forbidden = {";", "&&", "||", "|", ">", ">>", "<", "$(", "`"}
|
|
293
|
+
return any(any(mark in part for mark in forbidden) for part in argv)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def execute_sandbox_commands(
|
|
297
|
+
project: Path,
|
|
298
|
+
state_dir: Path,
|
|
299
|
+
session: dict[str, Any],
|
|
300
|
+
commands: list[dict[str, Any]],
|
|
301
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
302
|
+
policy = sandbox_policy(session)
|
|
303
|
+
root = command_sandbox_root(project, state_dir, session)
|
|
304
|
+
results: list[dict[str, Any]] = []
|
|
305
|
+
errors: list[str] = []
|
|
306
|
+
for index, command in enumerate(commands, start=1):
|
|
307
|
+
argv = command.get("argv")
|
|
308
|
+
cwd_raw = command.get("cwd", ".")
|
|
309
|
+
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv) or not argv:
|
|
310
|
+
errors.append(f"command {index}: invalid argv")
|
|
311
|
+
continue
|
|
312
|
+
if command_has_shell_metacharacters(argv):
|
|
313
|
+
errors.append(f"command {index}: shell metacharacters are not allowed")
|
|
314
|
+
continue
|
|
315
|
+
if command_needs_network(argv) and not policy["network"]:
|
|
316
|
+
errors.append(f"command {index}: blocked; sandbox network is not enabled")
|
|
317
|
+
continue
|
|
318
|
+
if command_is_install(argv) and not policy["installs"]:
|
|
319
|
+
errors.append(f"command {index}: blocked; sandbox installs are not enabled")
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
cwd = safe_sandbox_cwd(root, str(cwd_raw))
|
|
323
|
+
except ValueError as exc:
|
|
324
|
+
errors.append(f"command {index}: {exc}")
|
|
325
|
+
continue
|
|
326
|
+
record_agent_stage(state_dir, "sandbox", "running", command=argv, cwd=str(cwd))
|
|
327
|
+
ok, output = run_command(argv, cwd=cwd, timeout=180)
|
|
328
|
+
result = {"index": index, "argv": argv, "cwd": str(cwd), "ok": ok, "output": output[-4000:]}
|
|
329
|
+
results.append(result)
|
|
330
|
+
append_jsonl(state_dir / "sandbox-commands.jsonl", {"timestamp": now_iso(), **result})
|
|
331
|
+
record_agent_stage(state_dir, "sandbox", "passed" if ok else "failed", command=argv, cwd=str(cwd), output=output[-1000:])
|
|
332
|
+
if not ok:
|
|
333
|
+
errors.append(f"command {index} failed: {output[-1000:]}")
|
|
334
|
+
if results:
|
|
335
|
+
(state_dir / "last-sandbox-commands.json").write_text(json.dumps({"root": str(root), "results": results}, indent=2) + "\n", encoding="utf-8")
|
|
336
|
+
return results, errors
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def strip_json_fence(text: str) -> str:
|
|
340
|
+
stripped = text.strip()
|
|
341
|
+
if stripped.startswith("```"):
|
|
342
|
+
lines = stripped.splitlines()
|
|
343
|
+
if lines and lines[0].startswith("```"):
|
|
344
|
+
lines = lines[1:]
|
|
345
|
+
if lines and lines[-1].startswith("```"):
|
|
346
|
+
lines = lines[:-1]
|
|
347
|
+
return "\n".join(lines).strip()
|
|
348
|
+
return stripped
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def json_candidates(text: str) -> list[str]:
|
|
352
|
+
raw = text.strip()
|
|
353
|
+
candidates = [strip_json_fence(raw)]
|
|
354
|
+
for match in re.finditer(r"```(?:json)?\s*([\s\S]*?)```", raw, flags=re.IGNORECASE):
|
|
355
|
+
candidates.append(match.group(1).strip())
|
|
356
|
+
start = raw.find("{")
|
|
357
|
+
while start >= 0:
|
|
358
|
+
depth = 0
|
|
359
|
+
in_string = False
|
|
360
|
+
escaped = False
|
|
361
|
+
for index in range(start, len(raw)):
|
|
362
|
+
char = raw[index]
|
|
363
|
+
if escaped:
|
|
364
|
+
escaped = False
|
|
365
|
+
continue
|
|
366
|
+
if char == "\\":
|
|
367
|
+
escaped = True
|
|
368
|
+
continue
|
|
369
|
+
if char == '"':
|
|
370
|
+
in_string = not in_string
|
|
371
|
+
continue
|
|
372
|
+
if in_string:
|
|
373
|
+
continue
|
|
374
|
+
if char == "{":
|
|
375
|
+
depth += 1
|
|
376
|
+
elif char == "}":
|
|
377
|
+
depth -= 1
|
|
378
|
+
if depth == 0:
|
|
379
|
+
candidates.append(raw[start : index + 1])
|
|
380
|
+
break
|
|
381
|
+
start = raw.find("{", start + 1)
|
|
382
|
+
unique: list[str] = []
|
|
383
|
+
seen: set[str] = set()
|
|
384
|
+
for candidate in candidates:
|
|
385
|
+
if candidate and candidate not in seen:
|
|
386
|
+
seen.add(candidate)
|
|
387
|
+
unique.append(candidate)
|
|
388
|
+
return unique
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def loads_model_json(raw: str) -> Any:
|
|
392
|
+
try:
|
|
393
|
+
return json.loads(raw)
|
|
394
|
+
except json.JSONDecodeError:
|
|
395
|
+
# Some local models emit JavaScript-style escaped apostrophes inside
|
|
396
|
+
# JSON strings. JSON does not allow \', but code content often needs
|
|
397
|
+
# the backslash preserved, so encode it as a JSON backslash instead.
|
|
398
|
+
repaired = raw.replace("\\'", "\\\\'")
|
|
399
|
+
if repaired != raw:
|
|
400
|
+
return json.loads(repaired)
|
|
401
|
+
raise
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def parse_file_bundle(text: str) -> dict[str, Any] | None:
|
|
405
|
+
path_content_objects: list[dict[str, str]] = []
|
|
406
|
+
for raw in json_candidates(text):
|
|
407
|
+
try:
|
|
408
|
+
value = loads_model_json(raw)
|
|
409
|
+
except json.JSONDecodeError:
|
|
410
|
+
continue
|
|
411
|
+
if isinstance(value, dict) and isinstance(value.get("files"), list):
|
|
412
|
+
return value
|
|
413
|
+
if isinstance(value, dict) and isinstance(value.get("path"), str) and isinstance(value.get("content"), str):
|
|
414
|
+
path_content_objects.append({"path": value["path"], "content": value["content"]})
|
|
415
|
+
if path_content_objects:
|
|
416
|
+
return {"message": "Created files from the model response.", "files": path_content_objects}
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def parse_action_bundle(text: str) -> dict[str, Any] | None:
|
|
421
|
+
for raw in json_candidates(text):
|
|
422
|
+
try:
|
|
423
|
+
value = loads_model_json(raw)
|
|
424
|
+
except json.JSONDecodeError:
|
|
425
|
+
continue
|
|
426
|
+
if isinstance(value, dict) and (isinstance(value.get("files"), list) or isinstance(value.get("commands"), list)):
|
|
427
|
+
return value
|
|
428
|
+
file_bundle = parse_file_bundle(text)
|
|
429
|
+
return file_bundle
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def parse_command_requests(text: str) -> list[dict[str, Any]]:
|
|
433
|
+
bundle = parse_action_bundle(text)
|
|
434
|
+
if not bundle:
|
|
435
|
+
return []
|
|
436
|
+
commands = bundle.get("commands")
|
|
437
|
+
if not isinstance(commands, list):
|
|
438
|
+
return []
|
|
439
|
+
parsed: list[dict[str, Any]] = []
|
|
440
|
+
for item in commands:
|
|
441
|
+
if not isinstance(item, dict):
|
|
442
|
+
continue
|
|
443
|
+
argv = item.get("argv")
|
|
444
|
+
cmd = item.get("cmd")
|
|
445
|
+
if isinstance(argv, list) and all(isinstance(part, str) for part in argv):
|
|
446
|
+
parts = argv
|
|
447
|
+
elif isinstance(cmd, str):
|
|
448
|
+
try:
|
|
449
|
+
parts = shlex.split(cmd)
|
|
450
|
+
except ValueError:
|
|
451
|
+
continue
|
|
452
|
+
else:
|
|
453
|
+
continue
|
|
454
|
+
if not parts:
|
|
455
|
+
continue
|
|
456
|
+
parsed.append({"argv": parts, "cwd": item.get("cwd") if isinstance(item.get("cwd"), str) else "."})
|
|
457
|
+
return parsed
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def code_block_candidates(text: str) -> list[tuple[str, str]]:
|
|
461
|
+
blocks = []
|
|
462
|
+
for match in re.finditer(r"```(\w+)?\s*([\s\S]*?)```", text):
|
|
463
|
+
language = (match.group(1) or "").lower()
|
|
464
|
+
code = match.group(2).strip()
|
|
465
|
+
if code:
|
|
466
|
+
blocks.append((language, code))
|
|
467
|
+
return blocks
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def synthesize_file_bundle_from_code_blocks(text: str) -> dict[str, Any] | None:
|
|
471
|
+
files: list[dict[str, str]] = []
|
|
472
|
+
counters: dict[str, int] = {}
|
|
473
|
+
extension_by_language = {
|
|
474
|
+
"html": "html",
|
|
475
|
+
"css": "css",
|
|
476
|
+
"javascript": "js",
|
|
477
|
+
"js": "js",
|
|
478
|
+
"typescript": "ts",
|
|
479
|
+
"ts": "ts",
|
|
480
|
+
"tsx": "tsx",
|
|
481
|
+
"jsx": "jsx",
|
|
482
|
+
"python": "py",
|
|
483
|
+
"py": "py",
|
|
484
|
+
"swift": "swift",
|
|
485
|
+
"json": "json",
|
|
486
|
+
"markdown": "md",
|
|
487
|
+
"md": "md",
|
|
488
|
+
}
|
|
489
|
+
default_name = {
|
|
490
|
+
"html": "index.html",
|
|
491
|
+
"css": "styles.css",
|
|
492
|
+
"javascript": "script.js",
|
|
493
|
+
"js": "script.js",
|
|
494
|
+
}
|
|
495
|
+
for language, code in code_block_candidates(text):
|
|
496
|
+
if language == "json":
|
|
497
|
+
continue
|
|
498
|
+
ext = extension_by_language.get(language)
|
|
499
|
+
if not ext:
|
|
500
|
+
if "<html" in code.lower() or "<!doctype html" in code.lower():
|
|
501
|
+
ext = "html"
|
|
502
|
+
language = "html"
|
|
503
|
+
else:
|
|
504
|
+
continue
|
|
505
|
+
name = default_name.get(language)
|
|
506
|
+
if not name:
|
|
507
|
+
counters[ext] = counters.get(ext, 0) + 1
|
|
508
|
+
name = f"generated-{counters[ext]}.{ext}"
|
|
509
|
+
if any(file["path"] == name for file in files):
|
|
510
|
+
counters[ext] = counters.get(ext, 1) + 1
|
|
511
|
+
stem, suffix = name.rsplit(".", 1)
|
|
512
|
+
name = f"{stem}-{counters[ext]}.{suffix}"
|
|
513
|
+
files.append({"path": name, "content": code})
|
|
514
|
+
if not files:
|
|
515
|
+
return None
|
|
516
|
+
return {"message": "Created files from the model response.", "files": files}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def bundle_to_text(bundle: dict[str, Any]) -> str:
|
|
520
|
+
return json.dumps(bundle, ensure_ascii=False)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def repair_file_bundle_with_model(server: dict[str, Any], project: Path, user_text: str, assistant_text: str) -> str | None:
|
|
524
|
+
repair_system = (
|
|
525
|
+
"You convert a coding assistant answer into Socra file JSON. "
|
|
526
|
+
"Return ONLY JSON with this shape: "
|
|
527
|
+
'{"message":"short summary","files":[{"path":"relative/project/path","content":"complete file content"}]}. '
|
|
528
|
+
"Use only relative paths. Do not include prose or markdown."
|
|
529
|
+
)
|
|
530
|
+
repair_user = (
|
|
531
|
+
f"Project root: {project}\n"
|
|
532
|
+
f"Original user request:\n{user_text}\n\n"
|
|
533
|
+
f"Assistant answer to package:\n{assistant_text}"
|
|
534
|
+
)
|
|
535
|
+
try:
|
|
536
|
+
repaired = post_chat(
|
|
537
|
+
server,
|
|
538
|
+
[{"role": "system", "content": repair_system}, {"role": "user", "content": repair_user}],
|
|
539
|
+
json_mode=True,
|
|
540
|
+
)
|
|
541
|
+
except Exception:
|
|
542
|
+
return None
|
|
543
|
+
if parse_file_bundle(repaired):
|
|
544
|
+
return repaired
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def repair_written_files_with_model(
|
|
549
|
+
server: dict[str, Any],
|
|
550
|
+
project: Path,
|
|
551
|
+
user_text: str,
|
|
552
|
+
written: list[str],
|
|
553
|
+
validation_errors: list[str],
|
|
554
|
+
) -> str | None:
|
|
555
|
+
file_sections = []
|
|
556
|
+
for rel in written:
|
|
557
|
+
try:
|
|
558
|
+
content = safe_project_file(project, rel).read_text(encoding="utf-8")
|
|
559
|
+
except Exception:
|
|
560
|
+
continue
|
|
561
|
+
file_sections.append(f"--- {rel} ---\n{content}")
|
|
562
|
+
repair_system = (
|
|
563
|
+
"You repair files that were just generated by a coding agent. "
|
|
564
|
+
"Return ONLY Socra file JSON: "
|
|
565
|
+
'{"message":"short summary","files":[{"path":"relative/project/path","content":"complete file content"}]}. '
|
|
566
|
+
"Include complete corrected contents for every changed file. Use relative paths only."
|
|
567
|
+
)
|
|
568
|
+
repair_user = (
|
|
569
|
+
f"Original request:\n{user_text}\n\n"
|
|
570
|
+
f"Validation errors:\n" + "\n".join(f"- {error}" for error in validation_errors) + "\n\n"
|
|
571
|
+
f"Current files:\n\n" + "\n\n".join(file_sections)
|
|
572
|
+
)
|
|
573
|
+
try:
|
|
574
|
+
repaired = post_chat(
|
|
575
|
+
server,
|
|
576
|
+
[{"role": "system", "content": repair_system}, {"role": "user", "content": repair_user}],
|
|
577
|
+
json_mode=True,
|
|
578
|
+
)
|
|
579
|
+
except Exception:
|
|
580
|
+
return None
|
|
581
|
+
if parse_file_bundle(repaired):
|
|
582
|
+
return repaired
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def safe_project_file(project: Path, relative: str) -> Path:
|
|
587
|
+
raw = Path(relative)
|
|
588
|
+
if raw.is_absolute() or any(part == ".." for part in raw.parts):
|
|
589
|
+
raise ValueError(f"unsafe path: {relative}")
|
|
590
|
+
target = (project / raw).resolve()
|
|
591
|
+
project_root = project.resolve()
|
|
592
|
+
if target != project_root and project_root not in target.parents:
|
|
593
|
+
raise ValueError(f"path escapes project: {relative}")
|
|
594
|
+
return target
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def read_file_if_exists(project: Path, relative: str) -> str:
|
|
598
|
+
path = safe_project_file(project, relative)
|
|
599
|
+
if not path.exists() or not path.is_file():
|
|
600
|
+
return ""
|
|
601
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def file_mentions(project: Path, html: str, relative: str) -> bool:
|
|
605
|
+
return relative in html or f"./{relative}" in html
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def self_contain_static_site(project: Path, written: list[str], state_dir: Path) -> list[str]:
|
|
609
|
+
lower_written = {path.lower() for path in written}
|
|
610
|
+
html_files = [path for path in written if path.lower().endswith((".html", ".htm"))]
|
|
611
|
+
if "index.html" not in lower_written or not html_files:
|
|
612
|
+
return written
|
|
613
|
+
index = safe_project_file(project, "index.html")
|
|
614
|
+
html = index.read_text(encoding="utf-8", errors="replace")
|
|
615
|
+
changed = False
|
|
616
|
+
css = read_file_if_exists(project, "styles.css")
|
|
617
|
+
if css and file_mentions(project, html, "styles.css") and "<style>" not in html.lower():
|
|
618
|
+
html = re.sub(r'\s*<link[^>]+href=["\'](?:\./)?styles\.css["\'][^>]*>\s*', "\n", html, flags=re.IGNORECASE)
|
|
619
|
+
html = html.replace("</head>", f" <style>\n{css}\n </style>\n</head>")
|
|
620
|
+
changed = True
|
|
621
|
+
js = read_file_if_exists(project, "script.js")
|
|
622
|
+
if js and file_mentions(project, html, "script.js") and "<script>" not in html.lower():
|
|
623
|
+
html = re.sub(r'\s*<script[^>]+src=["\'](?:\./)?script\.js["\'][^>]*>\s*</script>\s*', "\n", html, flags=re.IGNORECASE)
|
|
624
|
+
html = html.replace("</body>", f" <script>\n{js}\n </script>\n</body>")
|
|
625
|
+
changed = True
|
|
626
|
+
if changed:
|
|
627
|
+
index.write_text(html, encoding="utf-8")
|
|
628
|
+
if "index.html" not in written:
|
|
629
|
+
written.append("index.html")
|
|
630
|
+
record = {"timestamp": now_iso(), "project": str(project.resolve()), "action": "self_contained_static_site", "files": ["index.html"]}
|
|
631
|
+
append_jsonl(state_dir / "validation-repairs.jsonl", record)
|
|
632
|
+
return written
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def validate_written_files(project: Path, written: list[str], state_dir: Path) -> tuple[bool, list[str]]:
|
|
636
|
+
errors: list[str] = []
|
|
637
|
+
checks: list[dict[str, Any]] = []
|
|
638
|
+
for rel in written:
|
|
639
|
+
path = safe_project_file(project, rel)
|
|
640
|
+
suffix = path.suffix.lower()
|
|
641
|
+
ok = True
|
|
642
|
+
output = ""
|
|
643
|
+
if suffix == ".js":
|
|
644
|
+
ok, output = run_command(["node", "--check", str(path)], cwd=project)
|
|
645
|
+
elif suffix == ".json":
|
|
646
|
+
try:
|
|
647
|
+
json.loads(path.read_text(encoding="utf-8"))
|
|
648
|
+
output = "json ok"
|
|
649
|
+
except Exception as exc:
|
|
650
|
+
ok = False
|
|
651
|
+
output = str(exc)
|
|
652
|
+
elif suffix == ".py":
|
|
653
|
+
ok, output = run_command([sys.executable, "-m", "py_compile", str(path)], cwd=project)
|
|
654
|
+
elif suffix == ".swift":
|
|
655
|
+
ok, output = run_command(["swiftc", "-parse", str(path)], cwd=project)
|
|
656
|
+
checks.append({"file": rel, "ok": ok, "output": output[-2000:]})
|
|
657
|
+
if not ok:
|
|
658
|
+
errors.append(f"{rel}: {output[-1000:]}")
|
|
659
|
+
if any(path.lower().endswith((".html", ".htm")) for path in written):
|
|
660
|
+
for rel in [path for path in written if path.lower().endswith((".html", ".htm"))]:
|
|
661
|
+
html = safe_project_file(project, rel).read_text(encoding="utf-8", errors="replace")
|
|
662
|
+
if "<style>" not in html.lower() and ".css" not in html.lower():
|
|
663
|
+
errors.append(f"{rel}: no visible CSS detected")
|
|
664
|
+
if "<script" not in html.lower() and ".js" not in html.lower():
|
|
665
|
+
errors.append(f"{rel}: no script detected")
|
|
666
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
667
|
+
record = {"timestamp": now_iso(), "checks": checks, "errors": errors}
|
|
668
|
+
(state_dir / "last-validation.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
|
669
|
+
append_jsonl(state_dir / "validation.jsonl", record)
|
|
670
|
+
return not errors, errors
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def reviewer_agent_check(project: Path, state_dir: Path, user_text: str, written: list[str]) -> tuple[list[str], list[str]]:
|
|
674
|
+
record_agent_stage(state_dir, "reviewer", "running", files=written)
|
|
675
|
+
current = self_contain_static_site(project, list(written), state_dir) if prompt_wants_website(user_text) else list(written)
|
|
676
|
+
ok, errors = validate_written_files(project, current, state_dir)
|
|
677
|
+
record_agent_stage(state_dir, "reviewer", "passed" if ok else "failed", files=current, errors=errors)
|
|
678
|
+
return current, errors
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def runner_agent_check(project: Path, state_dir: Path, user_text: str, written: list[str]) -> list[str]:
|
|
682
|
+
record_agent_stage(state_dir, "runner", "running", files=written)
|
|
683
|
+
errors: list[str] = []
|
|
684
|
+
if prompt_wants_website(user_text):
|
|
685
|
+
ok, output = run_static_preview_smoke(project, state_dir, written)
|
|
686
|
+
if not ok:
|
|
687
|
+
errors.append(output)
|
|
688
|
+
record_agent_stage(state_dir, "runner", "passed" if not errors else "failed", files=written, errors=errors)
|
|
689
|
+
return errors
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def validate_and_repair_output(
|
|
693
|
+
server: dict[str, Any],
|
|
694
|
+
project: Path,
|
|
695
|
+
state_dir: Path,
|
|
696
|
+
user_text: str,
|
|
697
|
+
written: list[str],
|
|
698
|
+
) -> tuple[list[str], list[str]]:
|
|
699
|
+
original = list(written)
|
|
700
|
+
current = list(written)
|
|
701
|
+
all_errors: list[str] = []
|
|
702
|
+
for attempt in range(1, MAX_AGENT_LOOP_PASSES + 1):
|
|
703
|
+
write_status(state_dir, f"Reviewer pass {attempt}/{MAX_AGENT_LOOP_PASSES}...", stage="reviewer", attempt=attempt)
|
|
704
|
+
current, review_errors = reviewer_agent_check(project, state_dir, user_text, current)
|
|
705
|
+
write_status(state_dir, f"Runner pass {attempt}/{MAX_AGENT_LOOP_PASSES}...", stage="runner", attempt=attempt)
|
|
706
|
+
run_errors = runner_agent_check(project, state_dir, user_text, current)
|
|
707
|
+
all_errors = [*review_errors, *run_errors]
|
|
708
|
+
if not all_errors:
|
|
709
|
+
record_agent_stage(state_dir, "agent_loop", "passed", attempt=attempt, files=current)
|
|
710
|
+
return current, []
|
|
711
|
+
if attempt >= MAX_AGENT_LOOP_PASSES:
|
|
712
|
+
break
|
|
713
|
+
write_status(state_dir, f"Creator repair pass {attempt + 1}/{MAX_AGENT_LOOP_PASSES}...", stage="creator_repair", attempt=attempt + 1)
|
|
714
|
+
record_agent_stage(state_dir, "creator", "repairing", attempt=attempt + 1, notes=all_errors)
|
|
715
|
+
repaired = repair_written_files_with_model(server, project, user_text, current, all_errors)
|
|
716
|
+
if not repaired:
|
|
717
|
+
record_agent_stage(state_dir, "creator", "failed", attempt=attempt + 1, notes=["model did not return repair bundle"])
|
|
718
|
+
break
|
|
719
|
+
_message, repaired_written = apply_file_bundle(project, repaired, state_dir)
|
|
720
|
+
current = sorted(set([*current, *repaired_written]))
|
|
721
|
+
record_agent_stage(state_dir, "creator", "repaired", attempt=attempt + 1, files=repaired_written)
|
|
722
|
+
record_agent_stage(state_dir, "agent_loop", "failed", files=current, errors=all_errors)
|
|
723
|
+
return original, all_errors
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def write_status(state_dir: Path, message: str, **extra: Any) -> None:
|
|
727
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
728
|
+
payload = {"timestamp": now_iso(), "message": message, **extra}
|
|
729
|
+
(state_dir / "working-status.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def record_agent_stage(state_dir: Path, stage: str, status: str, **extra: Any) -> None:
|
|
733
|
+
row = {"timestamp": now_iso(), "stage": stage, "status": status, **extra}
|
|
734
|
+
append_jsonl(state_dir / "agent-loop.jsonl", row)
|
|
735
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
736
|
+
(state_dir / "last-agent-stage.json").write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def free_loopback_port() -> int:
|
|
740
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
741
|
+
sock.bind(("127.0.0.1", 0))
|
|
742
|
+
return int(sock.getsockname()[1])
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def stop_previous_preview(state_dir: Path) -> None:
|
|
746
|
+
state = read_json(state_dir / "preview-state.json") or {}
|
|
747
|
+
pid = state.get("pid")
|
|
748
|
+
if not isinstance(pid, int):
|
|
749
|
+
return
|
|
750
|
+
try:
|
|
751
|
+
os.kill(pid, 0)
|
|
752
|
+
except OSError:
|
|
753
|
+
return
|
|
754
|
+
try:
|
|
755
|
+
os.kill(pid, 15)
|
|
756
|
+
except OSError:
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def copy_written_files_to_sandbox(project: Path, sandbox: Path, written: list[str]) -> None:
|
|
761
|
+
if sandbox.exists():
|
|
762
|
+
shutil.rmtree(sandbox)
|
|
763
|
+
sandbox.mkdir(parents=True, exist_ok=True)
|
|
764
|
+
for rel in written:
|
|
765
|
+
source = safe_project_file(project, rel)
|
|
766
|
+
if not source.exists() or not source.is_file():
|
|
767
|
+
continue
|
|
768
|
+
target = safe_project_file(sandbox, rel)
|
|
769
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
770
|
+
shutil.copy2(source, target)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def prepare_static_preview_sandbox(project: Path, state_dir: Path, written: list[str]) -> tuple[Path, str] | None:
|
|
774
|
+
route = preview_path_for(written)
|
|
775
|
+
if not route:
|
|
776
|
+
return None
|
|
777
|
+
sandbox = state_dir / "run-sandbox" / "static-preview"
|
|
778
|
+
copy_written_files_to_sandbox(project.resolve(), sandbox, written)
|
|
779
|
+
return sandbox, route
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def run_static_preview_smoke(project: Path, state_dir: Path, written: list[str]) -> tuple[bool, str]:
|
|
783
|
+
prepared = prepare_static_preview_sandbox(project, state_dir, written)
|
|
784
|
+
if not prepared:
|
|
785
|
+
return True, "no static preview target"
|
|
786
|
+
sandbox, route = prepared
|
|
787
|
+
file_url = preview_file_url(sandbox, route)
|
|
788
|
+
parsed = urllib.parse.urlparse(file_url)
|
|
789
|
+
file_path = Path(urllib.request.url2pathname(parsed.path))
|
|
790
|
+
if not file_path.exists():
|
|
791
|
+
return False, f"preview entry file missing: {file_path}"
|
|
792
|
+
html = file_path.read_text(encoding="utf-8", errors="replace")
|
|
793
|
+
if not html.strip():
|
|
794
|
+
return False, f"preview entry file is empty: {file_path}"
|
|
795
|
+
return True, f"preview smoke ok: {file_url}"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def preview_path_for(written: list[str]) -> str | None:
|
|
799
|
+
html = [path for path in written if path.lower().endswith((".html", ".htm"))]
|
|
800
|
+
if not html:
|
|
801
|
+
return None
|
|
802
|
+
if "index.html" in {Path(path).name.lower() for path in html}:
|
|
803
|
+
for path in html:
|
|
804
|
+
if Path(path).name.lower() == "index.html":
|
|
805
|
+
parent = str(Path(path).parent)
|
|
806
|
+
return "/" if parent == "." else "/" + parent.replace(os.sep, "/") + "/"
|
|
807
|
+
return "/" + html[0].replace(os.sep, "/")
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def preview_file_url(sandbox: Path, route: str) -> str:
|
|
811
|
+
if route.endswith("/"):
|
|
812
|
+
file_path = sandbox / route.lstrip("/") / "index.html"
|
|
813
|
+
else:
|
|
814
|
+
file_path = sandbox / route.lstrip("/")
|
|
815
|
+
return file_path.resolve().as_uri()
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def preview_server_ready(url: str, timeout_seconds: float = 2.0) -> bool:
|
|
819
|
+
deadline = time.time() + timeout_seconds
|
|
820
|
+
while time.time() < deadline:
|
|
821
|
+
try:
|
|
822
|
+
with urllib.request.urlopen(url, timeout=0.5) as res:
|
|
823
|
+
return 200 <= res.status < 500
|
|
824
|
+
except Exception:
|
|
825
|
+
time.sleep(0.1)
|
|
826
|
+
return False
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def launch_static_preview(project: Path, state_dir: Path, written: list[str]) -> str | None:
|
|
830
|
+
prepared = prepare_static_preview_sandbox(project, state_dir, written)
|
|
831
|
+
if not prepared:
|
|
832
|
+
return None
|
|
833
|
+
sandbox, route = prepared
|
|
834
|
+
project_root = project.resolve()
|
|
835
|
+
write_status(state_dir, "Preparing local preview sandbox...")
|
|
836
|
+
stop_previous_preview(state_dir)
|
|
837
|
+
port = free_loopback_port()
|
|
838
|
+
out_log = state_dir / "preview-server.out.log"
|
|
839
|
+
err_log = state_dir / "preview-server.err.log"
|
|
840
|
+
with out_log.open("a", encoding="utf-8") as out, err_log.open("a", encoding="utf-8") as err:
|
|
841
|
+
proc = subprocess.Popen(
|
|
842
|
+
[sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", str(sandbox)],
|
|
843
|
+
stdout=out,
|
|
844
|
+
stderr=err,
|
|
845
|
+
)
|
|
846
|
+
http_url = f"http://127.0.0.1:{port}{route}"
|
|
847
|
+
if preview_server_ready(http_url):
|
|
848
|
+
url = http_url
|
|
849
|
+
mode = "http_server"
|
|
850
|
+
pid: int | None = proc.pid
|
|
851
|
+
else:
|
|
852
|
+
proc.terminate()
|
|
853
|
+
url = preview_file_url(sandbox, route)
|
|
854
|
+
mode = "file_url_fallback"
|
|
855
|
+
pid = None
|
|
856
|
+
record = {
|
|
857
|
+
"timestamp": now_iso(),
|
|
858
|
+
"pid": pid,
|
|
859
|
+
"url": url,
|
|
860
|
+
"mode": mode,
|
|
861
|
+
"sandbox": str(sandbox),
|
|
862
|
+
"project": str(project_root),
|
|
863
|
+
"files": written,
|
|
864
|
+
}
|
|
865
|
+
(state_dir / "preview-state.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
|
866
|
+
if os.environ.get("SOCRA_NO_BROWSER") != "1":
|
|
867
|
+
try:
|
|
868
|
+
webbrowser.open(url)
|
|
869
|
+
except Exception:
|
|
870
|
+
pass
|
|
871
|
+
return url
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def apply_file_bundle(project: Path, text: str, state_dir: Path) -> tuple[str | None, list[str]]:
|
|
875
|
+
bundle = parse_file_bundle(text)
|
|
876
|
+
if not bundle:
|
|
877
|
+
return None, []
|
|
878
|
+
project_root = project.resolve()
|
|
879
|
+
written: list[str] = []
|
|
880
|
+
for item in bundle.get("files") or []:
|
|
881
|
+
if not isinstance(item, dict):
|
|
882
|
+
continue
|
|
883
|
+
rel = item.get("path")
|
|
884
|
+
content = item.get("content")
|
|
885
|
+
if not isinstance(rel, str) or not isinstance(content, str):
|
|
886
|
+
continue
|
|
887
|
+
target = safe_project_file(project_root, rel)
|
|
888
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
889
|
+
target.write_text(content, encoding="utf-8")
|
|
890
|
+
written.append(str(target.relative_to(project_root)))
|
|
891
|
+
record = {"timestamp": now_iso(), "project": str(project_root), "message": bundle.get("message"), "files": written}
|
|
892
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
893
|
+
(state_dir / "last-file-write.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
|
894
|
+
append_jsonl(state_dir / "file-writes.jsonl", record)
|
|
895
|
+
return str(bundle.get("message") or "").strip() or None, written
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def prompt_line(stdscr: Any, prompt: str) -> str:
|
|
899
|
+
h, w = stdscr.getmaxyx()
|
|
900
|
+
y = h - 2
|
|
901
|
+
stdscr.nodelay(False)
|
|
902
|
+
curses.echo()
|
|
903
|
+
safe_addstr(stdscr, y, 0, " " * max(0, w - 1))
|
|
904
|
+
safe_addstr(stdscr, y, 0, prompt)
|
|
905
|
+
stdscr.move(y, min(len(prompt), max(0, w - 2)))
|
|
906
|
+
try:
|
|
907
|
+
raw = stdscr.getstr(y, min(len(prompt), max(0, w - 2)), max(1, w - len(prompt) - 2))
|
|
908
|
+
finally:
|
|
909
|
+
curses.noecho()
|
|
910
|
+
stdscr.nodelay(True)
|
|
911
|
+
return raw.decode("utf-8", errors="replace").strip()
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def project_from_state(state_dir: Path, session: dict[str, Any]) -> Path:
|
|
915
|
+
project = session.get("project")
|
|
916
|
+
if isinstance(project, str) and project:
|
|
917
|
+
return Path(project).expanduser().resolve()
|
|
918
|
+
if state_dir.name == ".socra":
|
|
919
|
+
return state_dir.parent.resolve()
|
|
920
|
+
return Path.cwd().resolve()
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def run_chat_turn(stdscr: Any, state_dir: Path, user_text: str) -> None:
|
|
924
|
+
server = read_json(state_dir / "server-state.json") or {}
|
|
925
|
+
session = read_json(state_dir / "session.json") or {}
|
|
926
|
+
candidates = read_json(state_dir / "candidates.json") or {}
|
|
927
|
+
project = project_from_state(state_dir, session)
|
|
928
|
+
chat_path = state_dir / "chat.jsonl"
|
|
929
|
+
append_jsonl(chat_path, {"timestamp": now_iso(), "role": "user", "content": user_text})
|
|
930
|
+
|
|
931
|
+
history = read_chat(chat_path, limit=8)
|
|
932
|
+
messages = [{"role": "system", "content": chat_system_prompt(project, candidates, session)}]
|
|
933
|
+
for row in history:
|
|
934
|
+
if row.get("role") in {"user", "assistant"} and isinstance(row.get("content"), str):
|
|
935
|
+
messages.append({"role": row["role"], "content": row["content"]})
|
|
936
|
+
|
|
937
|
+
h, _w = stdscr.getmaxyx()
|
|
938
|
+
write_status(state_dir, "Model is working...", stage="model_call")
|
|
939
|
+
safe_addstr(stdscr, h - 1, 0, "Model is working locally...")
|
|
940
|
+
stdscr.refresh()
|
|
941
|
+
try:
|
|
942
|
+
wants_files = prompt_wants_files(user_text)
|
|
943
|
+
assistant_text = post_chat(server, messages, json_mode=wants_files)
|
|
944
|
+
write_status(state_dir, "Parsing response...", stage="parse_response")
|
|
945
|
+
command_results: list[dict[str, Any]] = []
|
|
946
|
+
command_errors: list[str] = []
|
|
947
|
+
commands = parse_command_requests(assistant_text)
|
|
948
|
+
bundle_message, written = apply_file_bundle(project, assistant_text, state_dir)
|
|
949
|
+
if commands:
|
|
950
|
+
write_status(state_dir, "Running requested sandbox commands...", stage="sandbox_commands")
|
|
951
|
+
command_results, command_errors = execute_sandbox_commands(project, state_dir, session, commands)
|
|
952
|
+
if wants_files and not written:
|
|
953
|
+
write_status(state_dir, "Repackaging model output into files...", stage="json_repair")
|
|
954
|
+
repaired = repair_file_bundle_with_model(server, project, user_text, assistant_text)
|
|
955
|
+
if repaired:
|
|
956
|
+
bundle_message, written = apply_file_bundle(project, repaired, state_dir)
|
|
957
|
+
assistant_text = repaired
|
|
958
|
+
repaired_commands = parse_command_requests(repaired)
|
|
959
|
+
if repaired_commands:
|
|
960
|
+
write_status(state_dir, "Running requested sandbox commands...", stage="sandbox_commands")
|
|
961
|
+
extra_results, extra_errors = execute_sandbox_commands(project, state_dir, session, repaired_commands)
|
|
962
|
+
command_results.extend(extra_results)
|
|
963
|
+
command_errors.extend(extra_errors)
|
|
964
|
+
if wants_files and not written:
|
|
965
|
+
synthesized = synthesize_file_bundle_from_code_blocks(assistant_text)
|
|
966
|
+
if synthesized:
|
|
967
|
+
bundle_message, written = apply_file_bundle(project, bundle_to_text(synthesized), state_dir)
|
|
968
|
+
display_text = bundle_message or assistant_text
|
|
969
|
+
if written:
|
|
970
|
+
record_agent_stage(state_dir, "creator", "created", files=written)
|
|
971
|
+
write_status(state_dir, "Validating generated files...", stage="validate")
|
|
972
|
+
written, validation_errors = validate_and_repair_output(server, project, state_dir, user_text, written)
|
|
973
|
+
write_status(state_dir, "Launching local preview sandbox...", stage="preview")
|
|
974
|
+
preview_url = launch_static_preview(project, state_dir, written)
|
|
975
|
+
if validation_errors:
|
|
976
|
+
display_text = f"{display_text}\nValidation warnings: {'; '.join(validation_errors[:2])}"
|
|
977
|
+
if preview_url:
|
|
978
|
+
display_text = f"{display_text}\nWrote: {', '.join(written)}\nPreview: {preview_url}"
|
|
979
|
+
else:
|
|
980
|
+
display_text = f"{display_text}\nWrote: {', '.join(written)}"
|
|
981
|
+
if command_results:
|
|
982
|
+
passed = sum(1 for result in command_results if result.get("ok"))
|
|
983
|
+
display_text = f"{display_text}\nSandbox commands: {passed}/{len(command_results)} passed."
|
|
984
|
+
if command_errors:
|
|
985
|
+
display_text = f"{display_text}\nSandbox warnings: {'; '.join(command_errors[:2])}"
|
|
986
|
+
if wants_files and not written and not command_results:
|
|
987
|
+
display_text = (
|
|
988
|
+
"No files were written because the model did not return the required Socra file JSON. "
|
|
989
|
+
"Try again with: create the files and return ONLY the JSON file bundle."
|
|
990
|
+
)
|
|
991
|
+
append_jsonl(chat_path, {"timestamp": now_iso(), "role": "assistant", "content": display_text, "files": written, "commands": command_results})
|
|
992
|
+
write_status(state_dir, "Ready.", stage="idle")
|
|
993
|
+
except Exception as exc:
|
|
994
|
+
append_jsonl(chat_path, {"timestamp": now_iso(), "role": "system", "content": f"Chat error: {exc}"})
|
|
995
|
+
write_status(state_dir, f"Chat error: {exc}", stage="error")
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def draw_chat(stdscr: Any, y: int, state_dir: Path, max_rows: int) -> int:
|
|
999
|
+
safe_addstr(stdscr, y, 0, "Vibe Chat", curses.A_BOLD)
|
|
1000
|
+
y += 1
|
|
1001
|
+
chat = read_chat(state_dir / "chat.jsonl", limit=6)
|
|
1002
|
+
if not chat:
|
|
1003
|
+
safe_addstr(stdscr, y, 2, "Press Enter or c to chat with the loaded local model.")
|
|
1004
|
+
return y + 1
|
|
1005
|
+
rows_used = 0
|
|
1006
|
+
for row in chat[-6:]:
|
|
1007
|
+
if rows_used >= max_rows:
|
|
1008
|
+
break
|
|
1009
|
+
role = row.get("role", "?")
|
|
1010
|
+
content = str(row.get("content") or "").replace("\n", " ")
|
|
1011
|
+
safe_addstr(stdscr, y, 2, f"{role}: {content}")
|
|
1012
|
+
y += 1
|
|
1013
|
+
rows_used += 1
|
|
1014
|
+
return y
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def tui_loop(stdscr: Any, state_dir: Path, refresh: float) -> None:
|
|
1018
|
+
curses.curs_set(0)
|
|
1019
|
+
stdscr.nodelay(True)
|
|
1020
|
+
event_path = state_dir / "events.jsonl"
|
|
1021
|
+
while True:
|
|
1022
|
+
stdscr.erase()
|
|
1023
|
+
h, w = stdscr.getmaxyx()
|
|
1024
|
+
events = read_events(event_path, limit=400)
|
|
1025
|
+
server = read_json(state_dir / "server-state.json") or {}
|
|
1026
|
+
status = read_json(state_dir / "working-status.json") or {}
|
|
1027
|
+
agent_stage = read_json(state_dir / "last-agent-stage.json") or {}
|
|
1028
|
+
preview = read_json(state_dir / "preview-state.json") or {}
|
|
1029
|
+
candidates = read_json(state_dir / "candidates.json") or {}
|
|
1030
|
+
results = read_json(state_dir / "humaneval" / "results.json") or read_json(state_dir / "results.json") or {}
|
|
1031
|
+
done, total, passed = latest_eval_progress(events)
|
|
1032
|
+
|
|
1033
|
+
safe_addstr(stdscr, 0, 0, "Socra Harness Terminal Dashboard", curses.A_BOLD)
|
|
1034
|
+
safe_addstr(stdscr, 1, 0, "Enter/c chat + create files | q quit | live from .socra state")
|
|
1035
|
+
|
|
1036
|
+
y = 3
|
|
1037
|
+
safe_addstr(stdscr, y, 0, "Runtime", curses.A_BOLD)
|
|
1038
|
+
y += 1
|
|
1039
|
+
model_label = server.get("model_alias") or server.get("model", "not running")
|
|
1040
|
+
safe_addstr(stdscr, y, 2, f"Model: {model_label}")
|
|
1041
|
+
y += 1
|
|
1042
|
+
endpoint = server.get("endpoint") or (f"http://{server.get('host', '127.0.0.1')}:{server.get('port', '11439')}/v1" if server else "not running")
|
|
1043
|
+
safe_addstr(stdscr, y, 2, f"Endpoint: {endpoint}")
|
|
1044
|
+
y += 1
|
|
1045
|
+
safe_addstr(stdscr, y, 2, f"Backend: {server.get('backend_endpoint', 'unknown')}")
|
|
1046
|
+
y += 1
|
|
1047
|
+
safe_addstr(
|
|
1048
|
+
stdscr,
|
|
1049
|
+
y,
|
|
1050
|
+
2,
|
|
1051
|
+
f"Harness: {server.get('harness', 'unknown')} | local-first: {server.get('local_first', 'unknown')} | hosted fallback: {server.get('hosted_fallback', 'unknown')}",
|
|
1052
|
+
)
|
|
1053
|
+
y += 2
|
|
1054
|
+
if status.get("message"):
|
|
1055
|
+
safe_addstr(stdscr, y, 2, f"Status: {status.get('message')}")
|
|
1056
|
+
y += 1
|
|
1057
|
+
if agent_stage.get("stage"):
|
|
1058
|
+
safe_addstr(stdscr, y, 2, f"Agent: {agent_stage.get('stage')} / {agent_stage.get('status')}")
|
|
1059
|
+
y += 1
|
|
1060
|
+
if preview.get("url"):
|
|
1061
|
+
safe_addstr(stdscr, y, 2, f"Preview: {preview.get('url')}")
|
|
1062
|
+
y += 1
|
|
1063
|
+
y += 1
|
|
1064
|
+
|
|
1065
|
+
safe_addstr(stdscr, y, 0, "HumanEval", curses.A_BOLD)
|
|
1066
|
+
y += 1
|
|
1067
|
+
fraction = (done / total) if total else 0
|
|
1068
|
+
draw_bar(stdscr, y, 2, min(42, max(10, w - 30)), fraction, f"{done}/{total} tasks, {passed} passed")
|
|
1069
|
+
y += 1
|
|
1070
|
+
if results.get("summary"):
|
|
1071
|
+
summary = results["summary"]
|
|
1072
|
+
safe_addstr(stdscr, y, 2, f"Latest: {summary.get('passed')}/{summary.get('total')} pass@1={pct(summary.get('pass_at_1', 0))}")
|
|
1073
|
+
y += 2
|
|
1074
|
+
else:
|
|
1075
|
+
y += 1
|
|
1076
|
+
|
|
1077
|
+
safe_addstr(stdscr, y, 0, "Repo Scan", curses.A_BOLD)
|
|
1078
|
+
y += 1
|
|
1079
|
+
safe_addstr(stdscr, y, 2, f"Source files: {candidates.get('source_files', 0)} | candidates: {candidates.get('candidate_count', 0)}")
|
|
1080
|
+
y += 1
|
|
1081
|
+
for item in (candidates.get("candidates") or [])[: min(4, max(0, h - y - 12))]:
|
|
1082
|
+
safe_addstr(stdscr, y, 2, f"{item['family']}: {item['file_count']} files, score {item['score_estimate']}")
|
|
1083
|
+
y += 1
|
|
1084
|
+
y += 1
|
|
1085
|
+
|
|
1086
|
+
chat_rows = max(3, min(8, h - y - 8))
|
|
1087
|
+
y = draw_chat(stdscr, y, state_dir, chat_rows)
|
|
1088
|
+
y += 1
|
|
1089
|
+
|
|
1090
|
+
safe_addstr(stdscr, y, 0, "Recent Events", curses.A_BOLD)
|
|
1091
|
+
y += 1
|
|
1092
|
+
for event in events[-min(5, max(0, h - y - 3)):]:
|
|
1093
|
+
label = event.get("event", "event")
|
|
1094
|
+
detail = event.get("task_id") or event.get("folder") or event.get("model") or ""
|
|
1095
|
+
safe_addstr(stdscr, y, 2, f"{label}: {detail}")
|
|
1096
|
+
y += 1
|
|
1097
|
+
|
|
1098
|
+
safe_addstr(stdscr, h - 1, 0, "Enter/c: chat | q: quit")
|
|
1099
|
+
stdscr.refresh()
|
|
1100
|
+
key = stdscr.getch()
|
|
1101
|
+
if key in {ord("q"), ord("Q")}:
|
|
1102
|
+
return
|
|
1103
|
+
if key in {10, 13, ord("c"), ord("C")}:
|
|
1104
|
+
user_text = prompt_line(stdscr, "You: ")
|
|
1105
|
+
if user_text:
|
|
1106
|
+
run_chat_turn(stdscr, state_dir, user_text)
|
|
1107
|
+
continue
|
|
1108
|
+
time.sleep(refresh)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def tui_main(args: Any) -> int:
|
|
1112
|
+
args.state_dir.mkdir(parents=True, exist_ok=True)
|
|
1113
|
+
curses.wrapper(tui_loop, args.state_dir, args.refresh)
|
|
1114
|
+
return 0
|