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.
Files changed (58) hide show
  1. aicortexpy/__init__.py +3 -0
  2. aicortexpy/__main__.py +5 -0
  3. aicortexpy/acp_client.py +559 -0
  4. aicortexpy/acp_conn.py +853 -0
  5. aicortexpy/acp_errors.py +107 -0
  6. aicortexpy/agent_env.py +88 -0
  7. aicortexpy/artifact_ws.py +233 -0
  8. aicortexpy/artifacts.py +67 -0
  9. aicortexpy/attachments.py +85 -0
  10. aicortexpy/auto_commit.py +106 -0
  11. aicortexpy/capabilities.py +92 -0
  12. aicortexpy/chat_acp_pool.py +321 -0
  13. aicortexpy/cli.py +133 -0
  14. aicortexpy/client.py +438 -0
  15. aicortexpy/codex_config.py +98 -0
  16. aicortexpy/config.py +298 -0
  17. aicortexpy/daemon.py +677 -0
  18. aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
  19. aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
  20. aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
  21. aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
  22. aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
  23. aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
  24. aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
  25. aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
  26. aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
  27. aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
  28. aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
  29. aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
  30. aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
  31. aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
  32. aicortexpy/design_seed.py +59 -0
  33. aicortexpy/diskusage.py +209 -0
  34. aicortexpy/execenv.py +264 -0
  35. aicortexpy/gc.py +286 -0
  36. aicortexpy/handoff_bundle.py +65 -0
  37. aicortexpy/health.py +178 -0
  38. aicortexpy/local_skills.py +157 -0
  39. aicortexpy/models.py +385 -0
  40. aicortexpy/openclaw_config.py +153 -0
  41. aicortexpy/pinned_workdir.py +60 -0
  42. aicortexpy/platform_info.py +61 -0
  43. aicortexpy/poisoned.py +44 -0
  44. aicortexpy/process.py +168 -0
  45. aicortexpy/procutil.py +61 -0
  46. aicortexpy/prompt.py +235 -0
  47. aicortexpy/repo.py +462 -0
  48. aicortexpy/runner.py +969 -0
  49. aicortexpy/runtime_config.py +540 -0
  50. aicortexpy/sandbox.py +264 -0
  51. aicortexpy/stream.py +422 -0
  52. aicortexpy/terminal.py +147 -0
  53. aicortexpy/tunnel.py +82 -0
  54. aicortexpy/wakeup.py +222 -0
  55. aicortexpy-0.1.0.dist-info/METADATA +106 -0
  56. aicortexpy-0.1.0.dist-info/RECORD +58 -0
  57. aicortexpy-0.1.0.dist-info/WHEEL +5 -0
  58. aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
@@ -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
@@ -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")