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,185 @@
|
|
|
1
|
+
"""The four tools coding agents call: the map, a brief, a page and the changes (design sections 23.5 and 23.7).
|
|
2
|
+
|
|
3
|
+
They read only the target's exported `index.json`: never `source/`, the mirror, the guide or the database. They run
|
|
4
|
+
no git, call no assistant and never take the update lock, so a reader's `codetrail update` runs while agents call
|
|
5
|
+
them; each call holds the target's shared in-use lock for its own length, as the page's requests do. Every argument
|
|
6
|
+
is checked before it is used, and a path is only matched against the pages' scopes, never opened.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from contextlib import ExitStack
|
|
15
|
+
from datetime import date
|
|
16
|
+
|
|
17
|
+
from codetrail.agents.convert import limit_words
|
|
18
|
+
from codetrail.agents.export import AgentExport, ExportedPage, read_export
|
|
19
|
+
from codetrail.config import AgentSettings, Paths
|
|
20
|
+
from codetrail.errors import CodetrailError
|
|
21
|
+
from codetrail.generate.outline import in_scope
|
|
22
|
+
from codetrail.guide import PAGE_ID
|
|
23
|
+
from codetrail.lock import target_in_use
|
|
24
|
+
from codetrail.repo.source import safe_path
|
|
25
|
+
|
|
26
|
+
COMMIT = re.compile(r"[0-9a-f]{7,40}")
|
|
27
|
+
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
|
28
|
+
MAX_PATH_CHARS = 1000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolInputError(CodetrailError):
|
|
32
|
+
"""A bad argument: the agent is told what's wrong, as a tool error."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _schema(properties: dict[str, object], required: list[str]) -> dict[str, object]:
|
|
36
|
+
return {"type": "object", "properties": properties, "required": required, "additionalProperties": False}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PAGE = {"type": "string", "description": "A guide page id, such as areas/api or concepts/contract-first"}
|
|
40
|
+
TOOLS: list[dict[str, object]] = [
|
|
41
|
+
{
|
|
42
|
+
"name": "codetrail_map",
|
|
43
|
+
"description": "Codetrail's map of the repository: its parts, decisions, recent changes and guide pages. Call "
|
|
44
|
+
"it before starting a task.",
|
|
45
|
+
"inputSchema": _schema({}, []),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "codetrail_brief",
|
|
49
|
+
"description": "The brief for coding agents of a guide page, or of the pages covering a path: key files, "
|
|
50
|
+
"conventions and the reasons for them. Give a page or a path, not both.",
|
|
51
|
+
"inputSchema": _schema(
|
|
52
|
+
{"page": PAGE, "path": {"type": "string", "description": "A path relative to the repository's root"}}, []
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "codetrail_page",
|
|
57
|
+
"description": "A whole guide page, for the full explanation behind a brief.",
|
|
58
|
+
"inputSchema": _schema({"page": PAGE}, ["page"]),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "codetrail_changes",
|
|
62
|
+
"description": "The guide's digests of what changed since a commit or a date, newest first.",
|
|
63
|
+
"inputSchema": _schema(
|
|
64
|
+
{
|
|
65
|
+
"since": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "A commit (7 to 40 lower-case hex characters) or a date (YYYY-MM-DD)",
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
["since"],
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
ARGUMENTS = {tool["name"]: set(tool["inputSchema"]["properties"]) for tool in TOOLS} # type: ignore[index]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AgentTools:
|
|
78
|
+
def __init__(self, paths: Paths, name: str, settings: AgentSettings, repository: str) -> None:
|
|
79
|
+
self.paths = paths
|
|
80
|
+
self.name = name
|
|
81
|
+
self.settings = settings
|
|
82
|
+
self.repository = repository
|
|
83
|
+
|
|
84
|
+
def definitions(self) -> list[dict[str, object]]:
|
|
85
|
+
"""The tools, each saying which repository it covers: the server is registered for the reader, so agents
|
|
86
|
+
working in other repositories see it too."""
|
|
87
|
+
scope = f"For {self.name}, the repository at {self.repository}, only; in any other repository don't call it. "
|
|
88
|
+
return [{**tool, "description": scope + str(tool["description"])} for tool in TOOLS]
|
|
89
|
+
|
|
90
|
+
def call(self, tool: str, arguments: Mapping[str, object]) -> str:
|
|
91
|
+
"""The tool's answer; raises ToolInputError for a bad argument and KeyError for an unknown tool."""
|
|
92
|
+
handlers: dict[str, Callable[[AgentExport, Mapping[str, object]], str]] = {
|
|
93
|
+
"codetrail_map": lambda export, _arguments: export.map_text,
|
|
94
|
+
"codetrail_brief": self._brief,
|
|
95
|
+
"codetrail_page": lambda export, given: _page(export, given["page"]).text,
|
|
96
|
+
"codetrail_changes": self._changes,
|
|
97
|
+
}
|
|
98
|
+
handler = handlers[tool]
|
|
99
|
+
unknown = set(arguments) - ARGUMENTS[tool]
|
|
100
|
+
if unknown:
|
|
101
|
+
raise ToolInputError(f"{tool} takes no {', '.join(sorted(unknown))}.")
|
|
102
|
+
with ExitStack() as stack:
|
|
103
|
+
try:
|
|
104
|
+
stack.enter_context(target_in_use(self.paths, self.name))
|
|
105
|
+
except CodetrailError:
|
|
106
|
+
return f"Codetrail no longer has the target {self.name!r}."
|
|
107
|
+
export = read_export(self.paths.target_data(self.name) / "agents")
|
|
108
|
+
if export is None:
|
|
109
|
+
return (f"Codetrail has no context for {self.name} yet: run codetrail update {self.name} or "
|
|
110
|
+
f"codetrail agents {self.name}.") # fmt: skip
|
|
111
|
+
answer = handler(export, arguments)
|
|
112
|
+
limit = self.settings.max_response_words
|
|
113
|
+
text, left_out = limit_words(answer.rstrip(), limit)
|
|
114
|
+
if left_out:
|
|
115
|
+
text += f"\n\n(cut at {limit} words; ask for one page with codetrail_page or codetrail_brief.)"
|
|
116
|
+
return f"{text}\n\nDescribes {export.commit[:12]}.\n"
|
|
117
|
+
|
|
118
|
+
def _brief(self, export: AgentExport, arguments: Mapping[str, object]) -> str:
|
|
119
|
+
if ("page" in arguments) == ("path" in arguments):
|
|
120
|
+
raise ToolInputError("Give codetrail_brief either a page or a path, not both.")
|
|
121
|
+
if "page" in arguments:
|
|
122
|
+
page = _page(export, arguments["page"])
|
|
123
|
+
if page.kind == "digest":
|
|
124
|
+
raise ToolInputError(f"{page.id} is a digest; digests have no brief.")
|
|
125
|
+
return page.brief
|
|
126
|
+
path = _path(arguments["path"])
|
|
127
|
+
best: tuple[int, ExportedPage] | None = None
|
|
128
|
+
for page in export.pages:
|
|
129
|
+
if page.kind == "area":
|
|
130
|
+
for scope in page.scope_paths:
|
|
131
|
+
if in_scope(path, [scope]) and (best is None or len(scope.strip("/")) > best[0]):
|
|
132
|
+
best = (len(scope.strip("/")), page)
|
|
133
|
+
concepts = [page for page in export.pages if page.kind == "concept" and in_scope(path, page.scope_paths)]
|
|
134
|
+
chosen = ([best[1]] if best else []) + concepts
|
|
135
|
+
if not chosen:
|
|
136
|
+
return f"No page covers {path}; the map's Pages list what the guide covers."
|
|
137
|
+
return "\n\n".join(page.brief.rstrip() for page in chosen[: self.settings.max_briefs_per_call])
|
|
138
|
+
|
|
139
|
+
def _changes(self, export: AgentExport, arguments: Mapping[str, object]) -> str:
|
|
140
|
+
since = arguments.get("since")
|
|
141
|
+
digests = [page for page in export.pages if page.kind == "digest"]
|
|
142
|
+
notice = ""
|
|
143
|
+
if isinstance(since, str) and DATE.fullmatch(since):
|
|
144
|
+
try:
|
|
145
|
+
date.fromisoformat(since)
|
|
146
|
+
except ValueError as error:
|
|
147
|
+
raise ToolInputError(f"{since} isn't a date.") from error
|
|
148
|
+
found = [page for page in digests if page.written_at[:10] >= since]
|
|
149
|
+
elif isinstance(since, str) and COMMIT.fullmatch(since):
|
|
150
|
+
matches = {commit for commit in export.snapshots if commit.startswith(since)}
|
|
151
|
+
if len(matches) > 1:
|
|
152
|
+
raise ToolInputError(f"{since} matches several commits; give more characters.")
|
|
153
|
+
if matches:
|
|
154
|
+
positions = {commit: index for index, commit in enumerate(export.snapshots)} # each commit's last
|
|
155
|
+
after = positions[matches.pop()]
|
|
156
|
+
found = [page for page in digests if positions.get(page.built_at, -1) > after]
|
|
157
|
+
else:
|
|
158
|
+
notice = f"{since} isn't a commit this guide knows; here are the latest digests.\n\n"
|
|
159
|
+
found = digests[: self.settings.recent_digests]
|
|
160
|
+
else:
|
|
161
|
+
raise ToolInputError("Give a commit (7 to 40 lower-case hexadecimal characters) or a date (YYYY-MM-DD).")
|
|
162
|
+
return notice + ("\n\n".join(page.text.rstrip() for page in found) or "No digests since then.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _page(export: AgentExport, value: object) -> ExportedPage:
|
|
166
|
+
if not isinstance(value, str) or not PAGE_ID.fullmatch(value):
|
|
167
|
+
raise ToolInputError("A page id looks like areas/<name> or concepts/<name>.")
|
|
168
|
+
for page in export.pages:
|
|
169
|
+
if page.id == value:
|
|
170
|
+
return page
|
|
171
|
+
raise ToolInputError(f"{value} isn't a page in this guide; the map's Pages list them.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _path(value: object) -> str:
|
|
175
|
+
if not isinstance(value, str) or len(value) > MAX_PATH_CHARS:
|
|
176
|
+
raise ToolInputError(f"A path is text of at most {MAX_PATH_CHARS} characters.")
|
|
177
|
+
if any(unicodedata.category(character) in ("Cc", "Cf") for character in value):
|
|
178
|
+
raise ToolInputError("A path can't hold control characters.")
|
|
179
|
+
path = value.strip()
|
|
180
|
+
while path.startswith("./"):
|
|
181
|
+
path = path[2:]
|
|
182
|
+
path = path.rstrip("/")
|
|
183
|
+
if not safe_path(path):
|
|
184
|
+
raise ToolInputError(f"{path or value!r} isn't a path relative to the repository's root.")
|
|
185
|
+
return path
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The interface Codetrail needs from an assistant, shaped by Codetrail's tasks, not by any vendor (design 6.9, 15).
|
|
2
|
+
|
|
3
|
+
Each provider (Claude Code, Codex, a local model) has its own adapter, the only code that knows it; tests use the fake.
|
|
4
|
+
Every draft carries the call's usage: its tokens, and its cost when the provider or the configured prices give one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from codetrail.errors import CodetrailError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AssistantError(CodetrailError):
|
|
17
|
+
"""The assistant couldn't complete a task: an error, a limit reached, or no usable answer."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PlanWindow:
|
|
22
|
+
"""How much of a subscription's usage window is used (0 to 1), and when it resets (seconds since the epoch)."""
|
|
23
|
+
|
|
24
|
+
window: str # "five_hour", "seven_day"...
|
|
25
|
+
utilization: float
|
|
26
|
+
resets_at: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Usage:
|
|
31
|
+
"""What one call used: tokens, and its cost in dollars when known (the provider's figure, or tokens x prices).
|
|
32
|
+
|
|
33
|
+
`input_tokens` counts input not read from a cache; `cached_input_tokens` counts input read from one.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
provider: str = ""
|
|
37
|
+
model: str = ""
|
|
38
|
+
input_tokens: int = 0
|
|
39
|
+
cached_input_tokens: int = 0
|
|
40
|
+
output_tokens: int = 0
|
|
41
|
+
cost_usd: float | None = None
|
|
42
|
+
plan_windows: tuple[PlanWindow, ...] = ()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PlanRequest:
|
|
47
|
+
target: str
|
|
48
|
+
facts: str # a rolled-up summary of the facts
|
|
49
|
+
existing_outline: str # YAML, empty on the first run
|
|
50
|
+
uncovered: list[str] = field(default_factory=list) # fact ids in no page's scope
|
|
51
|
+
paths_only: bool = False # propose guided paths through the existing pages, and no new pages
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class PlanDraft:
|
|
56
|
+
pages: list[dict[str, Any]]
|
|
57
|
+
paths: list[dict[str, Any]] = field(default_factory=list)
|
|
58
|
+
files_read: list[str] = field(default_factory=list)
|
|
59
|
+
cost_usd: float = 0.0
|
|
60
|
+
usage: Usage = field(default_factory=Usage)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PageRequest:
|
|
65
|
+
page_id: str
|
|
66
|
+
kind: str
|
|
67
|
+
title: str
|
|
68
|
+
scope_paths: list[str]
|
|
69
|
+
facts: str
|
|
70
|
+
decisions: str
|
|
71
|
+
history: str
|
|
72
|
+
problems: list[str] = field(default_factory=list) # from a failed validation, for the retry
|
|
73
|
+
previous_body: str = ""
|
|
74
|
+
# A revision (design 6.3): the page as it stands and what changed in its scope since it was written. With
|
|
75
|
+
# `changes`, the assistant returns only the sections to replace, and new checks only if the old ones don't fit.
|
|
76
|
+
current_body: str = ""
|
|
77
|
+
current_checks: list[dict[str, Any]] = field(default_factory=list)
|
|
78
|
+
changes: list[str] = field(default_factory=list)
|
|
79
|
+
brief_words: int = 300 # the brief for coding agents (design 23.3) has at most this many words
|
|
80
|
+
current_brief: str = "" # a revision's brief as it stands; empty when the page has none yet
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class PageDraft:
|
|
85
|
+
body: str # empty for a revision
|
|
86
|
+
checks: list[dict[str, Any]] = field(default_factory=list)
|
|
87
|
+
files_read: list[str] = field(default_factory=list)
|
|
88
|
+
cost_usd: float = 0.0
|
|
89
|
+
usage: Usage = field(default_factory=Usage)
|
|
90
|
+
sections: list[dict[str, Any]] = field(default_factory=list) # a revision's sections: {"heading", "body"}
|
|
91
|
+
brief: str = "" # the brief for coding agents; a revision may return none, keeping the page's
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class DigestRequest:
|
|
96
|
+
target: str
|
|
97
|
+
commits: str
|
|
98
|
+
fact_changes: str
|
|
99
|
+
pages_changed: list[str] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class DigestDraft:
|
|
104
|
+
title: str
|
|
105
|
+
body: str
|
|
106
|
+
files_read: list[str] = field(default_factory=list)
|
|
107
|
+
cost_usd: float = 0.0
|
|
108
|
+
usage: Usage = field(default_factory=Usage)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class QuestionRequest:
|
|
113
|
+
target: str
|
|
114
|
+
question: str
|
|
115
|
+
language: str # a validated, installed language code
|
|
116
|
+
page_title: str = ""
|
|
117
|
+
page_body: str = ""
|
|
118
|
+
page_facts: list[str] = field(default_factory=list)
|
|
119
|
+
diagrams: list[str] = field(default_factory=list) # placeholders Codetrail can draw from this guide's facts
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class AnswerChunk:
|
|
124
|
+
"""A piece of a streamed answer; the last one has `done` set, with the files read, the cost and the usage."""
|
|
125
|
+
|
|
126
|
+
text: str = ""
|
|
127
|
+
done: bool = False
|
|
128
|
+
files_read: list[str] = field(default_factory=list)
|
|
129
|
+
cost_usd: float = 0.0
|
|
130
|
+
usage: Usage = field(default_factory=Usage)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class GradeRequest:
|
|
135
|
+
question: str
|
|
136
|
+
rubric: list[dict[str, Any]]
|
|
137
|
+
page_title: str
|
|
138
|
+
page_body: str
|
|
139
|
+
answer: str
|
|
140
|
+
language: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class Verdict:
|
|
145
|
+
verdict: str # "pass", "partial" or "fail"; anything else is an error
|
|
146
|
+
missed: list[str] = field(default_factory=list)
|
|
147
|
+
feedback: str = ""
|
|
148
|
+
cost_usd: float = 0.0
|
|
149
|
+
usage: Usage = field(default_factory=Usage)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Assistant(Protocol):
|
|
153
|
+
async def plan(self, request: PlanRequest) -> PlanDraft: ...
|
|
154
|
+
|
|
155
|
+
async def write_page(self, request: PageRequest) -> PageDraft: ...
|
|
156
|
+
|
|
157
|
+
async def write_digest(self, request: DigestRequest) -> DigestDraft: ...
|
|
158
|
+
|
|
159
|
+
def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]: ...
|
|
160
|
+
|
|
161
|
+
async def grade(self, request: GradeRequest) -> Verdict: ...
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""The claude_code adapter: the reader's own `claude -p`, read-only, on the reader's sign-in (design 15.1, 15.2).
|
|
2
|
+
|
|
3
|
+
Each call runs `claude -p` in `source/` with the prompt on stdin and two independent read-only layers: Claude Code's
|
|
4
|
+
own permissions (only Read, Grep and Glob, allowed only under the working folder, `dontAsk` refusing the rest) and
|
|
5
|
+
Codetrail's tool guard as a PreToolUse hook. No settings, MCP servers or slash commands of the reader's or the
|
|
6
|
+
target's load. The environment comes from an allowlist, so with a subscription no key is visible. Structured answers
|
|
7
|
+
come back through the `StructuredOutput` tool; usage, cost and the plan's usage windows come from the stream.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import sys
|
|
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
|
+
PlanWindow,
|
|
36
|
+
QuestionRequest,
|
|
37
|
+
Usage,
|
|
38
|
+
Verdict,
|
|
39
|
+
runner,
|
|
40
|
+
)
|
|
41
|
+
from codetrail.assistant.environment import child_environment, resolve_program
|
|
42
|
+
from codetrail.assistant.guard import ALLOWED_TOOLS
|
|
43
|
+
from codetrail.assistant.prompts import (
|
|
44
|
+
ANSWER_RULES,
|
|
45
|
+
DIGEST_SCHEMA,
|
|
46
|
+
GRADE_RULES,
|
|
47
|
+
GRADE_SCHEMA,
|
|
48
|
+
GROUND_RULES,
|
|
49
|
+
PAGE_SYNTAX,
|
|
50
|
+
PLAN_SCHEMA,
|
|
51
|
+
answer_prompt,
|
|
52
|
+
digest_prompt,
|
|
53
|
+
grade_prompt,
|
|
54
|
+
neutralize,
|
|
55
|
+
page_task,
|
|
56
|
+
plan_prompt,
|
|
57
|
+
)
|
|
58
|
+
from codetrail.config import ClaudeCodeSettings, GenerationSettings
|
|
59
|
+
|
|
60
|
+
PROVIDER: Final = "claude_code"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _ProgramFailed(AssistantError):
|
|
64
|
+
"""The program ended without a result: worth another try."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class _Outcome:
|
|
69
|
+
result: dict[str, Any] | None = None
|
|
70
|
+
windows: dict[str, PlanWindow] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ClaudeCodeAssistant:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
source_root: Path,
|
|
77
|
+
models: Mapping[str, str],
|
|
78
|
+
settings: ClaudeCodeSettings,
|
|
79
|
+
limits: GenerationSettings,
|
|
80
|
+
retries: int = 2,
|
|
81
|
+
answer_limits: tuple[int, float] = (20, 1.0),
|
|
82
|
+
grade_budget_usd: float = 0.25,
|
|
83
|
+
environ: Mapping[str, str] = os.environ,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.root = source_root.resolve()
|
|
86
|
+
self.models = dict(models)
|
|
87
|
+
self.settings = settings
|
|
88
|
+
self.limits = limits
|
|
89
|
+
self.retries = retries
|
|
90
|
+
self.answer_limits = answer_limits # max turns and budget of one bridge answer
|
|
91
|
+
self.grade_budget_usd = grade_budget_usd
|
|
92
|
+
self.environment = child_environment(PROVIDER, settings.auth, environ)
|
|
93
|
+
self.program = resolve_program(settings.command, self.environment.get("PATH", ""))
|
|
94
|
+
|
|
95
|
+
async def plan(self, request: PlanRequest) -> PlanDraft:
|
|
96
|
+
data, files, usage = await self._run("plan", plan_prompt(request), PLAN_SCHEMA, GROUND_RULES)
|
|
97
|
+
return PlanDraft(list(data.get("pages", [])), list(data.get("paths", [])), files, _cost(usage), 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, files, usage = await self._run("write", prompt, schema, GROUND_RULES + "\n" + PAGE_SYNTAX)
|
|
102
|
+
return PageDraft(str(data.get("body", "")), list(data.get("checks", [])), files, _cost(usage), usage,
|
|
103
|
+
list(data.get("sections", [])), str(data.get("brief", ""))) # fmt: skip
|
|
104
|
+
|
|
105
|
+
async def write_digest(self, request: DigestRequest) -> DigestDraft:
|
|
106
|
+
data, files, usage = await self._run(
|
|
107
|
+
"digest", digest_prompt(request), DIGEST_SCHEMA, GROUND_RULES + "\n" + PAGE_SYNTAX
|
|
108
|
+
)
|
|
109
|
+
return DigestDraft(str(data.get("title", "")), str(data.get("body", "")), files, _cost(usage), usage)
|
|
110
|
+
|
|
111
|
+
async def grade(self, request: GradeRequest) -> Verdict:
|
|
112
|
+
"""Grades with no tools at all: only the check, the rubric, the page and the answer (design section 8.2)."""
|
|
113
|
+
data, _files, usage = await self._run(
|
|
114
|
+
"grade", grade_prompt(request), GRADE_SCHEMA, GRADE_RULES, tools=(), budget_usd=self.grade_budget_usd
|
|
115
|
+
)
|
|
116
|
+
missed = [str(item) for item in data.get("missed", [])]
|
|
117
|
+
return Verdict(str(data.get("verdict", "")), missed, str(data.get("feedback", "")), _cost(usage), usage)
|
|
118
|
+
|
|
119
|
+
async def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]:
|
|
120
|
+
"""Streams the answer's text as Claude writes it, then a final chunk with the files read and the usage."""
|
|
121
|
+
model = self.models["answer"]
|
|
122
|
+
outcome = _Outcome()
|
|
123
|
+
with self._call_files() as (settings_file, read_log):
|
|
124
|
+
command = self._command(
|
|
125
|
+
model,
|
|
126
|
+
None,
|
|
127
|
+
GROUND_RULES + "\n" + PAGE_SYNTAX + "\n" + ANSWER_RULES,
|
|
128
|
+
ALLOWED_TOOLS,
|
|
129
|
+
self.answer_limits[0],
|
|
130
|
+
self.answer_limits[1],
|
|
131
|
+
settings_file,
|
|
132
|
+
partial=True,
|
|
133
|
+
)
|
|
134
|
+
lines = runner.run_program(
|
|
135
|
+
command, neutralize(answer_prompt(request)), self.root, self.environment, self.settings.timeout_seconds
|
|
136
|
+
)
|
|
137
|
+
async with aclosing(lines):
|
|
138
|
+
async for event in _events(lines):
|
|
139
|
+
text = _text_delta(event)
|
|
140
|
+
if text:
|
|
141
|
+
yield AnswerChunk(text=text)
|
|
142
|
+
_note(event, outcome)
|
|
143
|
+
result = _finished(outcome)
|
|
144
|
+
usage = _usage(result, model, outcome)
|
|
145
|
+
yield AnswerChunk(done=True, files_read=_read(read_log), cost_usd=_cost(usage), usage=usage)
|
|
146
|
+
|
|
147
|
+
async def _run(
|
|
148
|
+
self,
|
|
149
|
+
kind: str,
|
|
150
|
+
prompt: str,
|
|
151
|
+
schema: dict[str, Any],
|
|
152
|
+
system_prompt: str,
|
|
153
|
+
tools: tuple[str, ...] = ALLOWED_TOOLS,
|
|
154
|
+
budget_usd: float | None = None,
|
|
155
|
+
) -> tuple[dict[str, Any], list[str], Usage]:
|
|
156
|
+
model = self.models[kind]
|
|
157
|
+
budget = self.limits.max_budget_usd_per_call if budget_usd is None else budget_usd
|
|
158
|
+
last_error: Exception | None = None
|
|
159
|
+
for attempt in range(self.retries + 1):
|
|
160
|
+
if attempt:
|
|
161
|
+
await anyio.sleep(2)
|
|
162
|
+
outcome = _Outcome()
|
|
163
|
+
with self._call_files() as (settings_file, read_log):
|
|
164
|
+
command = self._command(
|
|
165
|
+
model, schema, system_prompt, tools, self.limits.max_turns, budget, settings_file
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
lines = runner.run_program(
|
|
169
|
+
command, neutralize(prompt), self.root, self.environment, self.settings.timeout_seconds
|
|
170
|
+
)
|
|
171
|
+
async with aclosing(lines):
|
|
172
|
+
async for event in _events(lines):
|
|
173
|
+
_note(event, outcome)
|
|
174
|
+
except AssistantError as error:
|
|
175
|
+
if outcome.result is not None:
|
|
176
|
+
raise
|
|
177
|
+
last_error = _ProgramFailed(str(error))
|
|
178
|
+
continue
|
|
179
|
+
result = _finished(outcome)
|
|
180
|
+
data = result.get("structured_output")
|
|
181
|
+
if not isinstance(data, dict):
|
|
182
|
+
raise AssistantError("Claude Code finished without a structured answer.")
|
|
183
|
+
return data, _read(read_log), _usage(result, model, outcome)
|
|
184
|
+
raise last_error or AssistantError("Claude Code couldn't be run.")
|
|
185
|
+
|
|
186
|
+
def _command(
|
|
187
|
+
self,
|
|
188
|
+
model: str,
|
|
189
|
+
schema: dict[str, Any] | None,
|
|
190
|
+
system_prompt: str,
|
|
191
|
+
tools: tuple[str, ...],
|
|
192
|
+
max_turns: int,
|
|
193
|
+
budget_usd: float,
|
|
194
|
+
settings_file: Path,
|
|
195
|
+
partial: bool = False,
|
|
196
|
+
) -> list[str]:
|
|
197
|
+
command = [
|
|
198
|
+
self.program,
|
|
199
|
+
"-p",
|
|
200
|
+
"--output-format",
|
|
201
|
+
"stream-json",
|
|
202
|
+
"--verbose",
|
|
203
|
+
"--tools",
|
|
204
|
+
",".join(tools),
|
|
205
|
+
"--permission-mode",
|
|
206
|
+
"dontAsk",
|
|
207
|
+
"--setting-sources",
|
|
208
|
+
"",
|
|
209
|
+
"--strict-mcp-config",
|
|
210
|
+
"--disable-slash-commands",
|
|
211
|
+
"--no-session-persistence",
|
|
212
|
+
"--settings",
|
|
213
|
+
str(settings_file),
|
|
214
|
+
"--max-turns",
|
|
215
|
+
str(max_turns),
|
|
216
|
+
"--max-budget-usd",
|
|
217
|
+
f"{budget_usd:g}",
|
|
218
|
+
"--model",
|
|
219
|
+
model,
|
|
220
|
+
"--system-prompt",
|
|
221
|
+
system_prompt,
|
|
222
|
+
]
|
|
223
|
+
if tools:
|
|
224
|
+
# Reads are allowed only under the working folder; dontAsk refuses everything else, hook or not.
|
|
225
|
+
command += ["--allowedTools", *(f"{tool}(./**)" for tool in tools)]
|
|
226
|
+
if schema is not None:
|
|
227
|
+
command += ["--json-schema", json.dumps(schema)]
|
|
228
|
+
if partial:
|
|
229
|
+
command.append("--include-partial-messages")
|
|
230
|
+
return command
|
|
231
|
+
|
|
232
|
+
@contextmanager
|
|
233
|
+
def _call_files(self) -> Iterator[tuple[Path, Path]]:
|
|
234
|
+
"""The settings file with the guard hook, and the hook's read log: private, outside `source/`, removed after."""
|
|
235
|
+
with tempfile.TemporaryDirectory(prefix="codetrail-call-") as folder:
|
|
236
|
+
read_log = Path(folder) / "reads.log"
|
|
237
|
+
# -I: the hook must import Codetrail's guard, never a `codetrail` folder in `source/` (the working folder).
|
|
238
|
+
parts = (sys.executable, "-I", "-m", "codetrail.assistant.guard_hook", str(self.root), str(read_log))
|
|
239
|
+
hook = " ".join(shlex.quote(part) for part in parts)
|
|
240
|
+
settings = {
|
|
241
|
+
"hooks": {
|
|
242
|
+
"PreToolUse": [
|
|
243
|
+
{
|
|
244
|
+
"matcher": "*",
|
|
245
|
+
"hooks": [
|
|
246
|
+
{"type": "command", "command": hook, "timeout": self.settings.hook_timeout_seconds}
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
settings_file = Path(folder) / "settings.json"
|
|
253
|
+
descriptor = os.open(settings_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
254
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
255
|
+
json.dump(settings, handle)
|
|
256
|
+
yield settings_file, read_log
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _events(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]:
|
|
260
|
+
"""The stream's JSON events; a line that isn't a JSON object is skipped."""
|
|
261
|
+
async for line in lines:
|
|
262
|
+
try:
|
|
263
|
+
event = json.loads(line)
|
|
264
|
+
except ValueError:
|
|
265
|
+
continue
|
|
266
|
+
if isinstance(event, dict):
|
|
267
|
+
yield event
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _note(event: dict[str, Any], outcome: _Outcome) -> None:
|
|
271
|
+
if event.get("type") == "result":
|
|
272
|
+
outcome.result = event
|
|
273
|
+
elif event.get("type") == "rate_limit_event":
|
|
274
|
+
windows = (event.get("rate_limit_info") or {}).get("unifiedWindows") or {}
|
|
275
|
+
for name, window in windows.items():
|
|
276
|
+
if isinstance(window, dict):
|
|
277
|
+
try:
|
|
278
|
+
outcome.windows[str(name)] = PlanWindow(
|
|
279
|
+
str(name), float(window["utilization"]), int(window["resetsAt"])
|
|
280
|
+
)
|
|
281
|
+
except KeyError, TypeError, ValueError:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _text_delta(event: dict[str, Any]) -> str:
|
|
286
|
+
if event.get("type") != "stream_event":
|
|
287
|
+
return ""
|
|
288
|
+
inner = event.get("event") or {}
|
|
289
|
+
delta = inner.get("delta") or {}
|
|
290
|
+
if inner.get("type") == "content_block_delta" and delta.get("type") == "text_delta":
|
|
291
|
+
return str(delta.get("text", ""))
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _finished(outcome: _Outcome) -> dict[str, Any]:
|
|
296
|
+
result = outcome.result
|
|
297
|
+
if result is None:
|
|
298
|
+
raise AssistantError("Claude Code ended without a result.")
|
|
299
|
+
if result.get("is_error"):
|
|
300
|
+
raise AssistantError(f"Claude Code didn't finish the task ({result.get('subtype', 'error')}).")
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _usage(result: dict[str, Any], model: str, outcome: _Outcome) -> Usage:
|
|
305
|
+
usage = result.get("usage") or {}
|
|
306
|
+
|
|
307
|
+
def count(key: str) -> int:
|
|
308
|
+
value = usage.get(key, 0)
|
|
309
|
+
return int(value) if isinstance(value, int | float) else 0
|
|
310
|
+
|
|
311
|
+
cost = result.get("total_cost_usd")
|
|
312
|
+
return Usage(
|
|
313
|
+
PROVIDER,
|
|
314
|
+
model,
|
|
315
|
+
input_tokens=count("input_tokens") + count("cache_creation_input_tokens"),
|
|
316
|
+
cached_input_tokens=count("cache_read_input_tokens"),
|
|
317
|
+
output_tokens=count("output_tokens"),
|
|
318
|
+
cost_usd=float(cost) if isinstance(cost, int | float) else None,
|
|
319
|
+
plan_windows=tuple(outcome.windows.values()),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _cost(usage: Usage) -> float:
|
|
324
|
+
return usage.cost_usd or 0.0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _read(read_log: Path) -> list[str]:
|
|
328
|
+
if not read_log.exists():
|
|
329
|
+
return []
|
|
330
|
+
return list(dict.fromkeys(line for line in read_log.read_text(encoding="utf-8").splitlines() if line))
|