codemble 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. codemble/__init__.py +4 -0
  2. codemble/adapters/__init__.py +38 -0
  3. codemble/adapters/base.py +199 -0
  4. codemble/adapters/discovery.py +190 -0
  5. codemble/adapters/project.py +206 -0
  6. codemble/adapters/python_ast.py +766 -0
  7. codemble/adapters/typescript_tree_sitter.py +1263 -0
  8. codemble/checks/__init__.py +19 -0
  9. codemble/checks/service.py +380 -0
  10. codemble/cli.py +161 -0
  11. codemble/graph/__init__.py +17 -0
  12. codemble/graph/finalize.py +91 -0
  13. codemble/graph/layout.py +132 -0
  14. codemble/lens/__init__.py +18 -0
  15. codemble/lens/javascript_typescript.py +82 -0
  16. codemble/lens/python.py +71 -0
  17. codemble/llm/__init__.py +6 -0
  18. codemble/llm/providers.py +137 -0
  19. codemble/llm/study.py +439 -0
  20. codemble/progress/__init__.py +9 -0
  21. codemble/progress/store.py +160 -0
  22. codemble/server/__init__.py +6 -0
  23. codemble/server/app.py +273 -0
  24. codemble/server/runtime.py +68 -0
  25. codemble/web_dist/assets/index-DOBVd_-M.css +1 -0
  26. codemble/web_dist/assets/index-cIG6GGIB.js +5238 -0
  27. codemble/web_dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  28. codemble/web_dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  29. codemble/web_dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  30. codemble/web_dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  31. codemble/web_dist/assets/shippori-mincho-latin-500-normal-C-QwvIb3.woff +0 -0
  32. codemble/web_dist/assets/shippori-mincho-latin-500-normal-XI1O8euf.woff2 +0 -0
  33. codemble/web_dist/assets/shippori-mincho-latin-700-normal-CkoCYOiI.woff +0 -0
  34. codemble/web_dist/assets/shippori-mincho-latin-700-normal-DHcmzUO5.woff2 +0 -0
  35. codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-BEdayliK.woff2 +0 -0
  36. codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-CPSmNJAU.woff +0 -0
  37. codemble/web_dist/index.html +18 -0
  38. codemble-0.3.0.dist-info/METADATA +417 -0
  39. codemble-0.3.0.dist-info/RECORD +43 -0
  40. codemble-0.3.0.dist-info/WHEEL +4 -0
  41. codemble-0.3.0.dist-info/entry_points.txt +2 -0
  42. codemble-0.3.0.dist-info/licenses/LICENSE +202 -0
  43. codemble-0.3.0.dist-info/licenses/NOTICE +2 -0
codemble/llm/study.py ADDED
@@ -0,0 +1,439 @@
1
+ """Deep study module: source, grounding, provider narration, and disk cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import tokenize
9
+ import tomllib
10
+ from dataclasses import asdict
11
+ from pathlib import Path
12
+ from typing import Mapping
13
+
14
+ from codemble.adapters.base import Edge, Graph, Node
15
+ from codemble.lens import lens_notes
16
+ from codemble.llm.providers import (
17
+ AnthropicProvider,
18
+ NarrationProvider,
19
+ OpenAIProvider,
20
+ ProviderError,
21
+ )
22
+
23
+ PROMPT_VERSION = "study-v2"
24
+ _CACHE_SCHEMA = 1
25
+
26
+
27
+ class UnknownNodeError(LookupError):
28
+ """The requested node is not present in the parser graph."""
29
+
30
+
31
+ class StudySourceError(RuntimeError):
32
+ """The parser-proven source can no longer be read safely."""
33
+
34
+
35
+ class GroundingError(RuntimeError):
36
+ """Provider output references evidence outside the supplied graph context."""
37
+
38
+
39
+ class StudyService:
40
+ """Return a complete, evidence-bounded study payload through one interface."""
41
+
42
+ def __init__(
43
+ self,
44
+ graph: Graph,
45
+ *,
46
+ provider: NarrationProvider | None = None,
47
+ cache_root: Path | None = None,
48
+ setup_message: str | None = None,
49
+ ) -> None:
50
+ self._graph = graph
51
+ self._project_root = Path(graph.project_root).resolve()
52
+ self._nodes = {node.id: node for node in graph.nodes}
53
+ self._provider = provider
54
+ self._cache_root = cache_root or Path.home() / ".codemble" / "cache" / "explanations"
55
+ self._setup_message = setup_message or (
56
+ "Set ANTHROPIC_API_KEY or OPENAI_API_KEY, then restart Codemble."
57
+ )
58
+
59
+ @classmethod
60
+ def from_environment(
61
+ cls,
62
+ graph: Graph,
63
+ *,
64
+ environ: Mapping[str, str] | None = None,
65
+ config_path: Path | None = None,
66
+ cache_root: Path | None = None,
67
+ ) -> StudyService:
68
+ """Build the module from environment variables or ``~/.codemble/config``."""
69
+
70
+ values = dict(os.environ if environ is None else environ)
71
+ path = config_path or Path.home() / ".codemble" / "config"
72
+ config, config_error = _read_config(path)
73
+ provider_name = (values.get("CODEMBLE_PROVIDER") or config.get("provider") or "").lower()
74
+ generic_key = config.get("api_key")
75
+ anthropic_key = (
76
+ values.get("ANTHROPIC_API_KEY")
77
+ or config.get("anthropic_api_key")
78
+ or (generic_key if provider_name == "anthropic" else None)
79
+ )
80
+ openai_key = (
81
+ values.get("OPENAI_API_KEY")
82
+ or config.get("openai_api_key")
83
+ or (generic_key if provider_name == "openai" else None)
84
+ )
85
+
86
+ if not provider_name:
87
+ if anthropic_key:
88
+ provider_name = "anthropic"
89
+ elif openai_key:
90
+ provider_name = "openai"
91
+
92
+ provider: NarrationProvider | None = None
93
+ setup_message = config_error
94
+ if provider_name == "anthropic" and anthropic_key:
95
+ provider = AnthropicProvider(
96
+ anthropic_key,
97
+ values.get("CODEMBLE_ANTHROPIC_MODEL")
98
+ or config.get("anthropic_model")
99
+ or config.get("model")
100
+ or "claude-sonnet-5",
101
+ )
102
+ elif provider_name == "openai" and openai_key:
103
+ provider = OpenAIProvider(
104
+ openai_key,
105
+ values.get("CODEMBLE_OPENAI_MODEL")
106
+ or config.get("openai_model")
107
+ or config.get("model")
108
+ or "gpt-5.4-mini",
109
+ )
110
+ elif provider_name and provider_name not in {"anthropic", "openai"}:
111
+ setup_message = (
112
+ "CODEMBLE_PROVIDER must be 'anthropic' or 'openai'; structure remains available."
113
+ )
114
+ elif provider_name:
115
+ setup_message = (
116
+ f"Add the {provider_name.upper()} API key to the environment or {path}, "
117
+ "then restart Codemble."
118
+ )
119
+
120
+ return cls(
121
+ graph,
122
+ provider=provider,
123
+ cache_root=cache_root,
124
+ setup_message=setup_message,
125
+ )
126
+
127
+ def study(self, node_id: str) -> dict[str, object]:
128
+ """Return real source, parser neighbors, and a bounded narration state."""
129
+
130
+ node = self._nodes.get(node_id)
131
+ if node is None:
132
+ raise UnknownNodeError(node_id)
133
+ source = self._read_source(node)
134
+ neighbors = self._neighbors(node)
135
+ annotations = sorted(
136
+ (
137
+ annotation
138
+ for annotation in self._graph.concept_annotations
139
+ if annotation.node_id == node.id
140
+ ),
141
+ key=lambda item: (item.lineno, item.concept, item.end_lineno),
142
+ )
143
+ lens = lens_notes(node.language, annotations)
144
+ for note in lens:
145
+ note["citation"] = f"{node.file}:{note['line']}"
146
+ explanation = (
147
+ {
148
+ "status": "partial",
149
+ "message": (
150
+ "Narration is unavailable because the language parser reported "
151
+ "syntax errors in this file. "
152
+ "The raw source remains visible."
153
+ ),
154
+ }
155
+ if node.partial
156
+ else self._explain(node, source, neighbors, lens)
157
+ )
158
+ return {
159
+ "node": asdict(node),
160
+ "source": source,
161
+ "neighbors": neighbors,
162
+ "lens": lens,
163
+ "explanation": explanation,
164
+ }
165
+
166
+ def _read_source(self, node: Node) -> dict[str, object]:
167
+ source_path = (self._project_root / node.file).resolve()
168
+ if not source_path.is_relative_to(self._project_root) or not source_path.is_file():
169
+ raise StudySourceError("The parser-proven source file is no longer available.")
170
+ try:
171
+ if node.language == "python":
172
+ with tokenize.open(source_path) as source_file:
173
+ source_text = source_file.read()
174
+ else:
175
+ source_text = source_path.read_bytes().decode("utf-8", errors="replace")
176
+ all_lines = source_text.splitlines()
177
+ except (OSError, SyntaxError, UnicodeDecodeError) as error:
178
+ raise StudySourceError("The parser-proven source could not be decoded safely.") from error
179
+ start = max(1, node.lineno)
180
+ end = min(max(start, node.end_lineno), len(all_lines))
181
+ return {
182
+ "file": node.file,
183
+ "start_line": start,
184
+ "end_line": end,
185
+ "lines": [
186
+ {"number": line_number, "text": all_lines[line_number - 1]}
187
+ for line_number in range(start, end + 1)
188
+ ],
189
+ }
190
+
191
+ def _neighbors(self, node: Node) -> list[dict[str, object]]:
192
+ observations: dict[tuple[str, str, int], dict[str, object]] = {}
193
+ for edge in self._graph.edges:
194
+ neighbor_id = _neighbor_id(edge, node.id)
195
+ neighbor = self._nodes.get(neighbor_id) if neighbor_id else None
196
+ if neighbor is None:
197
+ continue
198
+ key = (neighbor.id, edge.kind, edge.lineno)
199
+ observations[key] = {
200
+ "node_id": neighbor.id,
201
+ "name": neighbor.name,
202
+ "kind": neighbor.kind,
203
+ "file": neighbor.file,
204
+ "line": neighbor.lineno,
205
+ "citation": f"{neighbor.file}:{neighbor.lineno}",
206
+ "relationship": edge.kind,
207
+ "certain": edge.certain,
208
+ "observed_line": edge.lineno,
209
+ }
210
+ return [observations[key] for key in sorted(observations)]
211
+
212
+ def _explain(
213
+ self,
214
+ node: Node,
215
+ source: dict[str, object],
216
+ neighbors: list[dict[str, object]],
217
+ lens: list[dict[str, object]],
218
+ ) -> dict[str, object]:
219
+ if self._provider is None:
220
+ return {
221
+ "status": "no_key",
222
+ "message": self._setup_message,
223
+ "cached": False,
224
+ }
225
+
226
+ file_hash = self._graph.file_hashes.get(node.file, "")
227
+ cache_key = _cache_key(self._provider, node, file_hash)
228
+ cached = self._read_cache(cache_key)
229
+ if cached is not None:
230
+ return {**cached, "cached": True}
231
+
232
+ prompt = _grounded_prompt(node, source, neighbors, lens)
233
+ try:
234
+ raw = self._provider.complete(prompt)
235
+ validated = _validate_explanation(raw, node, neighbors)
236
+ except (ProviderError, GroundingError) as error:
237
+ return {
238
+ "status": "error",
239
+ "message": str(error),
240
+ "cached": False,
241
+ "provider": self._provider.name,
242
+ "model": self._provider.model,
243
+ }
244
+
245
+ result = {
246
+ "status": "ready",
247
+ "cached": False,
248
+ "provider": self._provider.name,
249
+ "model": self._provider.model,
250
+ **validated,
251
+ }
252
+ self._write_cache(cache_key, result)
253
+ return result
254
+
255
+ def _read_cache(self, cache_key: str) -> dict[str, object] | None:
256
+ path = self._cache_root / f"{cache_key}.json"
257
+ try:
258
+ payload = json.loads(path.read_text(encoding="utf-8"))
259
+ except (OSError, json.JSONDecodeError):
260
+ return None
261
+ if not isinstance(payload, dict) or payload.get("schema_version") != _CACHE_SCHEMA:
262
+ return None
263
+ result = payload.get("result")
264
+ return result if isinstance(result, dict) and result.get("status") == "ready" else None
265
+
266
+ def _write_cache(self, cache_key: str, result: dict[str, object]) -> None:
267
+ try:
268
+ self._cache_root.mkdir(parents=True, exist_ok=True)
269
+ destination = self._cache_root / f"{cache_key}.json"
270
+ temporary = destination.with_suffix(".tmp")
271
+ payload = {"schema_version": _CACHE_SCHEMA, "result": result}
272
+ temporary.write_text(
273
+ json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
274
+ encoding="utf-8",
275
+ )
276
+ temporary.replace(destination)
277
+ except OSError:
278
+ return
279
+
280
+
281
+ def _read_config(path: Path) -> tuple[dict[str, str], str | None]:
282
+ try:
283
+ raw = path.read_text(encoding="utf-8")
284
+ except FileNotFoundError:
285
+ return {}, None
286
+ except OSError:
287
+ return {}, f"Codemble could not read {path}; environment keys still work."
288
+ try:
289
+ decoded = json.loads(raw) if raw.lstrip().startswith("{") else tomllib.loads(raw)
290
+ except (json.JSONDecodeError, tomllib.TOMLDecodeError):
291
+ return {}, f"{path} must contain a TOML table or JSON object."
292
+ if not isinstance(decoded, dict) or not all(
293
+ isinstance(key, str) and isinstance(value, str) for key, value in decoded.items()
294
+ ):
295
+ return {}, f"{path} must map string settings to string values."
296
+ return decoded, None
297
+
298
+
299
+ def _neighbor_id(edge: Edge, node_id: str) -> str | None:
300
+ if edge.src == node_id and not edge.external:
301
+ return edge.dst
302
+ if edge.dst == node_id:
303
+ return edge.src
304
+ return None
305
+
306
+
307
+ def _cache_key(provider: NarrationProvider, node: Node, file_hash: str) -> str:
308
+ material = "\0".join(
309
+ (PROMPT_VERSION, provider.name, provider.model, node.id, file_hash)
310
+ ).encode()
311
+ return hashlib.sha256(material).hexdigest()
312
+
313
+
314
+ def _grounded_prompt(
315
+ node: Node,
316
+ source: dict[str, object],
317
+ neighbors: list[dict[str, object]],
318
+ lens: list[dict[str, object]],
319
+ ) -> str:
320
+ source_lines = source.get("lines", [])
321
+ numbered_source = "\n".join(
322
+ f"{line['number']:04d}: {line['text']}"
323
+ for line in source_lines
324
+ if isinstance(line, dict) and isinstance(line.get("number"), int)
325
+ )
326
+ neighbor_evidence = "\n".join(
327
+ f"- {neighbor['node_id']} ({neighbor['relationship']}, "
328
+ f"{'certain' if neighbor['certain'] else 'possible'}) at {neighbor['citation']}"
329
+ for neighbor in neighbors
330
+ ) or "- none"
331
+ lens_evidence = "\n".join(
332
+ f"- {note['concept']} at {note['citation']}: {note['snippet']}"
333
+ for note in lens
334
+ ) or "- none"
335
+ return f"""You are the narration layer in Codemble, a code-learning tool.
336
+
337
+ HARD CORRECTNESS CONTRACT:
338
+ - Explain only the source and parser evidence below.
339
+ - Never invent a structure, identifier, behavior, dependency, or intent.
340
+ - If purpose is unclear from the code, say exactly that it is unclear from the code.
341
+ - Do not introduce identifier names in prose; the UI renders validated node IDs separately.
342
+ - Use only line numbers inside {node.file}:{node.lineno}-{node.end_lineno}.
343
+ - A relationship may name only one of the supplied neighbor node IDs.
344
+ - Approximate relationships must be described as possible, never certain.
345
+ - Return JSON only, with no Markdown fence.
346
+
347
+ Return this exact shape:
348
+ {{
349
+ "summary": "plain-language explanation",
350
+ "walkthrough": [{{"line": {node.lineno}, "explanation": "what that line does"}}],
351
+ "relationships": [{{"node_id": "allowed neighbor ID", "explanation": "relationship"}}]
352
+ }}
353
+
354
+ Selected node: {node.id} ({node.kind})
355
+ Citation: {node.file}:{node.lineno}
356
+
357
+ SOURCE:
358
+ {numbered_source}
359
+
360
+ PARSER-PROVEN NEIGHBORS:
361
+ {neighbor_evidence}
362
+
363
+ LANGUAGE-LENS ANNOTATIONS:
364
+ {lens_evidence}
365
+ """
366
+
367
+
368
+ def _validate_explanation(
369
+ raw: str,
370
+ node: Node,
371
+ neighbors: list[dict[str, object]],
372
+ ) -> dict[str, object]:
373
+ candidate = raw.strip()
374
+ if candidate.startswith("```"):
375
+ candidate = candidate.removeprefix("```json").removeprefix("```")
376
+ candidate = candidate.removesuffix("```").strip()
377
+ try:
378
+ payload = json.loads(candidate)
379
+ except json.JSONDecodeError as error:
380
+ raise GroundingError("The provider response was not valid grounded JSON.") from error
381
+ if not isinstance(payload, dict):
382
+ raise GroundingError("The provider response did not contain a grounded object.")
383
+
384
+ summary = _bounded_text(payload.get("summary"), "summary")
385
+ raw_walkthrough = payload.get("walkthrough")
386
+ if not isinstance(raw_walkthrough, list) or not raw_walkthrough:
387
+ raise GroundingError("The provider response omitted the source walkthrough.")
388
+ walkthrough: list[dict[str, object]] = []
389
+ for item in raw_walkthrough[:8]:
390
+ if not isinstance(item, dict) or not isinstance(item.get("line"), int):
391
+ raise GroundingError("A walkthrough item did not cite a real source line.")
392
+ line = item["line"]
393
+ if line < node.lineno or line > node.end_lineno:
394
+ raise GroundingError("A walkthrough citation fell outside the selected source span.")
395
+ walkthrough.append(
396
+ {
397
+ "line": line,
398
+ "citation": f"{node.file}:{line}",
399
+ "text": _bounded_text(item.get("explanation"), "walkthrough explanation"),
400
+ }
401
+ )
402
+
403
+ neighbor_by_id = {str(item["node_id"]): item for item in neighbors}
404
+ raw_relationships = payload.get("relationships", [])
405
+ if not isinstance(raw_relationships, list):
406
+ raise GroundingError("The provider relationships were not a list.")
407
+ relationships: list[dict[str, object]] = []
408
+ for item in raw_relationships[:8]:
409
+ if not isinstance(item, dict) or not isinstance(item.get("node_id"), str):
410
+ raise GroundingError("A relationship omitted its parser-proven node ID.")
411
+ neighbor = neighbor_by_id.get(item["node_id"])
412
+ if neighbor is None:
413
+ raise GroundingError("The provider named a relationship outside the parser graph.")
414
+ relationships.append(
415
+ {
416
+ "node_id": item["node_id"],
417
+ "citation": neighbor["citation"],
418
+ "certain": neighbor["certain"],
419
+ "text": _bounded_text(item.get("explanation"), "relationship explanation"),
420
+ }
421
+ )
422
+
423
+ return {
424
+ "summary": {"text": summary, "citation": f"{node.file}:{node.lineno}"},
425
+ "walkthrough": walkthrough,
426
+ "relationships": relationships,
427
+ }
428
+
429
+
430
+ def _bounded_text(value: object, label: str) -> str:
431
+ if not isinstance(value, str) or not value.strip():
432
+ raise GroundingError(f"The provider {label} was empty.")
433
+ text = value.strip()
434
+ if len(text) > 2400:
435
+ raise GroundingError(f"The provider {label} exceeded the grounded response limit.")
436
+ return text
437
+
438
+
439
+ __all__ = ["StudyService", "StudySourceError", "UnknownNodeError"]
@@ -0,0 +1,9 @@
1
+ """Local persistence: illumination state + concept star chart (~/.codemble/)."""
2
+
3
+ from codemble.progress.store import (
4
+ ProgressStore,
5
+ UnknownRegionError,
6
+ list_recent_projects,
7
+ )
8
+
9
+ __all__ = ["ProgressStore", "UnknownRegionError", "list_recent_projects"]
@@ -0,0 +1,160 @@
1
+ """File-hash-scoped local progress for parser regions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+
11
+ from codemble.adapters.base import Graph
12
+
13
+ _SCHEMA_VERSION = 1
14
+
15
+
16
+ class UnknownRegionError(KeyError):
17
+ """Raised when progress is requested for a region outside the graph."""
18
+
19
+
20
+ class ProgressStore:
21
+ """Persist understood regions without letting stale source stay lit."""
22
+
23
+ def __init__(self, graph: Graph, root: Path | None = None) -> None:
24
+ self._graph = graph
25
+ data_root = os.environ.get("CODEMBLE_DATA_DIR")
26
+ self._root = root or (
27
+ (Path(data_root).expanduser() if data_root else Path.home() / ".codemble")
28
+ / "progress"
29
+ )
30
+ project_key = hashlib.sha256(graph.project_root.encode()).hexdigest()[:20]
31
+ self.path = self._root / f"{project_key}.json"
32
+ self._signatures = _region_signatures(graph)
33
+
34
+ def understood_regions(self) -> frozenset[str]:
35
+ """Return only persisted regions whose current file hashes still match."""
36
+
37
+ saved = self._read().get("regions", {})
38
+ if not isinstance(saved, dict):
39
+ return frozenset()
40
+ return frozenset(
41
+ region_id
42
+ for region_id, signature in self._signatures.items()
43
+ if isinstance(saved.get(region_id), dict)
44
+ and saved[region_id].get("signature") == signature
45
+ )
46
+
47
+ def mark_understood(self, region_id: str) -> None:
48
+ """Persist the current signature for one proven region."""
49
+
50
+ signature = self._signatures.get(region_id)
51
+ if signature is None:
52
+ raise UnknownRegionError(region_id)
53
+ payload = self._read()
54
+ saved = payload.get("regions")
55
+ regions = saved if isinstance(saved, dict) else {}
56
+ regions[region_id] = {"signature": signature}
57
+ self._write(
58
+ {
59
+ "schema_version": _SCHEMA_VERSION,
60
+ "project_root": self._graph.project_root,
61
+ "regions": dict(sorted(regions.items())),
62
+ }
63
+ )
64
+
65
+ def hydrated_graph(self) -> Graph:
66
+ """Project valid progress onto immutable render data."""
67
+
68
+ understood = self.understood_regions()
69
+ nodes = tuple(
70
+ replace(node, understood=node.region in understood) for node in self._graph.nodes
71
+ )
72
+ regions = tuple(
73
+ replace(region, understood=region.id in understood)
74
+ for region in self._graph.regions
75
+ )
76
+ return replace(self._graph, nodes=nodes, regions=regions)
77
+
78
+ def _read(self) -> dict[str, object]:
79
+ try:
80
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
81
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
82
+ return self._empty_payload()
83
+ if (
84
+ not isinstance(payload, dict)
85
+ or payload.get("schema_version") != _SCHEMA_VERSION
86
+ or payload.get("project_root") != self._graph.project_root
87
+ ):
88
+ return self._empty_payload()
89
+ return payload
90
+
91
+ def _write(self, payload: dict[str, object]) -> None:
92
+ self._root.mkdir(parents=True, exist_ok=True)
93
+ temporary = self.path.with_suffix(f".{os.getpid()}.tmp")
94
+ try:
95
+ temporary.write_text(
96
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
97
+ encoding="utf-8",
98
+ )
99
+ temporary.replace(self.path)
100
+ finally:
101
+ temporary.unlink(missing_ok=True)
102
+
103
+ def _empty_payload(self) -> dict[str, object]:
104
+ return {
105
+ "schema_version": _SCHEMA_VERSION,
106
+ "project_root": self._graph.project_root,
107
+ "regions": {},
108
+ }
109
+
110
+
111
+ def _region_signatures(graph: Graph) -> dict[str, str]:
112
+ files_by_region: dict[str, set[str]] = {}
113
+ for node in graph.nodes:
114
+ files_by_region.setdefault(node.region, set()).add(node.file)
115
+ signatures: dict[str, str] = {}
116
+ for region_id, files in files_by_region.items():
117
+ evidence = [
118
+ (file, graph.file_hashes.get(file, "missing")) for file in sorted(files)
119
+ ]
120
+ signatures[region_id] = hashlib.sha256(
121
+ json.dumps(evidence, separators=(",", ":")).encode()
122
+ ).hexdigest()
123
+ return signatures
124
+
125
+
126
+ def list_recent_projects(limit: int = 8) -> list[dict[str, object]]:
127
+ """Return recently explored projects whose paths still exist, newest first."""
128
+
129
+ data_root = os.environ.get("CODEMBLE_DATA_DIR")
130
+ progress_root = (
131
+ (Path(data_root).expanduser() if data_root else Path.home() / ".codemble")
132
+ / "progress"
133
+ )
134
+ entries: list[tuple[float, dict[str, object]]] = []
135
+ try:
136
+ candidates = sorted(progress_root.glob("*.json"))
137
+ except OSError:
138
+ return []
139
+ for path in candidates:
140
+ try:
141
+ payload = json.loads(path.read_text(encoding="utf-8"))
142
+ modified = path.stat().st_mtime
143
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
144
+ continue
145
+ if not isinstance(payload, dict) or payload.get("schema_version") != _SCHEMA_VERSION:
146
+ continue
147
+ project_root = payload.get("project_root")
148
+ regions = payload.get("regions")
149
+ if not isinstance(project_root, str) or not isinstance(regions, dict):
150
+ continue
151
+ if not Path(project_root).is_dir():
152
+ continue
153
+ entries.append(
154
+ (modified, {"project_root": project_root, "understood_count": len(regions)})
155
+ )
156
+ entries.sort(key=lambda item: item[0], reverse=True)
157
+ return [entry for _, entry in entries[:limit]]
158
+
159
+
160
+ __all__ = ["ProgressStore", "UnknownRegionError", "list_recent_projects"]
@@ -0,0 +1,6 @@
1
+ """FastAPI app and local runtime for the Codemble experience."""
2
+
3
+ from codemble.server.app import create_app
4
+ from codemble.server.runtime import available_port, serve_project
5
+
6
+ __all__ = ["available_port", "create_app", "serve_project"]