codetrail-cli 1.4.1__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 (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,260 @@
1
+ """The codex adapter: the reader's own `codex exec`, in a read-only sandbox, on the reader's sign-in (design 15.1).
2
+
3
+ Codex's tools are shell commands, and its read-only sandbox prevents writes, not reads, so Codetrail can't confine it to
4
+ `source/`; targets must opt in to it (section 15.5). Each call runs with an empty temporary `HOME`, `/bin/sh`, no
5
+ `USER` or `LOGNAME`, the reader's `CODEX_HOME` for the sign-in, and `-c` overrides that switch off the reader's MCP
6
+ servers, notifications and shell environment and the target's `AGENTS.md`. The prompt goes in on stdin; structured
7
+ answers follow `--output-schema`; tokens come from `turn.completed` events. Answers are buffered, not streamed, so the
8
+ bridge can scan the whole text before the reader sees it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import shlex
16
+ import tempfile
17
+ from collections.abc import AsyncIterator, Iterator, Mapping
18
+ from contextlib import aclosing, contextmanager
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any, Final
22
+
23
+ import anyio
24
+
25
+ from codetrail.assistant import (
26
+ AnswerChunk,
27
+ AssistantError,
28
+ DigestDraft,
29
+ DigestRequest,
30
+ GradeRequest,
31
+ PageDraft,
32
+ PageRequest,
33
+ PlanDraft,
34
+ PlanRequest,
35
+ QuestionRequest,
36
+ Usage,
37
+ Verdict,
38
+ runner,
39
+ )
40
+ from codetrail.assistant.environment import child_environment, resolve_program
41
+ from codetrail.assistant.prompts import (
42
+ ANSWER_RULES,
43
+ DIGEST_SCHEMA,
44
+ GRADE_RULES,
45
+ GRADE_SCHEMA,
46
+ GROUND_RULES,
47
+ PAGE_SYNTAX,
48
+ PLAN_SCHEMA,
49
+ answer_prompt,
50
+ digest_prompt,
51
+ grade_prompt,
52
+ neutralize,
53
+ page_task,
54
+ plan_prompt,
55
+ )
56
+ from codetrail.config import CodexSettings, GenerationSettings
57
+
58
+ PROVIDER: Final = "codex"
59
+ # Switch off what the reader's Codex configuration and the target's instructions could add (design section 15.5).
60
+ ISOLATION = ("mcp_servers={}", "notify=[]", "project_doc_max_bytes=0", 'shell_environment_policy.inherit="none"')
61
+
62
+
63
+ class _ProgramFailed(AssistantError):
64
+ """The program ended without an answer: worth another try."""
65
+
66
+
67
+ @dataclass
68
+ class _Outcome:
69
+ message: str | None = None
70
+ failure: str | None = None
71
+ input_tokens: int = 0
72
+ cached_input_tokens: int = 0
73
+ output_tokens: int = 0
74
+ files_read: list[str] = field(default_factory=list)
75
+
76
+
77
+ class CodexAssistant:
78
+ def __init__(
79
+ self,
80
+ source_root: Path,
81
+ models: Mapping[str, str],
82
+ settings: CodexSettings,
83
+ limits: GenerationSettings,
84
+ retries: int = 2,
85
+ environ: Mapping[str, str] = os.environ,
86
+ ) -> None:
87
+ self.root = source_root.resolve()
88
+ self.models = dict(models)
89
+ self.settings = settings
90
+ self.limits = limits
91
+ self.retries = retries
92
+ self.environ = dict(environ)
93
+ self.program = resolve_program(settings.command, self.environ.get("PATH", ""))
94
+
95
+ async def plan(self, request: PlanRequest) -> PlanDraft:
96
+ data, outcome, usage = await self._run("plan", GROUND_RULES, plan_prompt(request), PLAN_SCHEMA)
97
+ return PlanDraft(list(data.get("pages", [])), list(data.get("paths", [])), outcome.files_read, 0.0, usage)
98
+
99
+ async def write_page(self, request: PageRequest) -> PageDraft:
100
+ prompt, schema = page_task(request) # a new page, or a revision's sections
101
+ data, outcome, usage = await self._run("write", GROUND_RULES + "\n" + PAGE_SYNTAX, prompt, schema)
102
+ return PageDraft(str(data.get("body", "")), list(data.get("checks", [])), outcome.files_read, 0.0, usage,
103
+ list(data.get("sections", [])), str(data.get("brief", ""))) # fmt: skip
104
+
105
+ async def write_digest(self, request: DigestRequest) -> DigestDraft:
106
+ data, outcome, usage = await self._run(
107
+ "digest", GROUND_RULES + "\n" + PAGE_SYNTAX, digest_prompt(request), DIGEST_SCHEMA
108
+ )
109
+ return DigestDraft(str(data.get("title", "")), str(data.get("body", "")), outcome.files_read, 0.0, usage)
110
+
111
+ async def grade(self, request: GradeRequest) -> Verdict:
112
+ data, _outcome, usage = await self._run("grade", GRADE_RULES, grade_prompt(request), GRADE_SCHEMA)
113
+ missed = [str(item) for item in data.get("missed", [])]
114
+ return Verdict(str(data.get("verdict", "")), missed, str(data.get("feedback", "")), 0.0, usage)
115
+
116
+ async def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]:
117
+ outcome = await self._call(
118
+ "answer", GROUND_RULES + "\n" + PAGE_SYNTAX + "\n" + ANSWER_RULES, answer_prompt(request), None
119
+ )
120
+ # One final chunk: the bridge shows it only after scanning it (design section 15.5).
121
+ yield AnswerChunk(outcome.message or "", True, outcome.files_read, usage=self._usage("answer", outcome))
122
+
123
+ async def _run(
124
+ self, kind: str, rules: str, prompt: str, schema: dict[str, Any]
125
+ ) -> tuple[dict[str, Any], _Outcome, Usage]:
126
+ last_error: Exception | None = None
127
+ for attempt in range(self.retries + 1):
128
+ if attempt:
129
+ await anyio.sleep(2)
130
+ try:
131
+ outcome = await self._call(kind, rules, prompt, schema)
132
+ except _ProgramFailed as error:
133
+ last_error = error
134
+ continue
135
+ try:
136
+ data = json.loads(outcome.message or "")
137
+ except ValueError as error:
138
+ raise AssistantError("Codex's answer wasn't the JSON it was asked for.") from error
139
+ if not isinstance(data, dict):
140
+ raise AssistantError("Codex's answer wasn't a JSON object.")
141
+ return data, outcome, self._usage(kind, outcome)
142
+ raise last_error or AssistantError("Codex couldn't be run.")
143
+
144
+ async def _call(self, kind: str, rules: str, prompt: str, schema: dict[str, Any] | None) -> _Outcome:
145
+ outcome = _Outcome()
146
+ with self._call_folder() as folder:
147
+ environment = child_environment(PROVIDER, self.settings.auth, self.environ, empty_home=folder / "home")
148
+ command = [
149
+ self.program,
150
+ "exec",
151
+ "-",
152
+ "--sandbox",
153
+ "read-only",
154
+ "--json",
155
+ "--ephemeral",
156
+ "--skip-git-repo-check",
157
+ "-C",
158
+ str(self.root),
159
+ ]
160
+ for override in ISOLATION:
161
+ command += ["-c", override]
162
+ model = self.models.get(kind, "")
163
+ if model:
164
+ command += ["-m", model]
165
+ if schema is not None:
166
+ schema_file = folder / "schema.json"
167
+ schema_file.write_text(json.dumps(schema), encoding="utf-8")
168
+ command += ["--output-schema", str(schema_file)]
169
+ # Codex has no system prompt flag; Codetrail's rules lead the prompt, above the task.
170
+ text = neutralize(f"{rules}\n\n{prompt}")
171
+ try:
172
+ lines = runner.run_program(command, text, self.root, environment, self.settings.timeout_seconds)
173
+ async with aclosing(lines):
174
+ async for line in lines:
175
+ self._note(line, outcome)
176
+ except _LimitPassed:
177
+ raise
178
+ except AssistantError as error:
179
+ if outcome.message is None:
180
+ raise _ProgramFailed(str(error)) from error
181
+ raise
182
+ if outcome.failure is not None:
183
+ raise AssistantError(f"Codex didn't finish the task: {outcome.failure[:300]}")
184
+ if outcome.message is None:
185
+ raise _ProgramFailed("Codex ended without an answer.")
186
+ return outcome
187
+
188
+ def _note(self, line: str, outcome: _Outcome) -> None:
189
+ try:
190
+ event = json.loads(line)
191
+ except ValueError:
192
+ return
193
+ if not isinstance(event, dict):
194
+ return
195
+ kind = event.get("type")
196
+ found = event.get("item")
197
+ item: dict[str, Any] = found if isinstance(found, dict) else {}
198
+ if kind == "item.completed" and item.get("type") == "agent_message":
199
+ outcome.message = str(item.get("text", ""))
200
+ elif kind == "item.completed" and item.get("type") == "command_execution":
201
+ for path in self._paths_in(str(item.get("command", ""))):
202
+ if path not in outcome.files_read:
203
+ outcome.files_read.append(path)
204
+ elif kind == "turn.completed":
205
+ usage = event.get("usage") or {}
206
+ outcome.input_tokens += _count(usage, "input_tokens")
207
+ outcome.cached_input_tokens += _count(usage, "cached_input_tokens")
208
+ outcome.output_tokens += _count(usage, "output_tokens")
209
+ if outcome.input_tokens + outcome.output_tokens > self.settings.max_tokens_per_call:
210
+ raise _LimitPassed(
211
+ f"Codex passed its token limit ({self.settings.max_tokens_per_call}) and was stopped."
212
+ )
213
+ elif kind == "turn.failed":
214
+ outcome.failure = str((event.get("error") or {}).get("message", "the turn failed"))
215
+ elif kind == "error":
216
+ outcome.failure = str(event.get("message", "an error"))
217
+
218
+ def _paths_in(self, command: str) -> list[str]:
219
+ """Files inside `source/` that a command names: a best-effort record of what Codex read."""
220
+ words = _words(command)
221
+ words += [inner for word in words if " " in word for inner in _words(word)] # bash -lc '<script>'
222
+ found = []
223
+ for word in words:
224
+ candidate = Path(word) if os.path.isabs(word) else self.root / word
225
+ candidate = candidate.resolve()
226
+ if candidate.is_relative_to(self.root) and candidate.is_file():
227
+ found.append(candidate.relative_to(self.root).as_posix())
228
+ return found
229
+
230
+ def _usage(self, kind: str, outcome: _Outcome) -> Usage:
231
+ return Usage(
232
+ PROVIDER,
233
+ self.models.get(kind, ""),
234
+ outcome.input_tokens - outcome.cached_input_tokens,
235
+ outcome.cached_input_tokens,
236
+ outcome.output_tokens,
237
+ )
238
+
239
+ @contextmanager
240
+ def _call_folder(self) -> Iterator[Path]:
241
+ """A private folder outside `source/` for the schema file and the empty `HOME`, removed after the call."""
242
+ with tempfile.TemporaryDirectory(prefix="codetrail-codex-") as folder:
243
+ (Path(folder) / "home").mkdir(mode=0o700)
244
+ yield Path(folder)
245
+
246
+
247
+ class _LimitPassed(AssistantError):
248
+ """The call passed its token limit; not worth another try."""
249
+
250
+
251
+ def _words(command: str) -> list[str]:
252
+ try:
253
+ return shlex.split(command)
254
+ except ValueError:
255
+ return command.split()
256
+
257
+
258
+ def _count(usage: Mapping[str, Any], key: str) -> int:
259
+ value = usage.get(key, 0)
260
+ return int(value) if isinstance(value, int | float) else 0
@@ -0,0 +1,66 @@
1
+ """The environment a provider's program starts with: an allowlist, so no key or token leaks in (design 15.2).
2
+
3
+ With `subscription`, no key variable is passed, so the program can only use its own saved sign-in. With `api_key`, the
4
+ provider's key variables pass by name; Codetrail never reads their values. Codex also gets an empty `HOME` and a plain
5
+ shell, so no profile runs and `~` leads nowhere (section 15.5). Relative `PATH` entries are dropped, and programs are
6
+ resolved to absolute paths before they start.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ from codetrail.assistant import AssistantError
18
+
19
+ Provider = Literal["claude_code", "codex"]
20
+ Auth = Literal["subscription", "api_key"]
21
+
22
+ # Claude Code's sign-in on macOS lives in the Keychain, which needs USER and LOGNAME (checked on 5 October 2026).
23
+ CLAUDE_NAMES = ("HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "TERM", "CLAUDE_CONFIG_DIR")
24
+ CODEX_NAMES = ("LANG", "TMPDIR", "TERM")
25
+ KEY_NAMES: dict[Provider, tuple[str, ...]] = {
26
+ "claude_code": ("ANTHROPIC_API_KEY",),
27
+ "codex": ("OPENAI_API_KEY", "CODEX_API_KEY"),
28
+ }
29
+
30
+
31
+ def safe_path(value: str) -> str:
32
+ """`PATH` without empty or relative entries, which would resolve against the working folder (`source/`)."""
33
+ return os.pathsep.join(entry for entry in value.split(os.pathsep) if entry and os.path.isabs(entry))
34
+
35
+
36
+ def child_environment(
37
+ provider: Provider, auth: Auth, environ: Mapping[str, str] = os.environ, empty_home: Path | None = None
38
+ ) -> dict[str, str]:
39
+ names = CLAUDE_NAMES if provider == "claude_code" else CODEX_NAMES
40
+ environment = {name: environ[name] for name in names if name in environ}
41
+ environment |= {name: value for name, value in environ.items() if name.startswith("LC_")}
42
+ environment["PATH"] = safe_path(environ.get("PATH", ""))
43
+ if provider == "codex":
44
+ if empty_home is None:
45
+ raise AssistantError("Codex needs an empty folder for its HOME.")
46
+ codex_home = environ.get("CODEX_HOME") or str(Path(environ.get("HOME", str(Path.home()))) / ".codex")
47
+ if not os.path.isabs(codex_home):
48
+ raise AssistantError(f"CODEX_HOME must be an absolute path, not {codex_home!r}.")
49
+ environment |= {"HOME": str(empty_home), "SHELL": "/bin/sh", "CODEX_HOME": codex_home}
50
+ if auth == "api_key":
51
+ environment |= {name: environ[name] for name in KEY_NAMES[provider] if name in environ}
52
+ return environment
53
+
54
+
55
+ def resolve_program(command: str, path: str) -> str:
56
+ """The program's absolute path, found only through absolute `PATH` entries."""
57
+ if os.sep in command:
58
+ if not os.path.isabs(command):
59
+ raise AssistantError(f"The program {command!r} must be a name on PATH or an absolute path.")
60
+ if not os.access(command, os.X_OK):
61
+ raise AssistantError(f"{command} isn't installed or isn't executable.")
62
+ return command
63
+ found = shutil.which(command, path=safe_path(path))
64
+ if found is None:
65
+ raise AssistantError(f"{command} isn't installed (it isn't on PATH).")
66
+ return found
@@ -0,0 +1,225 @@
1
+ """Estimates of what assistant calls will use, before they are made; they never call a provider (design 15.4).
2
+
3
+ Tokens per call are the median of the last `estimates.history_size` recorded calls of the same kind, provider and
4
+ model, once there are three; before that, the starting values in `[estimates]`. The cost is the median recorded cost,
5
+ or the tokens at the configured prices; local models cost nothing; a model with no price has tokens only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, field
13
+ from statistics import median
14
+
15
+ from codetrail.assistant import Usage
16
+ from codetrail.assistant.usage import priced
17
+ from codetrail.config import EstimateSettings, Price
18
+
19
+ MIN_HISTORY = 3
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CallEstimate:
24
+ kind: str
25
+ provider: str
26
+ model: str
27
+ input_tokens: int
28
+ output_tokens: int
29
+ cost_usd: float | None # None when the model has no configured price
30
+ from_history: bool
31
+
32
+ @property
33
+ def tokens(self) -> int:
34
+ return self.input_tokens + self.output_tokens
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PlanUsageReading:
39
+ provider: str
40
+ window: str
41
+ utilization: float
42
+ resets_at: int
43
+ observed_at: str
44
+
45
+
46
+ def estimate_call(
47
+ connection: sqlite3.Connection,
48
+ kind: str,
49
+ provider: str,
50
+ model: str,
51
+ settings: EstimateSettings,
52
+ prices: Mapping[str, Price],
53
+ ) -> CallEstimate:
54
+ rows = connection.execute(
55
+ "SELECT input_tokens, cached_input_tokens, output_tokens, cost_usd FROM assistant_calls"
56
+ " WHERE kind = ? AND provider = ? AND model = ? ORDER BY id DESC LIMIT ?",
57
+ (kind, provider, model, settings.history_size),
58
+ ).fetchall()
59
+ if len(rows) >= MIN_HISTORY:
60
+ input_tokens = int(median(row["input_tokens"] + row["cached_input_tokens"] for row in rows))
61
+ output_tokens = int(median(row["output_tokens"] for row in rows))
62
+ costs = [row["cost_usd"] for row in rows if row["cost_usd"] is not None]
63
+ cost = float(median(costs)) if costs else priced(Usage(provider, model, input_tokens, 0, output_tokens), prices)
64
+ return CallEstimate(kind, provider, model, input_tokens, output_tokens, cost, True)
65
+ guess = getattr(settings, kind)
66
+ cost = priced(Usage(provider, model, guess.input, 0, guess.output), prices)
67
+ return CallEstimate(kind, provider, model, guess.input, guess.output, cost, False)
68
+
69
+
70
+ def plan_usage(connection: sqlite3.Connection) -> list[PlanUsageReading]:
71
+ rows = connection.execute(
72
+ "SELECT provider, window, utilization, resets_at, observed_at FROM plan_usage ORDER BY provider, window"
73
+ ).fetchall()
74
+ return [PlanUsageReading(row["provider"], row["window"], float(row["utilization"]), int(row["resets_at"]),
75
+ str(row["observed_at"])) for row in rows] # fmt: skip
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class EstimateLine:
80
+ call: CallEstimate
81
+ expected: int # how many calls are expected
82
+ maximum: int # how many at most
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class PageToWrite:
87
+ """A page the update expects to write, and why: "new", "outline" (its outline entry changed), "update" (`changed`
88
+ of its facts changed in this update) or "catching_up" (they changed in an earlier update)."""
89
+
90
+ title: str
91
+ reason: str
92
+ changed: int = 0
93
+ revise: bool = False # revised (design 6.3): only the sections the changes affect are rewritten
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class UpdateEstimate:
98
+ """What an update's paid calls are expected to use, and at most, before it starts (design section 15.4)."""
99
+
100
+ lines: list[EstimateLine]
101
+ sign_ins: dict[str, str] # provider -> how it is signed in
102
+ plan_usage: list[PlanUsageReading]
103
+ budget_usd: float # the update's own limits: generation.max_budget_usd_per_update
104
+ budget_tokens: int # and generation.max_tokens_per_update, which counts every provider
105
+ pages: list[PageToWrite] = field(default_factory=list) # in the order they're written; empty before an outline
106
+ skipped: list[str] = field(default_factory=list) # titles of pages that failed before and haven't changed
107
+
108
+ @property
109
+ def expected_tokens(self) -> int:
110
+ return sum(line.call.tokens * line.expected for line in self.lines)
111
+
112
+ @property
113
+ def maximum_tokens(self) -> int:
114
+ return min(sum(line.call.tokens * line.maximum for line in self.lines), self.budget_tokens)
115
+
116
+ @property
117
+ def expected_usd(self) -> float | None:
118
+ return _dollars(self.lines, expected=True)
119
+
120
+ @property
121
+ def maximum_usd(self) -> float | None:
122
+ total = _dollars(self.lines, expected=False)
123
+ return None if total is None else min(total, self.budget_usd)
124
+
125
+ def as_json(self) -> dict[str, object]:
126
+ return {
127
+ "lines": [{"kind": line.call.kind, "provider": line.call.provider, "model": line.call.model,
128
+ "expected": line.expected, "maximum": line.maximum, "tokens_each": line.call.tokens,
129
+ "cost_each_usd": line.call.cost_usd, "from_history": line.call.from_history}
130
+ for line in self.lines],
131
+ "expected_tokens": self.expected_tokens, "maximum_tokens": self.maximum_tokens,
132
+ "expected_usd": self.expected_usd, "maximum_usd": self.maximum_usd,
133
+ "budget_usd": self.budget_usd, "budget_tokens": self.budget_tokens,
134
+ "sign_ins": self.sign_ins,
135
+ "pages": [{"title": page.title, "reason": page.reason, "changed": page.changed, "revise": page.revise}
136
+ for page in self.pages],
137
+ "skipped": self.skipped,
138
+ "plan_usage": [{"provider": reading.provider, "window": reading.window,
139
+ "utilization": reading.utilization, "resets_at": reading.resets_at,
140
+ "observed_at": reading.observed_at} for reading in self.plan_usage],
141
+ } # fmt: skip
142
+
143
+
144
+ def estimate_update(
145
+ connection: sqlite3.Connection,
146
+ calls: list[tuple[str, str, str, int, int]], # kind, provider, model, expected, maximum
147
+ settings: EstimateSettings,
148
+ prices: Mapping[str, Price],
149
+ sign_ins: Mapping[str, str],
150
+ budget_usd: float,
151
+ budget_tokens: int,
152
+ pages: list[PageToWrite] | None = None,
153
+ skipped: list[str] | None = None,
154
+ ) -> UpdateEstimate:
155
+ lines = [
156
+ EstimateLine(estimate_call(connection, kind, provider, model, settings, prices), expected, maximum)
157
+ for kind, provider, model, expected, maximum in calls
158
+ if maximum > 0
159
+ ]
160
+ return UpdateEstimate(lines, dict(sign_ins), plan_usage(connection), budget_usd, budget_tokens, pages or [],
161
+ skipped or []) # fmt: skip
162
+
163
+
164
+ def _why(page: PageToWrite) -> str:
165
+ if page.reason == "update":
166
+ return f"{page.changed} of its facts changed in this update"
167
+ reasons = {"new": "a new page", "outline": "its outline entry changed",
168
+ "catching_up": "catching up, since its facts changed after it was written"} # fmt: skip
169
+ return reasons.get(page.reason, page.reason)
170
+
171
+
172
+ def _dollars(lines: list[EstimateLine], expected: bool) -> float | None:
173
+ total = 0.0
174
+ for line in lines:
175
+ count = line.expected if expected else line.maximum
176
+ if count and line.call.cost_usd is None:
177
+ return None
178
+ total += (line.call.cost_usd or 0.0) * count
179
+ return total
180
+
181
+
182
+ def when_text(observed_at: str) -> str:
183
+ """A recorded UTC time for people: 2026-10-05 06:38 UTC."""
184
+ return observed_at[:16].replace("T", " ") + " UTC"
185
+
186
+
187
+ def tokens_text(tokens: int) -> str:
188
+ """A token count for people: 950, 12k, 1.4M."""
189
+ if tokens >= 1_000_000:
190
+ return f"{tokens / 1_000_000:.1f}M"
191
+ if tokens >= 1_000:
192
+ return f"{round(tokens / 1_000)}k"
193
+ return str(tokens)
194
+
195
+
196
+ def describe(estimate: UpdateEstimate) -> list[str]:
197
+ """The estimate as lines of text, for the command line."""
198
+ lines = ["This update will call:"]
199
+ for line in estimate.lines:
200
+ count = str(line.expected) if line.expected == line.maximum else f"{line.expected} to {line.maximum}"
201
+ cost = "free" if line.call.cost_usd == 0 else (
202
+ "no price configured" if line.call.cost_usd is None else f"~${line.call.cost_usd:.2f} each") # fmt: skip
203
+ source = "" if line.call.from_history else " (a starting guess)"
204
+ lines.append(f" {line.call.kind:<7} {line.call.provider} · {line.call.model or 'default model'}: "
205
+ f"{count} call(s), ~{tokens_text(line.call.tokens)} tokens each{source}, {cost}") # fmt: skip
206
+ expected, maximum = estimate.expected_usd, estimate.maximum_usd
207
+ money = "" if expected is None or maximum is None else f", ~${expected:.2f} (at most ${maximum:.2f})"
208
+ lines.append(f"Expected: ~{tokens_text(estimate.expected_tokens)} tokens (at most "
209
+ f"~{tokens_text(estimate.maximum_tokens)}){money} at API prices. "
210
+ f"The update stops at its budget of ${estimate.budget_usd:.2f} "
211
+ f"or {tokens_text(estimate.budget_tokens)} tokens.") # fmt: skip
212
+ if estimate.pages:
213
+ lines.append("Pages, in the order they're written:")
214
+ lines += [f" {page.title}{' (revised)' if page.revise else ''}: {_why(page)}" for page in estimate.pages]
215
+ if estimate.skipped:
216
+ lines.append("Skipped, since they failed last time and nothing in them changed: " + ", ".join(estimate.skipped))
217
+ for provider, method in estimate.sign_ins.items():
218
+ if "subscription" in method.lower():
219
+ lines.append(f"{provider}: {method}, so no charge; the work counts against your plan's usage limits.")
220
+ elif provider != "local":
221
+ lines.append(f"{provider}: {method}, billed to that account at its prices.")
222
+ for reading in estimate.plan_usage:
223
+ lines.append(f"Plan usage ({reading.provider}, {reading.window.replace('_', ' ')}): "
224
+ f"{reading.utilization:.0%}, as of {when_text(reading.observed_at)}.") # fmt: skip
225
+ return lines
@@ -0,0 +1,74 @@
1
+ """A fake assistant for tests: it replays scripted drafts and records every request it was given."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable
6
+ from dataclasses import dataclass, field
7
+
8
+ from codetrail.assistant import (
9
+ AnswerChunk,
10
+ AssistantError,
11
+ DigestDraft,
12
+ DigestRequest,
13
+ GradeRequest,
14
+ PageDraft,
15
+ PageRequest,
16
+ PlanDraft,
17
+ PlanRequest,
18
+ QuestionRequest,
19
+ Verdict,
20
+ )
21
+
22
+ PageScript = Callable[[PageRequest], PageDraft]
23
+
24
+
25
+ @dataclass
26
+ class FakeAssistant:
27
+ plans: list[PlanDraft] = field(default_factory=list)
28
+ pages: dict[str, list[PageDraft | Exception]] = field(default_factory=dict)
29
+ page_writer: PageScript | None = None
30
+ digests: list[DigestDraft] = field(default_factory=list)
31
+ answers: list[list[AnswerChunk] | Exception] = field(default_factory=list)
32
+ verdicts: list[Verdict | Exception] = field(default_factory=list)
33
+ requests: list[PlanRequest | PageRequest | DigestRequest | QuestionRequest | GradeRequest] = field(
34
+ default_factory=list
35
+ )
36
+
37
+ async def plan(self, request: PlanRequest) -> PlanDraft:
38
+ self.requests.append(request)
39
+ if not self.plans:
40
+ raise AssistantError("The fake has no plan scripted.")
41
+ return self.plans.pop(0)
42
+
43
+ async def write_page(self, request: PageRequest) -> PageDraft:
44
+ self.requests.append(request)
45
+ scripted = self.pages.get(request.page_id)
46
+ if scripted:
47
+ draft = scripted.pop(0)
48
+ if isinstance(draft, Exception):
49
+ raise draft
50
+ return draft
51
+ if self.page_writer is not None:
52
+ return self.page_writer(request)
53
+ raise AssistantError(f"The fake has no page scripted for {request.page_id}.")
54
+
55
+ async def write_digest(self, request: DigestRequest) -> DigestDraft:
56
+ self.requests.append(request)
57
+ if not self.digests:
58
+ return DigestDraft(title="Changes", body="Things changed.")
59
+ return self.digests.pop(0)
60
+
61
+ async def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]:
62
+ self.requests.append(request)
63
+ scripted = self.answers.pop(0) if self.answers else [AnswerChunk("An answer."), AnswerChunk(done=True)]
64
+ if isinstance(scripted, Exception):
65
+ raise scripted
66
+ for chunk in scripted:
67
+ yield chunk
68
+
69
+ async def grade(self, request: GradeRequest) -> Verdict:
70
+ self.requests.append(request)
71
+ scripted = self.verdicts.pop(0) if self.verdicts else Verdict("pass", [], "Well explained.")
72
+ if isinstance(scripted, Exception):
73
+ raise scripted
74
+ return scripted