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,530 @@
|
|
|
1
|
+
"""Writing the guide during an update (design section 6).
|
|
2
|
+
|
|
3
|
+
The outline is planned on the first run and extended for new facts no page covers; affected pages are ranked and
|
|
4
|
+
written within the budget, `concurrency` at a time; each draft is validated, retried once with its problems, and kept
|
|
5
|
+
out of the guide if it fails again; a digest records what changed. Everything is committed once at the end, and any
|
|
6
|
+
unexpected failure discards the uncommitted changes. Each step is reported to `progress` as it happens, for the
|
|
7
|
+
page's update panel.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import anyio
|
|
19
|
+
|
|
20
|
+
from codetrail.assistant import (
|
|
21
|
+
Assistant,
|
|
22
|
+
AssistantError,
|
|
23
|
+
DigestDraft,
|
|
24
|
+
DigestRequest,
|
|
25
|
+
PageDraft,
|
|
26
|
+
PageRequest,
|
|
27
|
+
PlanDraft,
|
|
28
|
+
PlanRequest,
|
|
29
|
+
)
|
|
30
|
+
from codetrail.assistant.usage import UsageLog
|
|
31
|
+
from codetrail.errors import CodetrailError
|
|
32
|
+
from codetrail.facts import EntityKind, FactDiff
|
|
33
|
+
from codetrail.facts.store import FactStore
|
|
34
|
+
from codetrail.generate.failures import FailedPages
|
|
35
|
+
from codetrail.generate.outline import (
|
|
36
|
+
OutlineEntry,
|
|
37
|
+
OutlinePath,
|
|
38
|
+
outline_data,
|
|
39
|
+
outline_entries,
|
|
40
|
+
outline_paths,
|
|
41
|
+
uncovered_facts,
|
|
42
|
+
validate_outline,
|
|
43
|
+
validate_paths,
|
|
44
|
+
)
|
|
45
|
+
from codetrail.generate.revise import apply_sections
|
|
46
|
+
from codetrail.generate.scope import (
|
|
47
|
+
affected_reason,
|
|
48
|
+
changed_fact_count,
|
|
49
|
+
facts_in_scope,
|
|
50
|
+
page_meta,
|
|
51
|
+
page_snapshot,
|
|
52
|
+
scope_changes,
|
|
53
|
+
scope_hash,
|
|
54
|
+
)
|
|
55
|
+
from codetrail.generate.validate import FLAGGED_PROBLEM, ValidationContext, validate_brief, validate_page
|
|
56
|
+
from codetrail.guide import GuideRepository, Page
|
|
57
|
+
from codetrail.repo.history import Commit, commits_between, recent_commits
|
|
58
|
+
from codetrail.repo.secrets import SecretScanner
|
|
59
|
+
from codetrail.repo.source import SourceManifest
|
|
60
|
+
|
|
61
|
+
MAX_FACT_LINES = 200
|
|
62
|
+
PAGE_ATTEMPTS = 2 # a page that fails validation is written once more
|
|
63
|
+
HISTORY_COMMITS = 25
|
|
64
|
+
|
|
65
|
+
Progress = Callable[[dict[str, object]], None] # one step of an update, such as {"step": "page", "title": ...}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def no_progress(event: dict[str, object]) -> None:
|
|
69
|
+
"""Reports nowhere: the command line prints its summary at the end instead."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class GenerationContext:
|
|
74
|
+
target: str
|
|
75
|
+
guide: GuideRepository
|
|
76
|
+
store: FactStore
|
|
77
|
+
manifest: SourceManifest
|
|
78
|
+
source_root: Path
|
|
79
|
+
mirror: Path
|
|
80
|
+
scanner: SecretScanner
|
|
81
|
+
visible: Callable[[str], bool]
|
|
82
|
+
max_pages: int
|
|
83
|
+
concurrency: int
|
|
84
|
+
max_budget_usd: float
|
|
85
|
+
previous_commit: str | None # the snapshot before this update, or None on the first
|
|
86
|
+
diff: FactDiff
|
|
87
|
+
learned: set[str] = field(default_factory=set) # pages the reader has learned: rewritten first
|
|
88
|
+
usage: UsageLog | None = None # records each call's usage; its costs count against the update's budget
|
|
89
|
+
max_tokens: int | None = None # the update's token budget, which counts every provider, priced or not
|
|
90
|
+
progress: Progress = no_progress
|
|
91
|
+
failed_pages: FailedPages | None = None # pages that failed validation, skipped until their scope changes
|
|
92
|
+
retry_failed: bool = False # try them anyway
|
|
93
|
+
revise_max_changes: int = 0 # an affected page with at most this many changes in its scope is revised (6.3)
|
|
94
|
+
max_brief_words: int = 300 # the brief for coding agents each page carries (23.3)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class GenerationResult:
|
|
99
|
+
written: list[str] = field(default_factory=list)
|
|
100
|
+
failed: list[tuple[str, str]] = field(default_factory=list)
|
|
101
|
+
left_for_later: list[str] = field(default_factory=list)
|
|
102
|
+
skipped: list[str] = field(default_factory=list) # failed before, and nothing in their scope changed since
|
|
103
|
+
outline_problems: list[str] = field(default_factory=list)
|
|
104
|
+
digest: str | None = None
|
|
105
|
+
commit: str | None = None
|
|
106
|
+
cost_usd: float = 0.0
|
|
107
|
+
tokens: int = 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def generate_guide(context: GenerationContext, claude: Assistant) -> GenerationResult:
|
|
111
|
+
guide = context.guide
|
|
112
|
+
guide.ensure()
|
|
113
|
+
if guide.has_uncommitted_changes():
|
|
114
|
+
raise CodetrailError(
|
|
115
|
+
f"The guide at {guide.root} has uncommitted edits; commit or discard them before updating."
|
|
116
|
+
)
|
|
117
|
+
result = GenerationResult()
|
|
118
|
+
try:
|
|
119
|
+
entries = await _outline(context, claude, result)
|
|
120
|
+
selection = select_pages(context, entries)
|
|
121
|
+
to_write = selection.to_write
|
|
122
|
+
result.left_for_later = [entry.id for entry in selection.left_for_later]
|
|
123
|
+
result.skipped = [entry.id for entry in selection.skipped]
|
|
124
|
+
for entry in selection.skipped:
|
|
125
|
+
context.progress({"step": "page_skipped", "id": entry.id, "title": entry.title})
|
|
126
|
+
validation = ValidationContext(context.source_root, context.manifest, context.store, context.mirror,
|
|
127
|
+
context.scanner) # fmt: skip
|
|
128
|
+
limiter = anyio.Semaphore(context.concurrency)
|
|
129
|
+
|
|
130
|
+
async def write(planned: PlannedPage) -> None:
|
|
131
|
+
async with limiter:
|
|
132
|
+
entry = planned.entry
|
|
133
|
+
if _budget_spent(context, result): # the update's total budget, in dollars or tokens, is spent
|
|
134
|
+
result.left_for_later.append(entry.id)
|
|
135
|
+
context.progress({"step": "page_later", "id": entry.id, "title": entry.title})
|
|
136
|
+
return
|
|
137
|
+
await _write_page(planned, context, claude, validation, result)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
async with anyio.create_task_group() as group:
|
|
141
|
+
for planned in to_write:
|
|
142
|
+
group.start_soon(write, planned)
|
|
143
|
+
except ExceptionGroup as errors: # one page's unexpected error is the update's error
|
|
144
|
+
if len(errors.exceptions) == 1:
|
|
145
|
+
raise errors.exceptions[0] from None
|
|
146
|
+
raise
|
|
147
|
+
result.written.sort()
|
|
148
|
+
await _write_digest(context, claude, validation, result)
|
|
149
|
+
result.commit = guide.commit(
|
|
150
|
+
f"Update to {context.manifest.commit[:12]}: {len(result.written)} pages"
|
|
151
|
+
+ (", a digest" if result.digest else "")
|
|
152
|
+
)
|
|
153
|
+
context.progress({"step": "committed", "pages": len(result.written), "failed": len(result.failed),
|
|
154
|
+
"later": len(result.left_for_later)}) # fmt: skip
|
|
155
|
+
except BaseException:
|
|
156
|
+
guide.discard()
|
|
157
|
+
raise
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _outline(context: GenerationContext, claude: Assistant, result: GenerationResult) -> list[OutlineEntry]:
|
|
162
|
+
guide, store = context.guide, context.store
|
|
163
|
+
stored = guide.read_outline()
|
|
164
|
+
entries = outline_entries(stored)
|
|
165
|
+
paths: list[OutlinePath] = []
|
|
166
|
+
if not entries:
|
|
167
|
+
context.progress({"step": "plan"})
|
|
168
|
+
draft = await claude.plan(PlanRequest(context.target, facts_summary(store, context.manifest), ""))
|
|
169
|
+
spent = _spend(context, result, "plan", draft)
|
|
170
|
+
entries, problems = validate_outline(draft.pages, store, context.manifest)
|
|
171
|
+
context.progress({"step": "planned", "pages": len(entries), **spent})
|
|
172
|
+
result.outline_problems += problems
|
|
173
|
+
if not entries:
|
|
174
|
+
raise CodetrailError("The assistant's outline had no page Codetrail could use: " + "; ".join(problems[:3]))
|
|
175
|
+
paths, problems = validate_paths(draft.paths, {entry.id for entry in entries})
|
|
176
|
+
result.outline_problems += problems
|
|
177
|
+
else:
|
|
178
|
+
paths = outline_paths(stored, {entry.id for entry in entries})
|
|
179
|
+
new_facts = set(context.diff.added_entities)
|
|
180
|
+
uncovered = [fact for fact in uncovered_facts(store, entries) if fact in new_facts]
|
|
181
|
+
if uncovered:
|
|
182
|
+
current = json.dumps(outline_data(entries, paths), indent=1)
|
|
183
|
+
context.progress({"step": "plan"})
|
|
184
|
+
draft = await claude.plan(PlanRequest(context.target, facts_summary(store, context.manifest), current,
|
|
185
|
+
uncovered[:200])) # fmt: skip
|
|
186
|
+
spent = _spend(context, result, "plan", draft)
|
|
187
|
+
added, problems = validate_outline(draft.pages, store, context.manifest, existing=entries)
|
|
188
|
+
context.progress({"step": "planned", "pages": len(added), **spent})
|
|
189
|
+
result.outline_problems += problems
|
|
190
|
+
entries = [*entries, *added]
|
|
191
|
+
if stored is not None and "paths" not in stored: # an outline written before paths existed: plan them once
|
|
192
|
+
current = json.dumps(outline_data(entries), indent=1)
|
|
193
|
+
context.progress({"step": "plan"})
|
|
194
|
+
try:
|
|
195
|
+
draft = await claude.plan(PlanRequest(context.target, "", current, paths_only=True))
|
|
196
|
+
except AssistantError as error:
|
|
197
|
+
result.outline_problems.append(f"Guided paths couldn't be planned: {error}")
|
|
198
|
+
else:
|
|
199
|
+
spent = _spend(context, result, "plan", draft)
|
|
200
|
+
context.progress({"step": "planned", "pages": 0, **spent})
|
|
201
|
+
paths, problems = validate_paths(draft.paths, {entry.id for entry in entries})
|
|
202
|
+
result.outline_problems += problems
|
|
203
|
+
if outline_data(entries, paths) != stored:
|
|
204
|
+
guide.write_outline(outline_data(entries, paths))
|
|
205
|
+
_write_paths(context, entries, paths)
|
|
206
|
+
return entries
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _budget_spent(context: GenerationContext, result: GenerationResult) -> bool:
|
|
210
|
+
over_tokens = context.max_tokens is not None and result.tokens >= context.max_tokens
|
|
211
|
+
return result.cost_usd >= context.max_budget_usd or over_tokens
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _spend(
|
|
215
|
+
context: GenerationContext, result: GenerationResult, kind: str, draft: PlanDraft | PageDraft | DigestDraft
|
|
216
|
+
) -> dict[str, object]:
|
|
217
|
+
"""Records the call's usage, and adds its cost (which counts against the update's budget) and tokens.
|
|
218
|
+
|
|
219
|
+
Returns what the call used, for the progress report: its provider, model, tokens and counted cost."""
|
|
220
|
+
usage = draft.usage
|
|
221
|
+
cost = context.usage.record(kind, usage) if context.usage is not None and usage.provider else draft.cost_usd
|
|
222
|
+
tokens = usage.input_tokens + usage.cached_input_tokens + usage.output_tokens
|
|
223
|
+
result.cost_usd += cost
|
|
224
|
+
result.tokens += tokens
|
|
225
|
+
return {"provider": usage.provider, "model": usage.model, "tokens": tokens, "cost_usd": cost}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _write_paths(context: GenerationContext, entries: Sequence[OutlineEntry], paths: Sequence[OutlinePath]) -> None:
|
|
229
|
+
"""Writes each guided path as a page, without Claude: its goal and its steps, by title."""
|
|
230
|
+
titles = {entry.id: entry.title for entry in entries}
|
|
231
|
+
wanted = {path.id for path in paths}
|
|
232
|
+
for page in context.guide.pages("path"):
|
|
233
|
+
if page.id.startswith("paths/") and page.id not in wanted:
|
|
234
|
+
context.guide.path_of(page.id).unlink()
|
|
235
|
+
for path in paths:
|
|
236
|
+
meta = {"id": path.id, "kind": "path", "title": path.title, "generated": True, "goal": path.goal,
|
|
237
|
+
"steps": path.steps} # fmt: skip
|
|
238
|
+
body = f"{path.goal}\n\n" + "\n".join(
|
|
239
|
+
f"{number}. [{titles.get(step, step)}](/pages/{step})" for number, step in enumerate(path.steps, start=1)
|
|
240
|
+
)
|
|
241
|
+
current = context.guide.read_page(path.id)
|
|
242
|
+
if current is None or current.meta != meta or current.body != body:
|
|
243
|
+
context.guide.write_page(Page(path.id, meta, body))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def _write_page(
|
|
247
|
+
planned: PlannedPage,
|
|
248
|
+
context: GenerationContext,
|
|
249
|
+
claude: Assistant,
|
|
250
|
+
validation: ValidationContext,
|
|
251
|
+
result: GenerationResult,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Writes the page, or revises it when it has `planned.changes`: the assistant returns only the sections to
|
|
254
|
+
replace, which go into the page as it stands; either way the whole page is validated, and retried once."""
|
|
255
|
+
entry = planned.entry
|
|
256
|
+
current = context.guide.read_page(entry.id) if planned.changes else None
|
|
257
|
+
revising = current is not None
|
|
258
|
+
history = history_text(context, entry.scope_paths)
|
|
259
|
+
if current is not None:
|
|
260
|
+
checks = [dict(check) for check in current.meta.get("checks") or []]
|
|
261
|
+
request = PageRequest(entry.id, entry.kind, entry.title, entry.scope_paths, "", "", history,
|
|
262
|
+
current_body=current.body, current_checks=checks, changes=planned.changes,
|
|
263
|
+
brief_words=context.max_brief_words,
|
|
264
|
+
current_brief=str(current.meta.get("brief") or "")) # fmt: skip
|
|
265
|
+
else:
|
|
266
|
+
request = PageRequest(entry.id, entry.kind, entry.title, entry.scope_paths, facts_text(context.store, entry),
|
|
267
|
+
decisions_text(context.store), history, brief_words=context.max_brief_words) # fmt: skip
|
|
268
|
+
problems: list[str] = []
|
|
269
|
+
try:
|
|
270
|
+
for attempt in range(1, PAGE_ATTEMPTS + 1):
|
|
271
|
+
context.progress({"step": "page", "id": entry.id, "title": entry.title, "attempt": attempt,
|
|
272
|
+
"reason": planned.reason, "changed": planned.changed_now,
|
|
273
|
+
"revise": revising}) # fmt: skip
|
|
274
|
+
draft = await claude.write_page(request)
|
|
275
|
+
spent = _spend(context, result, "revise" if revising else "write", draft)
|
|
276
|
+
body, checks, brief = draft.body, draft.checks, draft.brief
|
|
277
|
+
if current is not None:
|
|
278
|
+
body = apply_sections(request.current_body, draft.sections)
|
|
279
|
+
checks = draft.checks or request.current_checks
|
|
280
|
+
brief = draft.brief or request.current_brief # a revision keeps the brief it doesn't replace
|
|
281
|
+
problems = validate_page(body, checks, validation)
|
|
282
|
+
problems += validate_brief(brief, validation, context.max_brief_words)
|
|
283
|
+
if not problems:
|
|
284
|
+
meta = page_meta(entry, context.store, context.manifest, draft.files_read)
|
|
285
|
+
meta["checks"] = checks
|
|
286
|
+
meta["brief"] = brief
|
|
287
|
+
if current is not None: # files it didn't read again keep the version the page was written from
|
|
288
|
+
kept = [
|
|
289
|
+
item for item in current.meta.get("files") or [] if item.get("path") not in draft.files_read
|
|
290
|
+
]
|
|
291
|
+
meta["files"] = kept + meta["files"]
|
|
292
|
+
context.guide.write_page(Page(entry.id, meta, body))
|
|
293
|
+
result.written.append(entry.id)
|
|
294
|
+
if context.failed_pages is not None:
|
|
295
|
+
context.failed_pages.forget(entry.id)
|
|
296
|
+
unchanged = revising and not draft.sections and not draft.checks and not draft.brief
|
|
297
|
+
context.progress({"step": "page_written", "id": entry.id, "title": entry.title, "revise": revising,
|
|
298
|
+
"unchanged": unchanged, **spent}) # fmt: skip
|
|
299
|
+
return
|
|
300
|
+
if current is not None and any(problem.startswith(FLAGGED_PROBLEM) for problem in problems):
|
|
301
|
+
request = replace(request, problems=problems) # a flagged revision never goes back to the provider
|
|
302
|
+
elif current is not None: # the retry sees its own revision in the page, and what's wrong with it
|
|
303
|
+
request = replace(
|
|
304
|
+
request, problems=problems, current_body=body, current_checks=checks, current_brief=brief
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
request = replace(request, problems=problems, previous_body=draft.body)
|
|
308
|
+
reason = "; ".join(problems[:3])
|
|
309
|
+
if context.failed_pages is not None: # its drafts failed validation: not worth paying for again unchanged
|
|
310
|
+
context.failed_pages.record(entry.id, scope_hash(context.store, entry))
|
|
311
|
+
except AssistantError as error:
|
|
312
|
+
reason = str(error)
|
|
313
|
+
result.failed.append((entry.id, reason))
|
|
314
|
+
context.progress({"step": "page_failed", "id": entry.id, "title": entry.title, "reason": reason})
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _write_digest(
|
|
318
|
+
context: GenerationContext, claude: Assistant, validation: ValidationContext, result: GenerationResult
|
|
319
|
+
) -> None:
|
|
320
|
+
head = context.manifest.commit
|
|
321
|
+
found = _digest_range(context, bool(result.written))
|
|
322
|
+
if found is None:
|
|
323
|
+
return
|
|
324
|
+
context.progress({"step": "digest"})
|
|
325
|
+
since, commits = found
|
|
326
|
+
page_id = f"digests/{datetime.now(UTC).strftime('%Y-%m-%d')}-{head[:12]}"
|
|
327
|
+
meta = {"id": page_id, "kind": "digest", "generated": True, "from_commit": since, "to_commit": head,
|
|
328
|
+
"written_at": datetime.now(UTC).isoformat(timespec="seconds"), "pages_changed": result.written} # fmt: skip
|
|
329
|
+
if not commits:
|
|
330
|
+
meta["title"] = "The guide was created"
|
|
331
|
+
body = "The guide's first pages:\n\n" + "\n".join(f"- [{page}](/pages/{page})" for page in result.written)
|
|
332
|
+
context.guide.write_page(Page(page_id, meta, body))
|
|
333
|
+
result.digest = page_id
|
|
334
|
+
return
|
|
335
|
+
request = DigestRequest(context.target, commits_text(commits), fact_changes_text(context.diff), result.written)
|
|
336
|
+
try:
|
|
337
|
+
if _budget_spent(context, result):
|
|
338
|
+
raise AssistantError("the update's budget is spent")
|
|
339
|
+
draft = await claude.write_digest(request)
|
|
340
|
+
_spend(context, result, "digest", draft)
|
|
341
|
+
if validate_page(draft.body, None, validation):
|
|
342
|
+
raise AssistantError("the digest failed validation")
|
|
343
|
+
meta["title"], body = draft.title, draft.body
|
|
344
|
+
except AssistantError:
|
|
345
|
+
meta["title"] = f"{len(commits)} commits since the last update"
|
|
346
|
+
body = "The assistant's digest couldn't be checked, so here are the commits:\n\n" + "\n".join(
|
|
347
|
+
f"- {commit.subject}" for commit in commits
|
|
348
|
+
)
|
|
349
|
+
context.guide.write_page(Page(page_id, meta, body))
|
|
350
|
+
result.digest = page_id
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _digest_range(context: GenerationContext, pages_written: bool) -> tuple[str | None, list[Commit]] | None:
|
|
354
|
+
"""The commits a digest covers, from the last digest to the head; None when no digest is due."""
|
|
355
|
+
head = context.manifest.commit
|
|
356
|
+
digests = sorted(context.guide.pages("digest"), key=lambda page: str(page.meta.get("written_at", "")))
|
|
357
|
+
since = str(digests[-1].meta.get("to_commit")) if digests else context.previous_commit
|
|
358
|
+
if not digests and pages_written:
|
|
359
|
+
since = None # the guide's first pages: record its creation, whatever facts-only updates came before
|
|
360
|
+
if since == head or (since is None and not pages_written):
|
|
361
|
+
return None
|
|
362
|
+
commits = commits_between(context.mirror, since, head, context.visible, context.scanner) if since else []
|
|
363
|
+
if since and not commits and not pages_written:
|
|
364
|
+
return None
|
|
365
|
+
return since, commits
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@dataclass(frozen=True)
|
|
369
|
+
class PlannedPage:
|
|
370
|
+
"""A page the update will write, and why: "new", "outline" (its entry changed), "update" (facts in its scope
|
|
371
|
+
changed in this update) or "catching_up" (they changed in an earlier update that didn't rewrite it)."""
|
|
372
|
+
|
|
373
|
+
entry: OutlineEntry
|
|
374
|
+
reason: str
|
|
375
|
+
changed_now: int # facts in its scope this update added, changed or removed
|
|
376
|
+
changes: list[str] = field(default_factory=list) # set when the page is revised: what changed since it was written
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass(frozen=True)
|
|
380
|
+
class PageSelection:
|
|
381
|
+
to_write: list[PlannedPage]
|
|
382
|
+
left_for_later: list[OutlineEntry] # beyond max_pages_per_update
|
|
383
|
+
skipped: list[OutlineEntry] # failed validation before, and their scope hasn't changed since
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def select_pages(context: GenerationContext, entries: Sequence[OutlineEntry]) -> PageSelection:
|
|
387
|
+
"""The affected pages, in the order they are written: those this update's facts touched first, then the ones
|
|
388
|
+
the reader learned, then new pages, then the backlog; within a group, the most changed facts first. A page that
|
|
389
|
+
failed validation is skipped while its scope is what it was then, unless `context.retry_failed`."""
|
|
390
|
+
touched = context.diff.changed_ids()
|
|
391
|
+
affected: list[tuple[tuple[bool, bool, bool, int], PlannedPage]] = []
|
|
392
|
+
skipped: list[OutlineEntry] = []
|
|
393
|
+
for entry in entries:
|
|
394
|
+
page = context.guide.read_page(entry.id)
|
|
395
|
+
reason = affected_reason(entry, page, context.store)
|
|
396
|
+
if reason is None:
|
|
397
|
+
continue
|
|
398
|
+
if context.failed_pages is not None and not context.retry_failed:
|
|
399
|
+
failed_with = context.failed_pages.scope_hash(entry.id)
|
|
400
|
+
if failed_with is not None and failed_with == scope_hash(context.store, entry):
|
|
401
|
+
skipped.append(entry)
|
|
402
|
+
continue
|
|
403
|
+
changed_now = sum(1 for entity in facts_in_scope(context.store, entry) if entity.id in touched)
|
|
404
|
+
kind = {"new": "new", "outline changed": "outline"}.get(reason, "update" if changed_now else "catching_up")
|
|
405
|
+
rank = (not changed_now, entry.id not in context.learned, kind not in ("new", "outline"),
|
|
406
|
+
-changed_fact_count(entry, page, context.store)) # fmt: skip
|
|
407
|
+
affected.append((rank, PlannedPage(entry, kind, changed_now, _revision(context, entry, page, kind))))
|
|
408
|
+
affected.sort(key=lambda item: item[0])
|
|
409
|
+
planned = [page for _, page in affected]
|
|
410
|
+
return PageSelection(planned[: context.max_pages], [page.entry for page in planned[context.max_pages :]], skipped)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _revision(context: GenerationContext, entry: OutlineEntry, page: Page | None, kind: str) -> list[str]:
|
|
414
|
+
"""What changed in the page's scope since it was written, when there are few enough changes to revise it rather
|
|
415
|
+
than write it again; empty for a new page, a changed outline entry, or too many changes."""
|
|
416
|
+
if page is None or kind not in ("update", "catching_up") or not context.revise_max_changes:
|
|
417
|
+
return []
|
|
418
|
+
since = page_snapshot(context.store, page)
|
|
419
|
+
changes = scope_changes(context.store, entry, since) if since is not None else []
|
|
420
|
+
return changes if len(changes) <= context.revise_max_changes else []
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@dataclass(frozen=True)
|
|
424
|
+
class PlannedWork:
|
|
425
|
+
"""The paid calls an update will make, counted without making any (design section 15.4)."""
|
|
426
|
+
|
|
427
|
+
plan_calls: int
|
|
428
|
+
pages_expected: int # pages written whole
|
|
429
|
+
page_calls_max: int # each page may be written twice: the retry after a failed validation
|
|
430
|
+
digest_expected: bool
|
|
431
|
+
digest_possible: bool # a digest also runs when commits came in and no page was written
|
|
432
|
+
pages: list[PlannedPage] = field(default_factory=list) # empty before the first outline
|
|
433
|
+
skipped: list[OutlineEntry] = field(default_factory=list)
|
|
434
|
+
revisions_expected: int = 0 # pages revised (section 6.3), each also retried once at most
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def planned_work(context: GenerationContext) -> PlannedWork:
|
|
438
|
+
entries = outline_entries(context.guide.read_outline())
|
|
439
|
+
if not entries: # the first outline: a plan, then as many pages as the cap allows
|
|
440
|
+
return PlannedWork(1, context.max_pages, context.max_pages * PAGE_ATTEMPTS, True, True)
|
|
441
|
+
stored = context.guide.read_outline()
|
|
442
|
+
new_facts = set(context.diff.added_entities)
|
|
443
|
+
plan_calls = int(any(fact in new_facts for fact in uncovered_facts(context.store, entries)))
|
|
444
|
+
plan_calls += int(stored is not None and "paths" not in stored)
|
|
445
|
+
selection = select_pages(context, entries)
|
|
446
|
+
revisions = sum(1 for page in selection.to_write if page.changes)
|
|
447
|
+
expected = len(selection.to_write) - revisions
|
|
448
|
+
maximum = context.max_pages if plan_calls else expected # new pages can push revisions out and take every slot
|
|
449
|
+
|
|
450
|
+
def digest_due(pages_written: bool) -> bool:
|
|
451
|
+
found = _digest_range(context, pages_written)
|
|
452
|
+
return found is not None and bool(found[1])
|
|
453
|
+
|
|
454
|
+
possible = digest_due(True) or digest_due(False)
|
|
455
|
+
return PlannedWork(plan_calls, expected, maximum * PAGE_ATTEMPTS, digest_due(bool(selection.to_write)), possible,
|
|
456
|
+
selection.to_write, selection.skipped, revisions) # fmt: skip
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def facts_summary(store: FactStore, manifest: SourceManifest) -> str:
|
|
460
|
+
"""A rolled-up view of the facts for planning: folders, projects, decisions, packages."""
|
|
461
|
+
lines = ["Top-level folders (visible files):"]
|
|
462
|
+
folders: dict[str, int] = {}
|
|
463
|
+
for path in manifest.files:
|
|
464
|
+
parts = path.split("/")
|
|
465
|
+
key = "/".join(parts[:2]) if len(parts) > 2 else parts[0] if len(parts) > 1 else "(root)"
|
|
466
|
+
folders[key] = folders.get(key, 0) + 1
|
|
467
|
+
lines += [f"- {folder}: {count}" for folder, count in sorted(folders.items())]
|
|
468
|
+
for kind in (EntityKind.PROJECT, EntityKind.DECISION, EntityKind.PACKAGE, EntityKind.ROUTE,
|
|
469
|
+
EntityKind.TERRAFORM_MODULE, EntityKind.SWIFT_TARGET): # fmt: skip
|
|
470
|
+
lines.append(f"\n{kind} facts:")
|
|
471
|
+
lines += [f"- {entity.id} {json.dumps(dict(entity.attributes))}" for entity in store.entities(kind)]
|
|
472
|
+
modules: dict[str, int] = {}
|
|
473
|
+
for entity in store.entities(EntityKind.MODULE):
|
|
474
|
+
folder = "/".join(entity.id.removeprefix("module:").split("/")[:-1])
|
|
475
|
+
modules[folder] = modules.get(folder, 0) + 1
|
|
476
|
+
lines.append("\nmodule facts, counted by folder (ids are module:<path>):")
|
|
477
|
+
lines += [f"- {folder}: {count}" for folder, count in sorted(modules.items())]
|
|
478
|
+
return "\n".join(lines)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def facts_text(store: FactStore, entry: OutlineEntry) -> str:
|
|
482
|
+
entities = facts_in_scope(store, entry)
|
|
483
|
+
lines = [f"{entity.kind} {entity.id} {json.dumps(dict(entity.attributes))}" for entity in entities]
|
|
484
|
+
if len(lines) > MAX_FACT_LINES:
|
|
485
|
+
lines = [*lines[:MAX_FACT_LINES], f"... and {len(lines) - MAX_FACT_LINES} more in the same scope"]
|
|
486
|
+
return "\n".join(lines) or "(none)"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def decisions_text(store: FactStore) -> str:
|
|
490
|
+
return "\n".join(
|
|
491
|
+
f"- {entity.id} ({entity.attributes.get('status', '?')}): {entity.attributes.get('title', '')}"
|
|
492
|
+
f" — {entity.attributes.get('path', '')}"
|
|
493
|
+
for entity in store.entities(EntityKind.DECISION)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def history_text(context: GenerationContext, scope_paths: Sequence[str]) -> str:
|
|
498
|
+
commits = recent_commits(context.mirror, context.manifest.commit, scope_paths, HISTORY_COMMITS, context.visible,
|
|
499
|
+
context.scanner) # fmt: skip
|
|
500
|
+
return commits_text(commits)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def commits_text(commits: Sequence[Commit]) -> str:
|
|
504
|
+
blocks = []
|
|
505
|
+
for commit in commits:
|
|
506
|
+
why = _section(commit.body, "Why")
|
|
507
|
+
blocks.append(f"- {commit.sha[:12]} {commit.date[:10]} {commit.subject}" + (f"\n Why: {why}" if why else ""))
|
|
508
|
+
return "\n".join(blocks)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def fact_changes_text(diff: FactDiff) -> str:
|
|
512
|
+
lines = [f"added {fact}" for fact in diff.added_entities[:60]]
|
|
513
|
+
lines += [f"changed {fact}" for fact in diff.changed_entities[:60]]
|
|
514
|
+
lines += [f"removed {fact}" for fact in diff.removed_entities[:60]]
|
|
515
|
+
return "\n".join(lines)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _section(body: str, name: str) -> str:
|
|
519
|
+
"""The text of a commit body's section, written as a heading line or inline ("Why: ...")."""
|
|
520
|
+
lines = body.splitlines()
|
|
521
|
+
for index, line in enumerate(lines):
|
|
522
|
+
stripped = line.strip()
|
|
523
|
+
if stripped == name or stripped.startswith(f"{name}:"):
|
|
524
|
+
collected = [stripped[len(name) + 1 :].strip()] if stripped != name else []
|
|
525
|
+
for following in lines[index + 1 :]:
|
|
526
|
+
if not following.strip() or (following.strip().endswith(":") and following[:1].isupper()):
|
|
527
|
+
break
|
|
528
|
+
collected.append(following.strip())
|
|
529
|
+
return " ".join(part for part in collected if part)[:600]
|
|
530
|
+
return ""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""What a page covers, and whether it must be rewritten (design section 4.3).
|
|
2
|
+
|
|
3
|
+
A page records the facts it explains directly with their hashes, one hash over everything in its scope, the snapshot
|
|
4
|
+
it was written at, and the files Claude read with their blobs. Changed facts or a changed scope make it affected;
|
|
5
|
+
changed files only flag it. What changed in its scope since its snapshot is what a revision is told (section 6.3).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from codetrail.facts import Entity, Relation
|
|
16
|
+
from codetrail.facts.store import FactStore
|
|
17
|
+
from codetrail.generate.outline import OutlineEntry, in_scope
|
|
18
|
+
from codetrail.guide import Page
|
|
19
|
+
from codetrail.repo.source import SourceManifest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def facts_in_scope(store: FactStore, entry: OutlineEntry) -> list[Entity]:
|
|
23
|
+
return entities_in_scope(store.entities(), entry)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def entities_in_scope(entities: list[Entity], entry: OutlineEntry) -> list[Entity]:
|
|
27
|
+
"""The entities under the entry's scope paths (of its kinds, if it names any), and the facts it names."""
|
|
28
|
+
named = set(entry.facts)
|
|
29
|
+
found = []
|
|
30
|
+
for entity in entities:
|
|
31
|
+
kind_ok = not entry.scope_kinds or str(entity.kind) in entry.scope_kinds
|
|
32
|
+
under = any(in_scope(source.path, entry.scope_paths) for source in entity.sources)
|
|
33
|
+
if entity.id in named or (kind_ok and under):
|
|
34
|
+
found.append(entity)
|
|
35
|
+
return sorted(found, key=lambda entity: entity.id)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def page_snapshot(store: FactStore, page: Page) -> int | None:
|
|
39
|
+
"""The snapshot the page was written at; for a page written before pages recorded it, the earliest snapshot at
|
|
40
|
+
its commit, which can only show more changes than happened since, never fewer."""
|
|
41
|
+
recorded = page.meta.get("snapshot")
|
|
42
|
+
if isinstance(recorded, int):
|
|
43
|
+
return recorded
|
|
44
|
+
found = store.first_snapshot(str(page.meta.get("built_at", "")))
|
|
45
|
+
return found.id if found else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scope_changes(store: FactStore, entry: OutlineEntry, since: int) -> list[str]:
|
|
49
|
+
"""Each fact and link in the entry's scope added, changed or removed since the snapshot, one line each."""
|
|
50
|
+
before = {entity.id: entity for entity in entities_in_scope(store.entities_at(since), entry)}
|
|
51
|
+
now = {entity.id: entity for entity in entities_in_scope(store.entities(), entry)}
|
|
52
|
+
lines = []
|
|
53
|
+
for entity_id in sorted(before.keys() | now.keys()):
|
|
54
|
+
old, new = before.get(entity_id), now.get(entity_id)
|
|
55
|
+
if new is None and old is not None:
|
|
56
|
+
lines.append(f"removed {old.kind} {entity_id}")
|
|
57
|
+
elif new is not None and (old is None or old.hash != new.hash):
|
|
58
|
+
attributes = json.dumps(dict(new.attributes), sort_keys=True, ensure_ascii=False)
|
|
59
|
+
lines.append(f"{'added' if old is None else 'changed'} {new.kind} {entity_id} {attributes}")
|
|
60
|
+
old_links = _links(store.relations_at(since), before.keys())
|
|
61
|
+
new_links = _links(store.relations(), now.keys())
|
|
62
|
+
for key in sorted(old_links.keys() | new_links.keys()):
|
|
63
|
+
old_hash, new_hash = old_links.get(key), new_links.get(key)
|
|
64
|
+
if old_hash != new_hash:
|
|
65
|
+
change = "removed" if new_hash is None else "added" if old_hash is None else "changed"
|
|
66
|
+
lines.append(f"{change} link {' '.join(key)}")
|
|
67
|
+
return sorted(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _links(relations: list[Relation], sources: Iterable[str]) -> dict[tuple[str, str, str], str]:
|
|
71
|
+
"""The relations from the given entities, as scope_hash counts them."""
|
|
72
|
+
ids = set(sources)
|
|
73
|
+
return {relation.key: relation.hash for relation in relations if relation.source_id in ids}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scope_hash(store: FactStore, entry: OutlineEntry) -> str:
|
|
77
|
+
entities = facts_in_scope(store, entry)
|
|
78
|
+
ids = {entity.id for entity in entities}
|
|
79
|
+
relations = [relation for relation in store.relations() if relation.source_id in ids]
|
|
80
|
+
parts = [[entity.id, entity.hash] for entity in entities] + [[list(r.key), r.hash] for r in relations]
|
|
81
|
+
return hashlib.sha256(json.dumps(parts, sort_keys=True).encode()).hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def page_meta(entry: OutlineEntry, store: FactStore, manifest: SourceManifest, files_read: list[str]) -> dict[str, Any]:
|
|
85
|
+
facts = []
|
|
86
|
+
for fact in entry.facts:
|
|
87
|
+
entity = store.entity(fact)
|
|
88
|
+
if entity is not None:
|
|
89
|
+
facts.append({"id": entity.id, "hash": entity.hash})
|
|
90
|
+
snapshot = store.latest_snapshot()
|
|
91
|
+
return {
|
|
92
|
+
"id": entry.id,
|
|
93
|
+
"kind": entry.kind,
|
|
94
|
+
"title": entry.title,
|
|
95
|
+
"generated": True,
|
|
96
|
+
"built_at": snapshot.commit if snapshot else manifest.commit,
|
|
97
|
+
"snapshot": snapshot.id if snapshot else None,
|
|
98
|
+
"facts": facts,
|
|
99
|
+
"scope": {"paths": entry.scope_paths, "kinds": entry.scope_kinds, "hash": scope_hash(store, entry)},
|
|
100
|
+
"files": [{"path": path, "blob": manifest.files[path]} for path in files_read if path in manifest.files],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def affected_reason(entry: OutlineEntry, page: Page | None, store: FactStore) -> str | None:
|
|
105
|
+
"""Why the page must be rewritten, or None when it is current."""
|
|
106
|
+
if page is None:
|
|
107
|
+
return "new"
|
|
108
|
+
meta = page.meta
|
|
109
|
+
scope = meta.get("scope") or {}
|
|
110
|
+
if meta.get("title") != entry.title or scope.get("paths") != entry.scope_paths:
|
|
111
|
+
return "outline changed"
|
|
112
|
+
for fact in meta.get("facts") or []:
|
|
113
|
+
entity = store.entity(str(fact.get("id")))
|
|
114
|
+
if entity is None or entity.hash != fact.get("hash"):
|
|
115
|
+
return "facts changed"
|
|
116
|
+
if scope.get("hash") != scope_hash(store, entry):
|
|
117
|
+
return "scope changed"
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def changed_fact_count(entry: OutlineEntry, page: Page | None, store: FactStore) -> int:
|
|
122
|
+
"""How many of the page's recorded facts changed or disappeared; used to rank affected pages."""
|
|
123
|
+
if page is None:
|
|
124
|
+
return len(entry.facts)
|
|
125
|
+
count = 0
|
|
126
|
+
for fact in page.meta.get("facts") or []:
|
|
127
|
+
entity = store.entity(str(fact.get("id")))
|
|
128
|
+
if entity is None or entity.hash != fact.get("hash"):
|
|
129
|
+
count += 1
|
|
130
|
+
return count
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sources_changed(page: Page, manifest: SourceManifest) -> bool:
|
|
134
|
+
"""True when a file Claude read for the page has a different blob now (or is no longer visible)."""
|
|
135
|
+
return any(manifest.files.get(str(item.get("path"))) != item.get("blob") for item in page.meta.get("files") or [])
|