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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- 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
|