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
@@ -0,0 +1,132 @@
1
+ """Deterministic layout metadata for pure render consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ from collections import defaultdict
8
+ from dataclasses import replace
9
+
10
+ from codemble.adapters.base import Graph, Node, Region, RegionEdge
11
+
12
+ _GOLDEN_ANGLE = math.pi * (3.0 - math.sqrt(5.0))
13
+ _SYSTEM_RING_CAPACITY = 12
14
+
15
+
16
+ def layout_graph(graph: Graph) -> Graph:
17
+ """Return ``graph`` with stable galaxy and system coordinates filled.
18
+
19
+ Coordinates depend only on stable identifiers and sorted membership. No
20
+ clock, process hash seed, or random source participates in the result.
21
+ """
22
+
23
+ grouped: dict[str, list[Node]] = defaultdict(list)
24
+ for node in graph.nodes:
25
+ grouped[node.region].append(node)
26
+
27
+ region_order = sorted(grouped, key=lambda region_id: (_digest(region_id), region_id))
28
+ regions: list[Region] = []
29
+ positioned_nodes: list[Node] = []
30
+ node_by_id = {node.id: node for node in graph.nodes}
31
+
32
+ for region_index, region_id in enumerate(region_order):
33
+ members = sorted(
34
+ grouped[region_id],
35
+ key=lambda node: (node.kind != "module", node.id),
36
+ )
37
+ region_angle = region_index * _GOLDEN_ANGLE + _fraction(region_id, "phase") * 0.18
38
+ region_radius = 42.0 + 54.0 * math.sqrt(region_index)
39
+ region_x = _rounded(math.cos(region_angle) * region_radius)
40
+ region_y = _rounded(((_fraction(region_id, "height") * 2.0) - 1.0) * 28.0)
41
+ region_z = _rounded(math.sin(region_angle) * region_radius)
42
+
43
+ module_nodes = [node for node in members if node.kind == "module"]
44
+ loc = sum(node.loc for node in module_nodes) or sum(node.loc for node in members)
45
+ regions.append(
46
+ Region(
47
+ id=region_id,
48
+ language=members[0].language,
49
+ loc=loc,
50
+ centrality=sum(node.centrality for node in members),
51
+ node_count=len(members),
52
+ understood=bool(members) and all(node.understood for node in members),
53
+ home=any(node.id == graph.selected_entrypoint for node in members),
54
+ x=region_x,
55
+ y=region_y,
56
+ z=region_z,
57
+ )
58
+ )
59
+
60
+ for member_index, node in enumerate(members):
61
+ if member_index == 0:
62
+ positioned_nodes.append(
63
+ replace(node, system_x=0.0, system_y=0.0, system_z=0.0)
64
+ )
65
+ continue
66
+ orbit_index = member_index - 1
67
+ ring = orbit_index // _SYSTEM_RING_CAPACITY
68
+ slot = orbit_index % _SYSTEM_RING_CAPACITY
69
+ ring_members = min(
70
+ _SYSTEM_RING_CAPACITY,
71
+ max(1, len(members) - 1 - ring * _SYSTEM_RING_CAPACITY),
72
+ )
73
+ angle = (2.0 * math.pi * slot / ring_members) + _fraction(node.id, "orbit") * 0.08
74
+ radius = 34.0 + ring * 24.0
75
+ positioned_nodes.append(
76
+ replace(
77
+ node,
78
+ system_x=_rounded(math.cos(angle) * radius),
79
+ system_y=_rounded(((_fraction(node.id, "depth") * 2.0) - 1.0) * 8.0),
80
+ system_z=_rounded(math.sin(angle) * radius),
81
+ )
82
+ )
83
+
84
+ routes: dict[tuple[str, str], list[bool]] = defaultdict(list)
85
+ for edge in graph.edges:
86
+ if edge.kind != "import" or edge.external:
87
+ continue
88
+ src_node = node_by_id.get(edge.src)
89
+ dst_node = node_by_id.get(edge.dst)
90
+ if src_node is None or dst_node is None or src_node.region == dst_node.region:
91
+ continue
92
+ routes[(src_node.region, dst_node.region)].append(edge.certain)
93
+
94
+ region_edges = tuple(
95
+ RegionEdge(src=src, dst=dst, weight=len(certainties), certain=all(certainties))
96
+ for (src, dst), certainties in sorted(routes.items())
97
+ )
98
+ return replace(
99
+ graph,
100
+ nodes=tuple(sorted(positioned_nodes, key=lambda node: node.id)),
101
+ regions=tuple(sorted(regions, key=lambda region: region.id)),
102
+ region_edges=region_edges,
103
+ )
104
+
105
+
106
+ def with_entrypoint(graph: Graph, node_id: str) -> Graph:
107
+ """Select one parser-ranked candidate as Home without changing layout."""
108
+
109
+ if node_id not in graph.entrypoint_candidates:
110
+ raise ValueError(f"entrypoint is not a parser-ranked candidate: {node_id}")
111
+ node_by_id = {node.id: node for node in graph.nodes}
112
+ selected = node_by_id[node_id]
113
+ regions = tuple(
114
+ replace(region, home=region.id == selected.region) for region in graph.regions
115
+ )
116
+ return replace(graph, selected_entrypoint=node_id, regions=regions)
117
+
118
+
119
+ def _digest(value: str, salt: str = "") -> bytes:
120
+ return hashlib.sha256(f"{salt}:{value}".encode()).digest()
121
+
122
+
123
+ def _fraction(value: str, salt: str) -> float:
124
+ integer = int.from_bytes(_digest(value, salt)[:8], "big")
125
+ return integer / float((1 << 64) - 1)
126
+
127
+
128
+ def _rounded(value: float) -> float:
129
+ return round(value, 6)
130
+
131
+
132
+ __all__ = ["layout_graph", "with_entrypoint"]
@@ -0,0 +1,18 @@
1
+ """Language lens: parser-detected idiom annotations to teachable notes."""
2
+
3
+ from codemble.adapters.base import ConceptAnnotation
4
+ from codemble.lens.javascript_typescript import javascript_typescript_lens_notes
5
+ from codemble.lens.python import python_lens_notes
6
+
7
+
8
+ def lens_notes(language: str, annotations: list[ConceptAnnotation]) -> list[dict[str, object]]:
9
+ """Route proven annotations to the matching language lens."""
10
+
11
+ if language == "python":
12
+ return python_lens_notes(annotations)
13
+ if language in {"javascript", "typescript"}:
14
+ return javascript_typescript_lens_notes(language, annotations)
15
+ return []
16
+
17
+
18
+ __all__ = ["lens_notes"]
@@ -0,0 +1,82 @@
1
+ """Teachable JS/TS notes keyed only by tree-sitter concept evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codemble.adapters.base import ConceptAnnotation
6
+
7
+ _NOTES = {
8
+ "async-await": (
9
+ "Async / await",
10
+ "This syntax pauses the current async flow until the awaited value settles without blocking the JavaScript event loop.",
11
+ ),
12
+ "arrow-function": (
13
+ "Arrow function",
14
+ "This compact function form captures `this` from its surrounding scope instead of creating its own `this` binding.",
15
+ ),
16
+ "destructuring": (
17
+ "Destructuring",
18
+ "This pattern binds selected array positions or object properties directly to local names.",
19
+ ),
20
+ "optional-chaining": (
21
+ "Optional chaining",
22
+ "This chain stops and yields `undefined` when the value before `?.` is `null` or `undefined`.",
23
+ ),
24
+ "nullish-coalescing": (
25
+ "Nullish coalescing",
26
+ "This expression uses its right side only when the left side is `null` or `undefined`, preserving values such as `0` and an empty string.",
27
+ ),
28
+ "module-syntax": (
29
+ "Module syntax",
30
+ "This declaration makes a dependency or exported binding explicit in the source module graph.",
31
+ ),
32
+ "type-annotation": (
33
+ "Type annotation",
34
+ "TypeScript uses this annotation for static checking and editor tooling; the annotation itself does not become a runtime check.",
35
+ ),
36
+ "interface": (
37
+ "Interface",
38
+ "This TypeScript declaration names a structural type contract for checking and tooling without creating a runtime value.",
39
+ ),
40
+ "generic": (
41
+ "Generic",
42
+ "This type parameter preserves a relationship between types while letting callers supply a concrete type.",
43
+ ),
44
+ "jsx": (
45
+ "JSX",
46
+ "This syntax describes an element or component tree that the configured toolchain transforms into JavaScript.",
47
+ ),
48
+ }
49
+
50
+
51
+ def javascript_typescript_lens_notes(
52
+ language: str,
53
+ annotations: list[ConceptAnnotation],
54
+ ) -> list[dict[str, object]]:
55
+ """Attach deterministic teaching copy to matching JS/TS annotations."""
56
+
57
+ if language not in {"javascript", "typescript"}:
58
+ return []
59
+ notes: list[dict[str, object]] = []
60
+ for annotation in annotations:
61
+ if annotation.language != language:
62
+ continue
63
+ note = _NOTES.get(annotation.concept)
64
+ if note is None:
65
+ continue
66
+ title, explanation = note
67
+ notes.append(
68
+ {
69
+ "node_id": annotation.node_id,
70
+ "language": annotation.language,
71
+ "concept": annotation.concept,
72
+ "title": title,
73
+ "note": explanation,
74
+ "line": annotation.lineno,
75
+ "end_line": annotation.end_lineno,
76
+ "snippet": annotation.snippet,
77
+ }
78
+ )
79
+ return notes
80
+
81
+
82
+ __all__ = ["javascript_typescript_lens_notes"]
@@ -0,0 +1,71 @@
1
+ """Teachable Python notes keyed only by parser-detected concept IDs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codemble.adapters.base import ConceptAnnotation
6
+
7
+ _PYTHON_NOTES = {
8
+ "decorator": (
9
+ "Decorator",
10
+ "Python evaluates this decorator while defining the structure and binds the returned object to its name.",
11
+ ),
12
+ "comprehension": (
13
+ "Comprehension",
14
+ "This expression builds a collection through inline iteration and optional filtering.",
15
+ ),
16
+ "generator": (
17
+ "Generator",
18
+ "This construct produces values lazily instead of building the complete sequence at once.",
19
+ ),
20
+ "context-manager": (
21
+ "Context manager",
22
+ "The `with` protocol brackets this block with managed setup and cleanup behavior.",
23
+ ),
24
+ "async-await": (
25
+ "Async / await",
26
+ "This construct participates in Python's asynchronous protocol and may yield control while work is pending.",
27
+ ),
28
+ "dunder-method": (
29
+ "Dunder method",
30
+ "Python calls this specially named method through a language protocol such as length, comparison, or display.",
31
+ ),
32
+ "exception-handling": (
33
+ "Exception handling",
34
+ "This construct makes failure part of explicit control flow by catching, grouping, or raising an exception.",
35
+ ),
36
+ "type-hint": (
37
+ "Type hint",
38
+ "This annotation communicates an expected type to readers and tooling; Python does not enforce it by default.",
39
+ ),
40
+ }
41
+
42
+
43
+ def python_lens_notes(
44
+ annotations: list[ConceptAnnotation],
45
+ ) -> list[dict[str, object]]:
46
+ """Attach deterministic teaching copy to proven Python annotations."""
47
+
48
+ notes: list[dict[str, object]] = []
49
+ for annotation in annotations:
50
+ if annotation.language != "python":
51
+ continue
52
+ note = _PYTHON_NOTES.get(annotation.concept)
53
+ if note is None:
54
+ continue
55
+ title, explanation = note
56
+ notes.append(
57
+ {
58
+ "node_id": annotation.node_id,
59
+ "language": annotation.language,
60
+ "concept": annotation.concept,
61
+ "title": title,
62
+ "note": explanation,
63
+ "line": annotation.lineno,
64
+ "end_line": annotation.end_lineno,
65
+ "snippet": annotation.snippet,
66
+ }
67
+ )
68
+ return notes
69
+
70
+
71
+ __all__ = ["python_lens_notes"]
@@ -0,0 +1,6 @@
1
+ """Grounded narration: provider adapters, validation, and file-hash cache."""
2
+
3
+ from codemble.llm.providers import AnthropicProvider, OpenAIProvider
4
+ from codemble.llm.study import StudyService
5
+
6
+ __all__ = ["AnthropicProvider", "OpenAIProvider", "StudyService"]
@@ -0,0 +1,137 @@
1
+ """Direct BYO-key narration adapters for the supported external providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Callable, Protocol
8
+ from urllib import error, request
9
+
10
+ from codemble import __version__
11
+
12
+ JsonObject = dict[str, object]
13
+ PostJson = Callable[[str, dict[str, str], JsonObject], JsonObject]
14
+
15
+
16
+ class ProviderError(RuntimeError):
17
+ """A provider request failed without exposing credentials or response bodies."""
18
+
19
+
20
+ class NarrationProvider(Protocol):
21
+ """The transport seam used by the study module."""
22
+
23
+ name: str
24
+ model: str
25
+
26
+ def complete(self, prompt: str) -> str:
27
+ """Return the provider's text response for one grounded prompt."""
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class AnthropicProvider:
32
+ """Anthropic Messages adapter using the user's key directly."""
33
+
34
+ api_key: str
35
+ model: str = "claude-sonnet-5"
36
+ post_json: PostJson = field(default_factory=lambda: _post_json, repr=False)
37
+ name: str = field(default="anthropic", init=False)
38
+
39
+ def complete(self, prompt: str) -> str:
40
+ payload = self.post_json(
41
+ "https://api.anthropic.com/v1/messages",
42
+ {
43
+ "anthropic-version": "2023-06-01",
44
+ "content-type": "application/json",
45
+ "x-api-key": self.api_key,
46
+ },
47
+ {
48
+ "model": self.model,
49
+ "max_tokens": 1200,
50
+ "messages": [{"role": "user", "content": prompt}],
51
+ },
52
+ )
53
+ content = payload.get("content")
54
+ if not isinstance(content, list):
55
+ raise ProviderError("Anthropic returned no text content.")
56
+ text = "".join(
57
+ str(block.get("text", ""))
58
+ for block in content
59
+ if isinstance(block, dict) and block.get("type") == "text"
60
+ ).strip()
61
+ if not text:
62
+ raise ProviderError("Anthropic returned an empty text response.")
63
+ return text
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class OpenAIProvider:
68
+ """OpenAI Responses adapter using the user's key directly."""
69
+
70
+ api_key: str
71
+ model: str = "gpt-5.4-mini"
72
+ post_json: PostJson = field(default_factory=lambda: _post_json, repr=False)
73
+ name: str = field(default="openai", init=False)
74
+
75
+ def complete(self, prompt: str) -> str:
76
+ payload = self.post_json(
77
+ "https://api.openai.com/v1/responses",
78
+ {
79
+ "authorization": f"Bearer {self.api_key}",
80
+ "content-type": "application/json",
81
+ },
82
+ {
83
+ "model": self.model,
84
+ "input": prompt,
85
+ "max_output_tokens": 1200,
86
+ "store": False,
87
+ },
88
+ )
89
+ output = payload.get("output")
90
+ if not isinstance(output, list):
91
+ raise ProviderError("OpenAI returned no output items.")
92
+ text_parts: list[str] = []
93
+ for item in output:
94
+ if not isinstance(item, dict) or item.get("type") != "message":
95
+ continue
96
+ content = item.get("content")
97
+ if not isinstance(content, list):
98
+ continue
99
+ text_parts.extend(
100
+ str(block.get("text", ""))
101
+ for block in content
102
+ if isinstance(block, dict) and block.get("type") == "output_text"
103
+ )
104
+ text = "".join(text_parts).strip()
105
+ if not text:
106
+ raise ProviderError("OpenAI returned an empty text response.")
107
+ return text
108
+
109
+
110
+ def _post_json(url: str, headers: dict[str, str], payload: JsonObject) -> JsonObject:
111
+ encoded = json.dumps(payload).encode("utf-8")
112
+ outbound = request.Request(
113
+ url,
114
+ data=encoded,
115
+ headers={**headers, "user-agent": f"Codemble/{__version__}"},
116
+ method="POST",
117
+ )
118
+ try:
119
+ with request.urlopen(outbound, timeout=60) as response: # noqa: S310 - fixed HTTPS URLs
120
+ decoded = json.loads(response.read().decode("utf-8"))
121
+ except error.HTTPError as provider_error:
122
+ raise ProviderError(
123
+ f"The provider rejected the request with HTTP {provider_error.code}."
124
+ ) from provider_error
125
+ except (error.URLError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError) as exc:
126
+ raise ProviderError("The provider request could not be completed safely.") from exc
127
+ if not isinstance(decoded, dict):
128
+ raise ProviderError("The provider returned an unexpected response shape.")
129
+ return decoded
130
+
131
+
132
+ __all__ = [
133
+ "AnthropicProvider",
134
+ "NarrationProvider",
135
+ "OpenAIProvider",
136
+ "ProviderError",
137
+ ]