aicortexpy 0.1.0__py3-none-any.whl
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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/acp_errors.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""ACP stderr provider-error sniffing + completed→failed promotion.
|
|
2
|
+
|
|
3
|
+
Port of hermes.go acpProviderErrorSniffer / promoteACPResultOnProviderError
|
|
4
|
+
(aicortex#1952: session/prompt often ends with end_turn even on HTTP 4xx/429).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
_ACP_ERROR_HEADER_RE = re.compile(
|
|
13
|
+
r"(?:⚠️|❌|\[ERROR\]).*(?:BadRequestError|AuthenticationError|RateLimitError|HTTP [0-9]{3}|Non-retryable|API call failed)"
|
|
14
|
+
)
|
|
15
|
+
_ACP_ERROR_DETAIL_RE = re.compile(r"(?:Error:|detail:|Details:)\s*(.+)")
|
|
16
|
+
_ACP_TERMINAL_ERROR_RE = re.compile(
|
|
17
|
+
r"(?:❌|\[ERROR\]|after \d+ retr|Non-retryable|BadRequestError|AuthenticationError)"
|
|
18
|
+
)
|
|
19
|
+
_ACP_AGENT_OUTPUT_TERMINAL_RE = re.compile(r"API call failed after \d+ retr(?:y|ies)")
|
|
20
|
+
_ACP_MAX_ERROR_LINES = 8
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ACPProviderErrorSniffer:
|
|
24
|
+
def __init__(self, provider: str) -> None:
|
|
25
|
+
self.provider = provider
|
|
26
|
+
self._mu = threading.Lock()
|
|
27
|
+
self._remains = b""
|
|
28
|
+
self._lines: list[str] = []
|
|
29
|
+
self._seen: set[str] = set()
|
|
30
|
+
self._terminal = False
|
|
31
|
+
|
|
32
|
+
def write(self, data: bytes) -> int:
|
|
33
|
+
with self._mu:
|
|
34
|
+
buf = self._remains + data
|
|
35
|
+
nl = buf.rfind(b"\n")
|
|
36
|
+
if nl < 0:
|
|
37
|
+
self._remains = buf
|
|
38
|
+
return len(data)
|
|
39
|
+
complete = buf[:nl].decode("utf-8", errors="replace")
|
|
40
|
+
self._remains = buf[nl + 1 :]
|
|
41
|
+
for line in complete.split("\n"):
|
|
42
|
+
line = line.strip()
|
|
43
|
+
if not line:
|
|
44
|
+
continue
|
|
45
|
+
if not (
|
|
46
|
+
_ACP_ERROR_HEADER_RE.search(line) or _ACP_ERROR_DETAIL_RE.search(line)
|
|
47
|
+
):
|
|
48
|
+
continue
|
|
49
|
+
if _ACP_TERMINAL_ERROR_RE.search(line):
|
|
50
|
+
self._terminal = True
|
|
51
|
+
if line in self._seen:
|
|
52
|
+
continue
|
|
53
|
+
self._seen.add(line)
|
|
54
|
+
self._lines.append(line)
|
|
55
|
+
if len(self._lines) > _ACP_MAX_ERROR_LINES:
|
|
56
|
+
self._lines = self._lines[-_ACP_MAX_ERROR_LINES:]
|
|
57
|
+
return len(data)
|
|
58
|
+
|
|
59
|
+
def feed_text(self, text: str) -> None:
|
|
60
|
+
if text and not text.endswith("\n"):
|
|
61
|
+
text = text + "\n"
|
|
62
|
+
self.write(text.encode("utf-8", errors="replace"))
|
|
63
|
+
|
|
64
|
+
def message(self) -> str:
|
|
65
|
+
with self._mu:
|
|
66
|
+
return self._message_locked()
|
|
67
|
+
|
|
68
|
+
def terminal_message(self) -> str:
|
|
69
|
+
with self._mu:
|
|
70
|
+
if not self._terminal:
|
|
71
|
+
return ""
|
|
72
|
+
return self._message_locked()
|
|
73
|
+
|
|
74
|
+
def _message_locked(self) -> str:
|
|
75
|
+
prefix = f"{self.provider} provider error: "
|
|
76
|
+
for line in self._lines:
|
|
77
|
+
m = _ACP_ERROR_DETAIL_RE.search(line)
|
|
78
|
+
if m:
|
|
79
|
+
detail = m.group(1).strip()
|
|
80
|
+
if detail:
|
|
81
|
+
return prefix + detail
|
|
82
|
+
for line in self._lines:
|
|
83
|
+
if _ACP_ERROR_HEADER_RE.search(line):
|
|
84
|
+
return prefix + line
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def promote_acp_result_on_provider_error(
|
|
89
|
+
final_status: str,
|
|
90
|
+
final_error: str,
|
|
91
|
+
final_output: str,
|
|
92
|
+
sniffer: ACPProviderErrorSniffer | None,
|
|
93
|
+
) -> tuple[str, str]:
|
|
94
|
+
if final_status != "completed" or sniffer is None:
|
|
95
|
+
return final_status, final_error
|
|
96
|
+
msg = sniffer.terminal_message()
|
|
97
|
+
if msg:
|
|
98
|
+
return "failed", msg
|
|
99
|
+
m = _ACP_AGENT_OUTPUT_TERMINAL_RE.search(final_output or "")
|
|
100
|
+
if m:
|
|
101
|
+
msg = sniffer.message() or f"{sniffer.provider} provider error: {m.group(0)}"
|
|
102
|
+
return "failed", msg
|
|
103
|
+
if not (final_output or "").strip():
|
|
104
|
+
msg = sniffer.message()
|
|
105
|
+
if msg:
|
|
106
|
+
return "failed", msg
|
|
107
|
+
return final_status, final_error
|
aicortexpy/agent_env.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Inject daemon identity env into spawned agent processes (mirrors Go runTask agentEnv)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .client import Task
|
|
11
|
+
from .config import Config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_BLOCKED_CUSTOM_ENV = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"HOME",
|
|
17
|
+
"PATH",
|
|
18
|
+
"USER",
|
|
19
|
+
"SHELL",
|
|
20
|
+
"TERM",
|
|
21
|
+
"CODEX_HOME",
|
|
22
|
+
"OPENCLAW_CONFIG_PATH",
|
|
23
|
+
"OPENCLAW_INCLUDE_ROOTS",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_blocked_env_key(key: str) -> bool:
|
|
29
|
+
upper = key.upper()
|
|
30
|
+
if upper.startswith("AICORTEX_"):
|
|
31
|
+
return True
|
|
32
|
+
return upper in _BLOCKED_CUSTOM_ENV
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _aicortex_bin_dir() -> str | None:
|
|
36
|
+
which = shutil.which("aicortexpy")
|
|
37
|
+
if which:
|
|
38
|
+
return str(Path(which).resolve().parent)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_agent_environ(
|
|
43
|
+
base: dict[str, str],
|
|
44
|
+
*,
|
|
45
|
+
cfg: Config,
|
|
46
|
+
task: Task,
|
|
47
|
+
task_slot: int = 0,
|
|
48
|
+
extra: dict[str, str] | None = None,
|
|
49
|
+
) -> dict[str, str]:
|
|
50
|
+
"""Merge OS env with daemon credentials, PATH prepend, and safe custom_env."""
|
|
51
|
+
environ = dict(base)
|
|
52
|
+
agent = task.agent or {}
|
|
53
|
+
agent_name = str(agent.get("name") or "agent")
|
|
54
|
+
agent_id = str(agent.get("id") or task.raw.get("agent_id") or "")
|
|
55
|
+
|
|
56
|
+
environ["AICORTEX_TOKEN"] = cfg.token
|
|
57
|
+
environ["AICORTEX_SERVER_URL"] = cfg.server_base_url
|
|
58
|
+
environ["aicortex_PORT"] = str(cfg.health_port)
|
|
59
|
+
environ["AICORTEX_WORKSPACE_ID"] = task.workspace_id or ""
|
|
60
|
+
environ["AICORTEX_AGENT_NAME"] = agent_name
|
|
61
|
+
environ["AICORTEX_AGENT_ID"] = agent_id
|
|
62
|
+
environ["AICORTEX_TASK_ID"] = task.id
|
|
63
|
+
environ["AICORTEX_TASK_SLOT"] = str(task_slot)
|
|
64
|
+
|
|
65
|
+
if task.autopilot_run_id:
|
|
66
|
+
environ["AICORTEX_AUTOPILOT_RUN_ID"] = task.autopilot_run_id
|
|
67
|
+
if task.autopilot_id:
|
|
68
|
+
environ["AICORTEX_AUTOPILOT_ID"] = task.autopilot_id
|
|
69
|
+
if task.quick_create_prompt:
|
|
70
|
+
environ["AICORTEX_QUICK_CREATE_TASK_ID"] = task.id
|
|
71
|
+
|
|
72
|
+
bin_dir = _aicortex_bin_dir()
|
|
73
|
+
if bin_dir:
|
|
74
|
+
prev = environ.get("PATH") or os.environ.get("PATH") or ""
|
|
75
|
+
environ["PATH"] = bin_dir + os.pathsep + prev
|
|
76
|
+
|
|
77
|
+
if extra:
|
|
78
|
+
environ.update({str(k): str(v) for k, v in extra.items()})
|
|
79
|
+
|
|
80
|
+
custom_env = agent.get("custom_env")
|
|
81
|
+
if isinstance(custom_env, dict):
|
|
82
|
+
for k, v in custom_env.items():
|
|
83
|
+
key = str(k)
|
|
84
|
+
if is_blocked_env_key(key):
|
|
85
|
+
continue
|
|
86
|
+
environ[key] = str(v)
|
|
87
|
+
|
|
88
|
+
return environ
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Artifact browse + chunked upload over wakeup WS (Go artifact/*)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("aicortexpy.artifact_ws")
|
|
15
|
+
|
|
16
|
+
MAX_READ = 8 * 1024 * 1024
|
|
17
|
+
UPLOAD_TTL = 300.0
|
|
18
|
+
|
|
19
|
+
SendFn = Callable[[str, dict[str, Any]], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_under_root(root: str, rel: str) -> Path:
|
|
23
|
+
root_path = Path(root).resolve()
|
|
24
|
+
rel = (rel or "").replace("\\", "/").lstrip("/")
|
|
25
|
+
if ".." in rel.split("/"):
|
|
26
|
+
raise ValueError("path escapes root")
|
|
27
|
+
target = (root_path / rel).resolve() if rel else root_path
|
|
28
|
+
if not str(target).startswith(str(root_path)):
|
|
29
|
+
raise ValueError("path escapes root")
|
|
30
|
+
return target
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ArtifactBrowser:
|
|
34
|
+
def __init__(self, send: SendFn | None = None):
|
|
35
|
+
self._send = send
|
|
36
|
+
self._mu = threading.Lock()
|
|
37
|
+
self._uploads: dict[str, dict[str, Any]] = {}
|
|
38
|
+
|
|
39
|
+
def set_send(self, send: SendFn) -> None:
|
|
40
|
+
self._send = send
|
|
41
|
+
|
|
42
|
+
def handle_request(self, payload: dict[str, Any]) -> None:
|
|
43
|
+
threading.Thread(
|
|
44
|
+
target=self._handle, args=(payload,), name="artifact-req", daemon=True
|
|
45
|
+
).start()
|
|
46
|
+
|
|
47
|
+
def _reply(self, resp: dict[str, Any]) -> None:
|
|
48
|
+
if self._send:
|
|
49
|
+
self._send("artifact:response", resp)
|
|
50
|
+
|
|
51
|
+
def _handle(self, payload: dict[str, Any]) -> None:
|
|
52
|
+
req_id = str(payload.get("request_id") or payload.get("requestId") or "")
|
|
53
|
+
op = str(payload.get("op") or "").lower().strip()
|
|
54
|
+
resp: dict[str, Any] = {"request_id": req_id}
|
|
55
|
+
try:
|
|
56
|
+
if op in ("write_start", "write_chunk", "write_finish"):
|
|
57
|
+
if op == "write_start":
|
|
58
|
+
self._write_start(payload)
|
|
59
|
+
return
|
|
60
|
+
if op == "write_chunk":
|
|
61
|
+
self._write_chunk(payload)
|
|
62
|
+
return
|
|
63
|
+
resp = self._write_finish(payload)
|
|
64
|
+
self._reply(resp)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
root = str(payload.get("root_path") or payload.get("rootPath") or "")
|
|
68
|
+
rel = str(payload.get("rel_path") or payload.get("relPath") or "")
|
|
69
|
+
if not root:
|
|
70
|
+
raise ValueError("root path required")
|
|
71
|
+
abs_path = _resolve_under_root(root, rel)
|
|
72
|
+
|
|
73
|
+
if op == "list":
|
|
74
|
+
resp["entries"] = self._list_dir(abs_path, rel)
|
|
75
|
+
elif op == "read":
|
|
76
|
+
body, ctype = self._read_file(abs_path)
|
|
77
|
+
resp["content_type"] = ctype
|
|
78
|
+
if body:
|
|
79
|
+
resp["body"] = base64.b64encode(body).decode("ascii")
|
|
80
|
+
elif op == "write":
|
|
81
|
+
raw = b""
|
|
82
|
+
if payload.get("body"):
|
|
83
|
+
raw = base64.b64decode(str(payload["body"]))
|
|
84
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
abs_path.write_bytes(raw)
|
|
86
|
+
elif op == "delete":
|
|
87
|
+
if abs_path.is_dir():
|
|
88
|
+
import shutil
|
|
89
|
+
|
|
90
|
+
shutil.rmtree(abs_path)
|
|
91
|
+
elif abs_path.exists():
|
|
92
|
+
abs_path.unlink()
|
|
93
|
+
elif op == "rename":
|
|
94
|
+
new_rel = str(payload.get("new_rel_path") or payload.get("newRelPath") or "")
|
|
95
|
+
new_abs = _resolve_under_root(root, new_rel)
|
|
96
|
+
new_abs.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
abs_path.rename(new_abs)
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError(f"unknown artifact op {op!r}")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
resp["error"] = str(e)
|
|
102
|
+
self._reply(resp)
|
|
103
|
+
|
|
104
|
+
def _list_dir(self, abs_path: Path, rel: str) -> list[dict[str, Any]]:
|
|
105
|
+
if not abs_path.is_dir():
|
|
106
|
+
raise ValueError("not a directory")
|
|
107
|
+
entries: list[dict[str, Any]] = []
|
|
108
|
+
for child in sorted(abs_path.iterdir(), key=lambda p: p.name.lower()):
|
|
109
|
+
try:
|
|
110
|
+
st = child.stat()
|
|
111
|
+
except OSError:
|
|
112
|
+
continue
|
|
113
|
+
entries.append(
|
|
114
|
+
{
|
|
115
|
+
"name": child.name,
|
|
116
|
+
"rel_path": f"{rel.rstrip('/')}/{child.name}".lstrip("/") if rel else child.name,
|
|
117
|
+
"is_dir": child.is_dir(),
|
|
118
|
+
"size": st.st_size if child.is_file() else 0,
|
|
119
|
+
"mtime": int(st.st_mtime),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
return entries
|
|
123
|
+
|
|
124
|
+
def _read_file(self, abs_path: Path) -> tuple[bytes, str]:
|
|
125
|
+
if not abs_path.is_file():
|
|
126
|
+
raise ValueError("not a file")
|
|
127
|
+
data = abs_path.read_bytes()
|
|
128
|
+
if len(data) > MAX_READ:
|
|
129
|
+
raise ValueError("file too large")
|
|
130
|
+
ctype = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
|
131
|
+
return data, ctype
|
|
132
|
+
|
|
133
|
+
def _purge(self) -> None:
|
|
134
|
+
now = time.time()
|
|
135
|
+
dead = [k for k, v in self._uploads.items() if now > float(v.get("deadline") or 0)]
|
|
136
|
+
for k in dead:
|
|
137
|
+
sess = self._uploads.pop(k, None)
|
|
138
|
+
if not sess:
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
fh = sess.get("file")
|
|
142
|
+
if fh:
|
|
143
|
+
fh.close()
|
|
144
|
+
tmp = sess.get("tmp")
|
|
145
|
+
if tmp and Path(tmp).exists():
|
|
146
|
+
Path(tmp).unlink()
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def _write_start(self, payload: dict[str, Any]) -> None:
|
|
151
|
+
upload_id = str(payload.get("upload_id") or payload.get("uploadId") or "").strip()
|
|
152
|
+
root = str(payload.get("root_path") or payload.get("rootPath") or "")
|
|
153
|
+
rel = str(payload.get("rel_path") or payload.get("relPath") or "")
|
|
154
|
+
total = int(payload.get("total_size") or payload.get("totalSize") or 0)
|
|
155
|
+
chunks = int(payload.get("chunk_count") or payload.get("chunkCount") or 0)
|
|
156
|
+
resp = {"request_id": str(payload.get("request_id") or "")}
|
|
157
|
+
try:
|
|
158
|
+
if not upload_id:
|
|
159
|
+
raise ValueError("upload id required")
|
|
160
|
+
if total <= 0 or total > MAX_READ:
|
|
161
|
+
raise ValueError("invalid upload size")
|
|
162
|
+
if chunks <= 0:
|
|
163
|
+
raise ValueError("chunk count required")
|
|
164
|
+
if not root:
|
|
165
|
+
raise ValueError("root path required")
|
|
166
|
+
abs_path = _resolve_under_root(root, rel)
|
|
167
|
+
with self._mu:
|
|
168
|
+
self._purge()
|
|
169
|
+
if upload_id in self._uploads:
|
|
170
|
+
raise ValueError("upload session already exists")
|
|
171
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
tmp = str(abs_path) + f".upload-{upload_id}"
|
|
173
|
+
fh = open(tmp, "wb")
|
|
174
|
+
self._uploads[upload_id] = {
|
|
175
|
+
"abs": str(abs_path),
|
|
176
|
+
"tmp": tmp,
|
|
177
|
+
"file": fh,
|
|
178
|
+
"total": total,
|
|
179
|
+
"chunks": chunks,
|
|
180
|
+
"next": 0,
|
|
181
|
+
"written": 0,
|
|
182
|
+
"deadline": time.time() + UPLOAD_TTL,
|
|
183
|
+
}
|
|
184
|
+
except Exception as e:
|
|
185
|
+
resp["error"] = str(e)
|
|
186
|
+
self._reply(resp)
|
|
187
|
+
|
|
188
|
+
def _write_chunk(self, payload: dict[str, Any]) -> None:
|
|
189
|
+
upload_id = str(payload.get("upload_id") or payload.get("uploadId") or "").strip()
|
|
190
|
+
idx = int(payload.get("chunk_index") or payload.get("chunkIndex") or -1)
|
|
191
|
+
resp = {"request_id": str(payload.get("request_id") or "")}
|
|
192
|
+
try:
|
|
193
|
+
raw = base64.b64decode(str(payload.get("body") or ""))
|
|
194
|
+
with self._mu:
|
|
195
|
+
self._purge()
|
|
196
|
+
sess = self._uploads.get(upload_id)
|
|
197
|
+
if not sess:
|
|
198
|
+
raise ValueError("upload session not found")
|
|
199
|
+
if idx != int(sess["next"]):
|
|
200
|
+
raise ValueError(f"unexpected chunk index {idx}")
|
|
201
|
+
fh = sess["file"]
|
|
202
|
+
fh.write(raw)
|
|
203
|
+
sess["written"] = int(sess["written"]) + len(raw)
|
|
204
|
+
sess["next"] = idx + 1
|
|
205
|
+
sess["deadline"] = time.time() + UPLOAD_TTL
|
|
206
|
+
except Exception as e:
|
|
207
|
+
resp["error"] = str(e)
|
|
208
|
+
self._reply(resp)
|
|
209
|
+
|
|
210
|
+
def _write_finish(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
211
|
+
upload_id = str(payload.get("upload_id") or payload.get("uploadId") or "").strip()
|
|
212
|
+
resp: dict[str, Any] = {"request_id": str(payload.get("request_id") or "")}
|
|
213
|
+
with self._mu:
|
|
214
|
+
self._purge()
|
|
215
|
+
sess = self._uploads.pop(upload_id, None)
|
|
216
|
+
if not sess:
|
|
217
|
+
resp["error"] = "upload session not found"
|
|
218
|
+
return resp
|
|
219
|
+
try:
|
|
220
|
+
fh = sess["file"]
|
|
221
|
+
fh.close()
|
|
222
|
+
tmp = Path(sess["tmp"])
|
|
223
|
+
abs_path = Path(sess["abs"])
|
|
224
|
+
if int(sess["written"]) != int(sess["total"]):
|
|
225
|
+
tmp.unlink(missing_ok=True)
|
|
226
|
+
raise ValueError("upload size mismatch")
|
|
227
|
+
if int(sess["next"]) != int(sess["chunks"]):
|
|
228
|
+
tmp.unlink(missing_ok=True)
|
|
229
|
+
raise ValueError("missing chunks")
|
|
230
|
+
os.replace(tmp, abs_path)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
resp["error"] = str(e)
|
|
233
|
+
return resp
|
aicortexpy/artifacts.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Scan HTML artifacts and report metadata to the server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("aicortexpy.artifacts")
|
|
11
|
+
|
|
12
|
+
_SKIP_DIRS = {
|
|
13
|
+
"node_modules",
|
|
14
|
+
".git",
|
|
15
|
+
".next",
|
|
16
|
+
".turbo",
|
|
17
|
+
"dist",
|
|
18
|
+
"build",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
".repos",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def scan_html_artifacts(work_dir: str | Path) -> list[dict[str, str]]:
|
|
25
|
+
root = Path(work_dir)
|
|
26
|
+
if not root.is_dir():
|
|
27
|
+
return []
|
|
28
|
+
items: list[dict[str, str]] = []
|
|
29
|
+
for path in root.rglob("*.html"):
|
|
30
|
+
if any(p in _SKIP_DIRS for p in path.parts):
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
rel = path.relative_to(root).as_posix()
|
|
34
|
+
except ValueError:
|
|
35
|
+
continue
|
|
36
|
+
title = _title_from_html(path) or path.stem
|
|
37
|
+
kind = "preview"
|
|
38
|
+
if "design" in path.parts:
|
|
39
|
+
kind = "design"
|
|
40
|
+
items.append({"rel_path": rel, "kind": kind, "title": title[:120]})
|
|
41
|
+
if len(items) >= 50:
|
|
42
|
+
break
|
|
43
|
+
return items
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _title_from_html(path: Path) -> str:
|
|
47
|
+
try:
|
|
48
|
+
text = path.read_text(encoding="utf-8", errors="replace")[:8000]
|
|
49
|
+
except OSError:
|
|
50
|
+
return ""
|
|
51
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", text, re.I | re.S)
|
|
52
|
+
if not m:
|
|
53
|
+
return ""
|
|
54
|
+
return re.sub(r"\s+", " ", m.group(1)).strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def report_issue_artifacts(
|
|
58
|
+
work_dir: str,
|
|
59
|
+
report: Callable[[list[dict[str, str]]], None],
|
|
60
|
+
) -> None:
|
|
61
|
+
items = scan_html_artifacts(work_dir)
|
|
62
|
+
if not items:
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
report(items)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
log.warning("report artifacts failed: %s", e)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Materialize chat attachments into workdir/_incoming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .client import DaemonClient, Task
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("aicortexpy.attachments")
|
|
12
|
+
|
|
13
|
+
INCOMING_DIR = "_incoming"
|
|
14
|
+
_UNSAFE = re.compile(r'[<>:"|?*\\/]+')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sanitize_filename(name: str) -> str:
|
|
18
|
+
name = (name or "").replace("\\", "/").strip()
|
|
19
|
+
name = name.rsplit("/", 1)[-1].strip()
|
|
20
|
+
if not name or name in (".", ".."):
|
|
21
|
+
return ""
|
|
22
|
+
return _UNSAFE.sub("_", name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def unique_filename(name: str, used: set[str]) -> str:
|
|
26
|
+
if name not in used:
|
|
27
|
+
used.add(name)
|
|
28
|
+
return name
|
|
29
|
+
stem, dot, ext = name.partition(".")
|
|
30
|
+
if not dot:
|
|
31
|
+
stem, ext = name, ""
|
|
32
|
+
else:
|
|
33
|
+
# keep full extension (e.g. .tar.gz → use last suffix only for simplicity)
|
|
34
|
+
parts = name.rsplit(".", 1)
|
|
35
|
+
stem, ext = parts[0], "." + parts[1]
|
|
36
|
+
i = 2
|
|
37
|
+
while True:
|
|
38
|
+
cand = f"{stem}-{i}{ext}"
|
|
39
|
+
if cand not in used:
|
|
40
|
+
used.add(cand)
|
|
41
|
+
return cand
|
|
42
|
+
i += 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def materialize_attachments(
|
|
46
|
+
client: DaemonClient,
|
|
47
|
+
task: Task,
|
|
48
|
+
work_dir: Path,
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
"""Download attachments into work_dir/_incoming. Returns relative paths."""
|
|
51
|
+
atts = task.chat_message_attachments
|
|
52
|
+
if not atts:
|
|
53
|
+
return []
|
|
54
|
+
incoming = work_dir / INCOMING_DIR
|
|
55
|
+
incoming.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
used: set[str] = set()
|
|
57
|
+
rel_paths: list[str] = []
|
|
58
|
+
|
|
59
|
+
for att in atts:
|
|
60
|
+
att_id = str(att.get("id") or "")
|
|
61
|
+
if not att_id:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
body, header_name = client.fetch_attachment_bytes(att_id)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
log.warning("fetch attachment failed id=%s: %s", att_id, e)
|
|
67
|
+
continue
|
|
68
|
+
name = sanitize_filename(str(att.get("filename") or "")) or sanitize_filename(
|
|
69
|
+
header_name
|
|
70
|
+
)
|
|
71
|
+
if not name:
|
|
72
|
+
name = att_id
|
|
73
|
+
name = unique_filename(name, used)
|
|
74
|
+
rel = f"{INCOMING_DIR}/{name}"
|
|
75
|
+
abs_path = work_dir / INCOMING_DIR / name
|
|
76
|
+
try:
|
|
77
|
+
abs_path.write_bytes(body)
|
|
78
|
+
except OSError as e:
|
|
79
|
+
log.warning("write attachment failed path=%s: %s", rel, e)
|
|
80
|
+
continue
|
|
81
|
+
rel_paths.append(rel)
|
|
82
|
+
log.info("materialized attachment id=%s path=%s bytes=%s", att_id, rel, len(body))
|
|
83
|
+
|
|
84
|
+
task.design_incoming_rel_paths = rel_paths
|
|
85
|
+
return rel_paths
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Best-effort auto-commit after successful agent runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("aicortexpy.auto_commit")
|
|
10
|
+
|
|
11
|
+
EXCLUDE = [
|
|
12
|
+
"node_modules",
|
|
13
|
+
".next",
|
|
14
|
+
".turbo",
|
|
15
|
+
"__pycache__",
|
|
16
|
+
"target",
|
|
17
|
+
"dist",
|
|
18
|
+
"build",
|
|
19
|
+
".cache",
|
|
20
|
+
"*.pyc",
|
|
21
|
+
"*.o",
|
|
22
|
+
"*.so",
|
|
23
|
+
"*.dylib",
|
|
24
|
+
".env",
|
|
25
|
+
".env.local",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _git(cwd: Path, *args: str) -> tuple[int, str, str]:
|
|
30
|
+
proc = subprocess.run(
|
|
31
|
+
["git", *args],
|
|
32
|
+
cwd=str(cwd),
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
timeout=120,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_git_repo(work_dir: Path) -> bool:
|
|
42
|
+
git = work_dir / ".git"
|
|
43
|
+
if git.is_dir():
|
|
44
|
+
return True
|
|
45
|
+
if git.is_file():
|
|
46
|
+
try:
|
|
47
|
+
return git.read_text(encoding="utf-8", errors="replace").startswith("gitdir:")
|
|
48
|
+
except OSError:
|
|
49
|
+
return False
|
|
50
|
+
# walk up a bit for nested checkouts
|
|
51
|
+
code, out, _ = _git(work_dir, "rev-parse", "--is-inside-work-tree")
|
|
52
|
+
return code == 0 and "true" in out.lower()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ensure_git_exclude(work_dir: Path) -> None:
|
|
56
|
+
exclude = work_dir / ".git" / "info" / "exclude"
|
|
57
|
+
if not exclude.parent.is_dir():
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else ""
|
|
61
|
+
except OSError:
|
|
62
|
+
return
|
|
63
|
+
lines = existing.splitlines()
|
|
64
|
+
changed = False
|
|
65
|
+
for pat in EXCLUDE:
|
|
66
|
+
if pat not in lines:
|
|
67
|
+
lines.append(pat)
|
|
68
|
+
changed = True
|
|
69
|
+
if changed:
|
|
70
|
+
try:
|
|
71
|
+
exclude.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
72
|
+
except OSError as e:
|
|
73
|
+
log.warning("write exclude failed: %s", e)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def auto_commit_and_push(work_dir: str, task_id: str, agent_name: str) -> None:
|
|
77
|
+
if not work_dir:
|
|
78
|
+
return
|
|
79
|
+
path = Path(work_dir)
|
|
80
|
+
if not path.is_dir() or not _is_git_repo(path):
|
|
81
|
+
return
|
|
82
|
+
ensure_git_exclude(path)
|
|
83
|
+
code, status, err = _git(path, "status", "--porcelain")
|
|
84
|
+
if code != 0:
|
|
85
|
+
log.warning("auto-commit: status failed: %s", err)
|
|
86
|
+
return
|
|
87
|
+
if not status.strip():
|
|
88
|
+
return
|
|
89
|
+
code, _, err = _git(path, "add", "-A")
|
|
90
|
+
if code != 0:
|
|
91
|
+
log.warning("auto-commit: add failed: %s", err)
|
|
92
|
+
return
|
|
93
|
+
code, _, _ = _git(path, "diff", "--cached", "--quiet")
|
|
94
|
+
if code == 0:
|
|
95
|
+
return
|
|
96
|
+
msg = f"aicortex: auto-commit after task {task_id[:8]} ({agent_name})"
|
|
97
|
+
code, _, err = _git(path, "commit", "-m", msg)
|
|
98
|
+
if code != 0:
|
|
99
|
+
log.warning("auto-commit: commit failed: %s", err)
|
|
100
|
+
return
|
|
101
|
+
log.info("auto-commit: committed in %s", work_dir)
|
|
102
|
+
code, _, err = _git(path, "push")
|
|
103
|
+
if code != 0:
|
|
104
|
+
log.debug("auto-commit: push skipped/failed: %s", err[:200])
|
|
105
|
+
else:
|
|
106
|
+
log.info("auto-commit: pushed")
|