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,218 @@
|
|
|
1
|
+
"""Validation before a page is saved (design section 6.5).
|
|
2
|
+
|
|
3
|
+
- A documented block cites a visible file with a line range (at most 40 lines) or a commit, and quotes it; the
|
|
4
|
+
quote must appear, whitespace-normalized and contiguous, in those lines or in the commit's message. Commit messages
|
|
5
|
+
are scanned with gitleaks first; a flagged one can't be quoted.
|
|
6
|
+
- Fact links must name existing facts; diagram placeholders must name a known diagram over an existing scope.
|
|
7
|
+
- Checks must have a question and rubric points grounded in existing facts or visible paths.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from codetrail.errors import CodetrailError
|
|
20
|
+
from codetrail.facts import EntityKind, RelationKind
|
|
21
|
+
from codetrail.facts.store import FactStore
|
|
22
|
+
from codetrail.generate.outline import in_scope
|
|
23
|
+
from codetrail.repo.git import run_git
|
|
24
|
+
from codetrail.repo.secrets import SecretScanner
|
|
25
|
+
from codetrail.repo.source import SourceManifest
|
|
26
|
+
|
|
27
|
+
MARKER = re.compile(r"^>\s*\[!(documented|inferred)\]\s*(\S*)\s*$")
|
|
28
|
+
FACT_LINK = re.compile(r"\[\[([a-z_]+:[^\]\s]+)\]\]")
|
|
29
|
+
DIAGRAM = re.compile(r"^\{\{\s*diagram\b(.*?)\}\}\s*$")
|
|
30
|
+
DIAGRAM_KINDS = {"imports": "scope", "dependencies": "project", "resources": "scope", "system": "focus"}
|
|
31
|
+
FILE_CITATION = re.compile(r"^(?P<path>[^#\s]+)#L(?P<start>\d+)(?:-L(?P<end>\d+))?$")
|
|
32
|
+
COMMIT_CITATION = re.compile(r"^commit:(?P<sha>[0-9a-f]{7,40})$")
|
|
33
|
+
MAX_CITED_LINES = 40
|
|
34
|
+
QUOTE_MARKS = "\"'\u201c\u201d\u2018\u2019\u00ab\u00bb" # straight and typographic quotation marks
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class RationaleBlock:
|
|
39
|
+
kind: str
|
|
40
|
+
citation: str | None
|
|
41
|
+
text: str
|
|
42
|
+
line: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ValidationContext:
|
|
47
|
+
source_root: Path
|
|
48
|
+
manifest: SourceManifest
|
|
49
|
+
store: FactStore
|
|
50
|
+
mirror: Path
|
|
51
|
+
scanner: SecretScanner
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_rationale(body: str) -> list[RationaleBlock]:
|
|
55
|
+
blocks = []
|
|
56
|
+
lines = body.splitlines()
|
|
57
|
+
index = 0
|
|
58
|
+
while index < len(lines):
|
|
59
|
+
marker = MARKER.match(lines[index])
|
|
60
|
+
if not marker:
|
|
61
|
+
index += 1
|
|
62
|
+
continue
|
|
63
|
+
start = index
|
|
64
|
+
index += 1
|
|
65
|
+
content = []
|
|
66
|
+
while index < len(lines) and lines[index].startswith(">") and not MARKER.match(lines[index]):
|
|
67
|
+
content.append(lines[index][1:].removeprefix(" "))
|
|
68
|
+
index += 1
|
|
69
|
+
blocks.append(RationaleBlock(marker.group(1), marker.group(2) or None, "\n".join(content).strip(), start + 1))
|
|
70
|
+
return blocks
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize(text: str) -> str:
|
|
74
|
+
return " ".join(text.split())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
FLAGGED_PROBLEM = "The page contains something that looks like a secret"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_page(body: str, checks: Sequence[Mapping[str, Any]] | None, context: ValidationContext) -> list[str]:
|
|
81
|
+
problems: list[str] = []
|
|
82
|
+
if not body.strip():
|
|
83
|
+
return ["The page is empty."]
|
|
84
|
+
# The last check before anything is saved: a page or its checks must hold nothing that looks like a secret.
|
|
85
|
+
for finding in context.scanner.scan_text(body + "\n" + json.dumps(list(checks or []), ensure_ascii=False)):
|
|
86
|
+
problems.append(f"{FLAGGED_PROBLEM} ({finding.rule}); leave it out.")
|
|
87
|
+
if problems: # the other problems quote the draft, and they reach the retry prompt, the CLI and the page
|
|
88
|
+
return problems
|
|
89
|
+
for block in parse_rationale(body):
|
|
90
|
+
if block.kind == "documented":
|
|
91
|
+
problems += _check_documented(block, context)
|
|
92
|
+
for fact in FACT_LINK.findall(body):
|
|
93
|
+
if context.store.entity(fact) is None:
|
|
94
|
+
problems.append(f"The fact link [[{fact}]] names no existing fact.")
|
|
95
|
+
for line in body.splitlines():
|
|
96
|
+
diagram = DIAGRAM.match(line.strip())
|
|
97
|
+
if diagram:
|
|
98
|
+
problems += _check_diagram(diagram.group(1), context)
|
|
99
|
+
if checks is not None:
|
|
100
|
+
problems += _check_checks(checks, context)
|
|
101
|
+
return problems
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate_brief(brief: str, context: ValidationContext, max_words: int) -> list[str]:
|
|
105
|
+
"""A page's brief for coding agents (design 23.3): present, within its words, and valid as a page is. A secret
|
|
106
|
+
finding keeps its own wording, so a flagged revision is still retried from the page as it stood."""
|
|
107
|
+
if not brief.strip():
|
|
108
|
+
return ["The brief is missing."]
|
|
109
|
+
words = len(brief.split())
|
|
110
|
+
if words > max_words:
|
|
111
|
+
return [f"The brief has {words} words; keep it to at most {max_words}."]
|
|
112
|
+
return [
|
|
113
|
+
problem if problem.startswith(FLAGGED_PROBLEM) else f"In the brief: {problem}"
|
|
114
|
+
for problem in validate_page(brief, None, context)
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _check_documented(block: RationaleBlock, context: ValidationContext) -> list[str]:
|
|
119
|
+
quote = normalize(block.text).strip(QUOTE_MARKS + " ")
|
|
120
|
+
where = f"The documented block at line {block.line}"
|
|
121
|
+
if not quote:
|
|
122
|
+
return [f"{where} has no quote."]
|
|
123
|
+
citation = block.citation or ""
|
|
124
|
+
file = FILE_CITATION.match(citation)
|
|
125
|
+
commit = COMMIT_CITATION.match(citation)
|
|
126
|
+
if file:
|
|
127
|
+
path, start = file.group("path"), int(file.group("start"))
|
|
128
|
+
end = int(file.group("end") or start)
|
|
129
|
+
if path not in context.manifest.files:
|
|
130
|
+
return [f"{where} cites {path}, which isn't a visible file."]
|
|
131
|
+
if end < start or end - start + 1 > MAX_CITED_LINES:
|
|
132
|
+
return [f"{where} cites lines {start}-{end}; cite at most {MAX_CITED_LINES} lines, in order."]
|
|
133
|
+
lines = (context.source_root / PurePosixPath(path)).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
134
|
+
if start < 1 or end > len(lines):
|
|
135
|
+
return [f"{where} cites lines {start}-{end}, but {path} has {len(lines)} lines."]
|
|
136
|
+
if quote not in normalize("\n".join(lines[start - 1 : end])):
|
|
137
|
+
return [f"{where}: the quote isn't in {path} lines {start}-{end}. Copy the words exactly from those lines."]
|
|
138
|
+
return []
|
|
139
|
+
if commit:
|
|
140
|
+
message = _commit_message(commit.group("sha"), context)
|
|
141
|
+
if message is None:
|
|
142
|
+
return [f"{where} cites a commit that doesn't exist in the repository."]
|
|
143
|
+
if context.scanner.scan_text(message):
|
|
144
|
+
return [f"{where} cites a commit whose message is withheld (gitleaks flagged it)."]
|
|
145
|
+
if quote not in normalize(message):
|
|
146
|
+
return [f"{where}: the quote isn't in the message of commit {commit.group('sha')}."]
|
|
147
|
+
return []
|
|
148
|
+
return [f"{where} has no valid citation (use path#L<start>-L<end> or commit:<sha>)."]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _commit_message(sha: str, context: ValidationContext) -> str | None:
|
|
152
|
+
try:
|
|
153
|
+
return run_git(["show", "-s", "--format=%B", f"{sha}^{{commit}}"], git_dir=context.mirror).decode(
|
|
154
|
+
"utf-8", "replace"
|
|
155
|
+
)
|
|
156
|
+
except CodetrailError:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _check_diagram(arguments: str, context: ValidationContext) -> list[str]:
|
|
161
|
+
parts = arguments.split()
|
|
162
|
+
if parts == ["system"]:
|
|
163
|
+
has_parts = bool(context.store.entities(EntityKind.PART))
|
|
164
|
+
return [] if has_parts else ["The system diagram would draw nothing: no parts were found."]
|
|
165
|
+
if len(parts) != 2 or parts[0] not in DIAGRAM_KINDS or "=" not in parts[1]:
|
|
166
|
+
return [f"The diagram placeholder '{{{{diagram{arguments}}}}}' isn't a known diagram."]
|
|
167
|
+
kind, (key, value) = parts[0], parts[1].split("=", 1)
|
|
168
|
+
if key != DIAGRAM_KINDS[kind]:
|
|
169
|
+
return [f"The {kind} diagram takes {DIAGRAM_KINDS[kind]}=, not {key}=."]
|
|
170
|
+
if kind in ("imports", "resources") and not any(in_scope(path, [value]) for path in context.manifest.files):
|
|
171
|
+
return [f"The diagram scope {value} holds no visible file."]
|
|
172
|
+
if kind == "dependencies":
|
|
173
|
+
project = context.store.entity(value)
|
|
174
|
+
if project is None or project.kind is not EntityKind.PROJECT:
|
|
175
|
+
return [f"The diagram project {value} isn't a project fact."]
|
|
176
|
+
if not _draws_something(kind, value, context.store):
|
|
177
|
+
return [f"The {kind} diagram for {value} would draw nothing: no facts of that kind are there."]
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _draws_something(kind: str, value: str, store: FactStore) -> bool:
|
|
182
|
+
"""Whether the diagram has at least one fact to draw, so no page shows an empty box."""
|
|
183
|
+
prefix = value.rstrip("/") + "/"
|
|
184
|
+
if kind == "imports":
|
|
185
|
+
kinds = (EntityKind.MODULE, EntityKind.SWIFT_TARGET)
|
|
186
|
+
return any(entity.id.split(":", 1)[1].startswith(prefix) for each in kinds for entity in store.entities(each))
|
|
187
|
+
if kind == "resources":
|
|
188
|
+
folders = (entity.id.split(":", 1)[1] + "/" for entity in store.entities(EntityKind.TERRAFORM_MODULE))
|
|
189
|
+
return any(folder.startswith(prefix) or prefix.startswith(folder) for folder in folders)
|
|
190
|
+
if kind == "system":
|
|
191
|
+
focus = value.strip("/")
|
|
192
|
+
folders = (str(part.attributes.get("folder") or "") for part in store.entities(EntityKind.PART)
|
|
193
|
+
if part.attributes.get("kind") != "platform") # fmt: skip
|
|
194
|
+
return any(folder == focus or folder.startswith(focus + "/") for folder in folders)
|
|
195
|
+
return any(relation.source_id == value for relation in store.relations(RelationKind.DEPENDS_ON))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _check_checks(checks: Sequence[Mapping[str, Any]], context: ValidationContext) -> list[str]:
|
|
199
|
+
if not 1 <= len(checks) <= 6:
|
|
200
|
+
return ["Write two to four checks."]
|
|
201
|
+
problems = []
|
|
202
|
+
for check in checks:
|
|
203
|
+
name = str(check.get("id", "?"))
|
|
204
|
+
if not str(check.get("question", "")).strip():
|
|
205
|
+
problems.append(f"Check {name} has no question.")
|
|
206
|
+
rubric = check.get("rubric") or []
|
|
207
|
+
if not rubric:
|
|
208
|
+
problems.append(f"Check {name} has no rubric.")
|
|
209
|
+
for point in rubric:
|
|
210
|
+
for ground in point.get("grounds") or []:
|
|
211
|
+
ground = str(ground)
|
|
212
|
+
known_fact = context.store.entity(ground) is not None
|
|
213
|
+
known_path = any(in_scope(path, [ground]) for path in context.manifest.files)
|
|
214
|
+
if not (known_fact or known_path):
|
|
215
|
+
problems.append(
|
|
216
|
+
f"Check {name} is grounded in {ground}, which is neither a fact nor a visible path."
|
|
217
|
+
)
|
|
218
|
+
return problems
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The guide: the knowledge base Claude writes, as Markdown with YAML front matter in its own git repository.
|
|
2
|
+
|
|
3
|
+
It lives in Codetrail's data folder, never in a target. An update writes pages into the working tree and commits once;
|
|
4
|
+
if anything fails, the uncommitted changes are discarded (design section 6.8). Front matter is read with
|
|
5
|
+
`yaml.safe_load`, so no tag in it can build an object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from codetrail.errors import CodetrailError
|
|
18
|
+
from codetrail.repo.git import run_git
|
|
19
|
+
|
|
20
|
+
PAGE_ID = re.compile(r"(areas|concepts|paths|digests|answers)/[a-z0-9][a-z0-9-]{0,80}")
|
|
21
|
+
OUTLINE_FILE = "outline.yaml"
|
|
22
|
+
COMMIT_ID = re.compile(r"[0-9a-f]{7,40}")
|
|
23
|
+
# The guide's own commits run no hooks and need no signing key, whatever the user's global git settings say.
|
|
24
|
+
COMMIT_SETTINGS = ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false",
|
|
25
|
+
"-c", "user.name=Codetrail", "-c", "user.email=codetrail@localhost"] # fmt: skip
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Page:
|
|
30
|
+
id: str
|
|
31
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
body: str = ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def kind(self) -> str:
|
|
36
|
+
return str(self.meta.get("kind", ""))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def title(self) -> str:
|
|
40
|
+
return str(self.meta.get("title", self.id))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_page_id(page_id: str) -> str:
|
|
44
|
+
if not PAGE_ID.fullmatch(page_id):
|
|
45
|
+
raise CodetrailError(f"{page_id!r} isn't a valid page id.")
|
|
46
|
+
return page_id
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GuideRepository:
|
|
50
|
+
def __init__(self, root: Path) -> None:
|
|
51
|
+
self.root = root
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def git_dir(self) -> Path:
|
|
55
|
+
return self.root / ".git"
|
|
56
|
+
|
|
57
|
+
def ensure(self) -> None:
|
|
58
|
+
if (self.git_dir / "HEAD").exists():
|
|
59
|
+
return
|
|
60
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
run_git(["init", "-q", "-b", "main", str(self.root)])
|
|
62
|
+
|
|
63
|
+
def _git(self, *arguments: str) -> bytes:
|
|
64
|
+
return run_git([*COMMIT_SETTINGS, *arguments], git_dir=self.git_dir, work_tree=self.root)
|
|
65
|
+
|
|
66
|
+
def path_of(self, page_id: str) -> Path:
|
|
67
|
+
return self.root / f"{validate_page_id(page_id)}.md"
|
|
68
|
+
|
|
69
|
+
def read_page(self, page_id: str) -> Page | None:
|
|
70
|
+
file = self.path_of(page_id)
|
|
71
|
+
if not file.exists():
|
|
72
|
+
return None
|
|
73
|
+
return parse_page(page_id, file.read_text(encoding="utf-8"))
|
|
74
|
+
|
|
75
|
+
def write_page(self, page: Page) -> None:
|
|
76
|
+
file = self.path_of(page.id)
|
|
77
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
file.write_text(render_page(page), encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
def pages(self, kind: str | None = None) -> list[Page]:
|
|
81
|
+
found = []
|
|
82
|
+
for file in sorted(self.root.glob("*/*.md")):
|
|
83
|
+
page_id = f"{file.parent.name}/{file.stem}"
|
|
84
|
+
if PAGE_ID.fullmatch(page_id):
|
|
85
|
+
page = parse_page(page_id, file.read_text(encoding="utf-8"))
|
|
86
|
+
if kind is None or page.kind == kind:
|
|
87
|
+
found.append(page)
|
|
88
|
+
return found
|
|
89
|
+
|
|
90
|
+
def read_outline(self) -> dict[str, Any] | None:
|
|
91
|
+
file = self.root / OUTLINE_FILE
|
|
92
|
+
if not file.exists():
|
|
93
|
+
return None
|
|
94
|
+
data = yaml.safe_load(file.read_text(encoding="utf-8"))
|
|
95
|
+
return data if isinstance(data, dict) else None
|
|
96
|
+
|
|
97
|
+
def write_outline(self, outline: dict[str, Any]) -> None:
|
|
98
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
text = yaml.safe_dump(outline, sort_keys=False, allow_unicode=True)
|
|
100
|
+
(self.root / OUTLINE_FILE).write_text(text, encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
def has_uncommitted_changes(self) -> bool:
|
|
103
|
+
# No optional locks: the page checks this while an update may be committing (design 16.3).
|
|
104
|
+
return bool(self._git("--no-optional-locks", "status", "--porcelain", "--untracked-files=all").strip())
|
|
105
|
+
|
|
106
|
+
def head(self) -> str | None:
|
|
107
|
+
try:
|
|
108
|
+
return self._git("rev-parse", "--verify", "-q", "HEAD").decode().strip() or None
|
|
109
|
+
except CodetrailError:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def commit(self, message: str) -> str | None:
|
|
113
|
+
"""Commits every change and returns the commit, or None when nothing changed."""
|
|
114
|
+
self._git("add", "-A")
|
|
115
|
+
if not self._git("status", "--porcelain").strip():
|
|
116
|
+
return None
|
|
117
|
+
self._git("commit", "-q", "-m", message)
|
|
118
|
+
return self.head()
|
|
119
|
+
|
|
120
|
+
def discard(self) -> None:
|
|
121
|
+
"""Throws away every uncommitted change, back to the last commit."""
|
|
122
|
+
if self.head() is not None:
|
|
123
|
+
self._git("reset", "-q", "--hard", "HEAD")
|
|
124
|
+
self._git("clean", "-q", "-f", "-d")
|
|
125
|
+
|
|
126
|
+
def file_at(self, commit: str, page_id: str) -> str | None:
|
|
127
|
+
if not COMMIT_ID.fullmatch(commit): # only a commit id reaches git, never an option
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
return self._git("show", f"{commit}:{validate_page_id(page_id)}.md").decode("utf-8", "replace")
|
|
131
|
+
except CodetrailError:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_page(page: Page) -> str:
|
|
136
|
+
front = yaml.safe_dump(page.meta, sort_keys=False, allow_unicode=True)
|
|
137
|
+
return f"---\n{front}---\n\n{page.body.rstrip()}\n"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def parse_page(page_id: str, text: str) -> Page:
|
|
141
|
+
if text.startswith("---\n"):
|
|
142
|
+
end = text.find("\n---\n", 4)
|
|
143
|
+
if end != -1:
|
|
144
|
+
meta = yaml.safe_load(text[4 : end + 1]) or {}
|
|
145
|
+
body = text[end + 5 :].lstrip("\n").rstrip()
|
|
146
|
+
return Page(page_id, meta if isinstance(meta, dict) else {}, body)
|
|
147
|
+
return Page(page_id, {}, text.rstrip())
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Learning state: what the reader has read and learned, and what went stale (design section 8.3).
|
|
2
|
+
|
|
3
|
+
A page's version is a hash of its body and checks. Passing a check records its hash, so when an update rewrites a
|
|
4
|
+
page, checks that didn't change stay passed and only new or changed ones need answering again. A page is learned when
|
|
5
|
+
every current check has a pass; a page learned before whose version changed is stale until it is learned again.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from codetrail.guide import Page
|
|
19
|
+
|
|
20
|
+
PASS = "pass" # noqa: S105 - a verdict, not a password
|
|
21
|
+
VERDICTS = ("pass", "partial", "fail")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hash(value: Any) -> str:
|
|
25
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_hash(check: Mapping[str, Any]) -> str:
|
|
29
|
+
return _hash({"question": check.get("question"), "rubric": check.get("rubric")})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def page_version(page: Page) -> str:
|
|
33
|
+
return _hash({"body": page.body, "checks": page.meta.get("checks") or []})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def page_checks(page: Page) -> list[dict[str, Any]]:
|
|
37
|
+
return [check for check in page.meta.get("checks") or [] if isinstance(check, dict) and check.get("id")]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class PageStatus:
|
|
42
|
+
state: str # "unread", "read", "learned" or "stale"
|
|
43
|
+
passed: set[str] = field(default_factory=set)
|
|
44
|
+
learned_commit: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _now() -> str:
|
|
48
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LearningState:
|
|
52
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
53
|
+
self.connection = connection
|
|
54
|
+
|
|
55
|
+
def mark_read(self, page: Page) -> None:
|
|
56
|
+
self.connection.execute(
|
|
57
|
+
"INSERT INTO page_marks (page_id, read_version, read_at) VALUES (?, ?, ?)"
|
|
58
|
+
" ON CONFLICT (page_id) DO UPDATE SET read_version = excluded.read_version, read_at = excluded.read_at",
|
|
59
|
+
(page.id, page_version(page), _now()),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def mark_unread(self, page: Page) -> None:
|
|
63
|
+
"""Takes back a read mark; a learned page stays learned, since that came from its checks."""
|
|
64
|
+
self.connection.execute(
|
|
65
|
+
"UPDATE page_marks SET read_version = NULL, read_at = NULL WHERE page_id = ?", (page.id,)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def record_attempt(
|
|
69
|
+
self,
|
|
70
|
+
page: Page,
|
|
71
|
+
check: Mapping[str, Any],
|
|
72
|
+
answer: str,
|
|
73
|
+
verdict: str,
|
|
74
|
+
feedback: str,
|
|
75
|
+
language: str,
|
|
76
|
+
guide_commit: str | None,
|
|
77
|
+
) -> PageStatus:
|
|
78
|
+
"""Stores an attempt; a pass is recorded for the check's hash, and a page whose checks all pass is learned."""
|
|
79
|
+
digest = check_hash(check)
|
|
80
|
+
self.connection.execute(
|
|
81
|
+
"INSERT INTO check_attempts (page_id, check_id, check_hash, answer, verdict, feedback, language,"
|
|
82
|
+
" attempted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
83
|
+
(page.id, str(check["id"]), digest, answer, verdict, feedback, language, _now()),
|
|
84
|
+
)
|
|
85
|
+
if verdict == PASS:
|
|
86
|
+
self.connection.execute(
|
|
87
|
+
"INSERT OR IGNORE INTO check_passes (page_id, check_id, check_hash, passed_at) VALUES (?, ?, ?, ?)",
|
|
88
|
+
(page.id, str(check["id"]), digest, _now()),
|
|
89
|
+
)
|
|
90
|
+
if self._all_passed(page):
|
|
91
|
+
self.connection.execute(
|
|
92
|
+
"INSERT INTO page_marks (page_id, learned_version, learned_commit, learned_at) VALUES (?, ?, ?, ?)"
|
|
93
|
+
" ON CONFLICT (page_id) DO UPDATE SET learned_version = excluded.learned_version,"
|
|
94
|
+
" learned_commit = excluded.learned_commit, learned_at = excluded.learned_at",
|
|
95
|
+
(page.id, page_version(page), guide_commit, _now()),
|
|
96
|
+
)
|
|
97
|
+
return self.status(page)
|
|
98
|
+
|
|
99
|
+
def _passed(self, page: Page) -> set[str]:
|
|
100
|
+
passes = {
|
|
101
|
+
(row["check_id"], row["check_hash"])
|
|
102
|
+
for row in self.connection.execute(
|
|
103
|
+
"SELECT check_id, check_hash FROM check_passes WHERE page_id = ?", (page.id,)
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return {str(check["id"]) for check in page_checks(page) if (str(check["id"]), check_hash(check)) in passes}
|
|
107
|
+
|
|
108
|
+
def _all_passed(self, page: Page) -> bool:
|
|
109
|
+
checks = page_checks(page)
|
|
110
|
+
return bool(checks) and self._passed(page) == {str(check["id"]) for check in checks}
|
|
111
|
+
|
|
112
|
+
def status(self, page: Page) -> PageStatus:
|
|
113
|
+
row = self.connection.execute("SELECT * FROM page_marks WHERE page_id = ?", (page.id,)).fetchone()
|
|
114
|
+
passed = self._passed(page)
|
|
115
|
+
learned_commit = row["learned_commit"] if row else None
|
|
116
|
+
if row and row["learned_version"]:
|
|
117
|
+
if row["learned_version"] == page_version(page) or self._all_passed(page):
|
|
118
|
+
return PageStatus("learned", passed, learned_commit)
|
|
119
|
+
return PageStatus("stale", passed, learned_commit)
|
|
120
|
+
if row and row["read_version"]:
|
|
121
|
+
return PageStatus("read", passed)
|
|
122
|
+
return PageStatus("unread", passed)
|
|
123
|
+
|
|
124
|
+
def learned_page_ids(self) -> set[str]:
|
|
125
|
+
return {
|
|
126
|
+
row["page_id"]
|
|
127
|
+
for row in self.connection.execute("SELECT page_id FROM page_marks WHERE learned_version IS NOT NULL")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def mark_digest_read(self, digest_id: str) -> None:
|
|
131
|
+
self.connection.execute(
|
|
132
|
+
"INSERT OR IGNORE INTO digest_reads (digest_id, read_at) VALUES (?, ?)", (digest_id, _now())
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def unread_digests(self, digest_ids: Iterable[str]) -> list[str]:
|
|
136
|
+
read = {row["digest_id"] for row in self.connection.execute("SELECT digest_id FROM digest_reads")}
|
|
137
|
+
return [digest for digest in digest_ids if digest not in read]
|
|
138
|
+
|
|
139
|
+
def attempts(self, page_id: str) -> Sequence[sqlite3.Row]:
|
|
140
|
+
return self.connection.execute(
|
|
141
|
+
"SELECT check_id, verdict, feedback, attempted_at FROM check_attempts WHERE page_id = ? ORDER BY id",
|
|
142
|
+
(page_id,),
|
|
143
|
+
).fetchall()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The page's learning routes: marks and checks (design section 8).
|
|
2
|
+
|
|
3
|
+
`POST /learn/read` marks a page or a digest read, and `POST /learn/unread` takes a page's read mark back.
|
|
4
|
+
`POST /learn/checks` sends the reader's answer to the assistant's grading (no tools) and records the attempt; the
|
|
5
|
+
response carries the verdict and the feedback, never the rubric. Feedback that holds something gitleaks flags is
|
|
6
|
+
replaced before it is stored or shown (design section 15.5), and the call's usage is recorded. One grading runs at a
|
|
7
|
+
time. Every route sits behind the security middleware.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
from fastapi import APIRouter
|
|
17
|
+
from fastapi.responses import JSONResponse, Response
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
19
|
+
|
|
20
|
+
from codetrail.assistant import Assistant, AssistantError, GradeRequest
|
|
21
|
+
from codetrail.assistant.usage import UsageLog
|
|
22
|
+
from codetrail.config import Paths, Price, ToolsSettings
|
|
23
|
+
from codetrail.database import connect
|
|
24
|
+
from codetrail.guide import PAGE_ID, GuideRepository, Page
|
|
25
|
+
from codetrail.learn import VERDICTS, LearningState, page_checks
|
|
26
|
+
from codetrail.repo.secrets import SecretScanner
|
|
27
|
+
from codetrail.web.i18n import Language
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReadMark(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
page_id: str = Field(max_length=200)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CheckAnswer(BaseModel):
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
page_id: str = Field(max_length=200)
|
|
38
|
+
check_id: str = Field(max_length=100)
|
|
39
|
+
answer: str = Field(min_length=1, max_length=20_000)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def learning_router(
|
|
43
|
+
paths: Paths,
|
|
44
|
+
name: str,
|
|
45
|
+
assistant_for: Callable[[], Assistant],
|
|
46
|
+
language_of: Callable[[], Language],
|
|
47
|
+
max_answer_chars: int,
|
|
48
|
+
cooldown_seconds: int,
|
|
49
|
+
tools: ToolsSettings,
|
|
50
|
+
prices: Mapping[str, Price] | None = None,
|
|
51
|
+
) -> APIRouter:
|
|
52
|
+
router = APIRouter()
|
|
53
|
+
data = paths.target_data(name)
|
|
54
|
+
guide = GuideRepository(data / "guide")
|
|
55
|
+
grading: dict[str, float | bool] = {"busy": False, "last": -1e18}
|
|
56
|
+
|
|
57
|
+
def find_page(page_id: str) -> Page | None:
|
|
58
|
+
return guide.read_page(page_id) if PAGE_ID.fullmatch(page_id) else None
|
|
59
|
+
|
|
60
|
+
@router.post("/learn/read")
|
|
61
|
+
def mark_read(mark: ReadMark) -> Response:
|
|
62
|
+
_ = language_of().translations.gettext
|
|
63
|
+
page = find_page(mark.page_id)
|
|
64
|
+
if page is None:
|
|
65
|
+
return JSONResponse({"error": _("That page doesn't exist.")}, 404)
|
|
66
|
+
connection = connect(data / "codetrail.db")
|
|
67
|
+
try:
|
|
68
|
+
learning = LearningState(connection)
|
|
69
|
+
if page.kind == "digest":
|
|
70
|
+
learning.mark_digest_read(page.id)
|
|
71
|
+
else:
|
|
72
|
+
learning.mark_read(page)
|
|
73
|
+
finally:
|
|
74
|
+
connection.close()
|
|
75
|
+
return Response(status_code=204)
|
|
76
|
+
|
|
77
|
+
@router.post("/learn/unread")
|
|
78
|
+
def mark_unread(mark: ReadMark) -> Response:
|
|
79
|
+
_ = language_of().translations.gettext
|
|
80
|
+
page = find_page(mark.page_id)
|
|
81
|
+
if page is None or page.kind == "digest":
|
|
82
|
+
return JSONResponse({"error": _("That page doesn't exist.")}, 404)
|
|
83
|
+
connection = connect(data / "codetrail.db")
|
|
84
|
+
try:
|
|
85
|
+
LearningState(connection).mark_unread(page)
|
|
86
|
+
finally:
|
|
87
|
+
connection.close()
|
|
88
|
+
return Response(status_code=204)
|
|
89
|
+
|
|
90
|
+
@router.post("/learn/checks")
|
|
91
|
+
async def answer_check(submitted: CheckAnswer) -> Response:
|
|
92
|
+
language = language_of()
|
|
93
|
+
_ = language.translations.gettext
|
|
94
|
+
page = find_page(submitted.page_id)
|
|
95
|
+
check = next((item for item in page_checks(page) if item["id"] == submitted.check_id), None) if page else None
|
|
96
|
+
if page is None or check is None:
|
|
97
|
+
return JSONResponse({"error": _("That check doesn't exist.")}, 404)
|
|
98
|
+
if len(submitted.answer) > max_answer_chars:
|
|
99
|
+
limit = _("Answers are limited to %(count)s characters.") % {"count": max_answer_chars}
|
|
100
|
+
return JSONResponse({"error": limit}, 413)
|
|
101
|
+
if grading["busy"]: # claimed with no await before it, so two gradings can't both start
|
|
102
|
+
return JSONResponse({"error": _("Another answer is being graded.")}, 429)
|
|
103
|
+
if time.monotonic() - float(grading["last"]) < cooldown_seconds:
|
|
104
|
+
return JSONResponse({"error": _("Wait a few seconds before the next answer.")}, 429)
|
|
105
|
+
grading["busy"], grading["last"] = True, time.monotonic()
|
|
106
|
+
try:
|
|
107
|
+
request = GradeRequest(
|
|
108
|
+
str(check.get("question", "")), list(check.get("rubric") or []), page.title, page.body,
|
|
109
|
+
submitted.answer, language.code,
|
|
110
|
+
) # fmt: skip
|
|
111
|
+
verdict = await assistant_for().grade(request)
|
|
112
|
+
except AssistantError as error:
|
|
113
|
+
return JSONResponse({"error": str(error)}, 502)
|
|
114
|
+
finally:
|
|
115
|
+
grading["busy"] = False
|
|
116
|
+
feedback = verdict.feedback
|
|
117
|
+
findings = await anyio.to_thread.run_sync(SecretScanner(tools).scan_text, feedback)
|
|
118
|
+
if findings:
|
|
119
|
+
withheld = _("The feedback was withheld: it contains something that looks like a secret (%(rule)s).")
|
|
120
|
+
feedback = withheld % {"rule": findings[0].rule}
|
|
121
|
+
if verdict.verdict not in VERDICTS:
|
|
122
|
+
unread = _("The grade couldn't be read, so nothing was recorded; try again.")
|
|
123
|
+
return JSONResponse({"error": unread}, 502)
|
|
124
|
+
connection = connect(data / "codetrail.db")
|
|
125
|
+
try:
|
|
126
|
+
cost = UsageLog(connection, prices or {}).record("grade", verdict.usage) or verdict.cost_usd
|
|
127
|
+
status = LearningState(connection).record_attempt(
|
|
128
|
+
page, check, submitted.answer, verdict.verdict, feedback, language.code, guide.head()
|
|
129
|
+
)
|
|
130
|
+
finally:
|
|
131
|
+
connection.close()
|
|
132
|
+
usage = verdict.usage
|
|
133
|
+
used = {
|
|
134
|
+
"tokens": usage.input_tokens + usage.cached_input_tokens + usage.output_tokens,
|
|
135
|
+
"cost_usd": round(cost, 4),
|
|
136
|
+
}
|
|
137
|
+
return JSONResponse({"verdict": verdict.verdict, "feedback": feedback, "state": status.state, "usage": used})
|
|
138
|
+
|
|
139
|
+
return router
|
|
Binary file
|