differix 0.1.0a1__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 (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
tools/drift_guard.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ tools/drift_guard.py
3
+ --------------------
4
+ Detects external modifications to critical files during a running pipeline.
5
+
6
+ Problem this solves:
7
+ A single VegeX process holds an in-memory snapshot of state (and often
8
+ of the target file's pre-write content) across multiple pipeline steps.
9
+ If the user edits state.json or the target file between steps, the
10
+ process operates on stale data and writes decisions based on it.
11
+
12
+ Design:
13
+ - Every critical checkpoint computes a SHA-256 hash of the relevant
14
+ artifact (state.json, target file, plan).
15
+ - The hash is stored in a DriftGuard instance that lives for the
16
+ duration of one run_agent_cycle call.
17
+ - At each pipeline transition, verify() compares the expected hash
18
+ against the current on-disk hash.
19
+ - On mismatch, DriftDetected is raised. The caller can choose to
20
+ reload from disk (default: continue with new truth) or abort.
21
+
22
+ Determinism:
23
+ - State hashing normalizes via json.dumps(sort_keys=True, default=str)
24
+ so Python version and dict insertion order do not affect the hash.
25
+ - File hashing is raw SHA-256 of file bytes.
26
+
27
+ Public API:
28
+ - DriftGuard(project_root)
29
+ .pin_state(label) -> None
30
+ .check_state(label) -> bool (True = unchanged)
31
+ .pin_file(path, label) -> None
32
+ .check_file(path, label) -> bool
33
+ .report() -> dict
34
+ - DriftDetected(exception)
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import json
41
+ import logging
42
+ import os
43
+ from dataclasses import dataclass, field
44
+ from pathlib import Path
45
+ from typing import Any, Dict, Optional
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ class DriftDetected(Exception):
51
+ """Raised when a pinned artifact's hash no longer matches."""
52
+
53
+ def __init__(self, kind: str, label: str, path: str, expected: str, actual: str):
54
+ self.kind = kind
55
+ self.label = label
56
+ self.path = path
57
+ self.expected = expected
58
+ self.actual = actual
59
+ super().__init__(
60
+ f"Drift detected in {kind} ({label}) at {path}: "
61
+ f"expected={expected[:12]}…, actual={actual[:12]}…"
62
+ )
63
+
64
+
65
+ @dataclass
66
+ class PinRecord:
67
+ kind: str
68
+ label: str
69
+ path: str
70
+ expected: str
71
+ captured_at: str
72
+
73
+
74
+ @dataclass
75
+ class DriftGuard:
76
+ """Tracks expected hashes for critical artifacts during one pipeline run."""
77
+
78
+ project_root: str
79
+ _pins: Dict[str, PinRecord] = field(default_factory=dict)
80
+
81
+ # ------------------------------------------------------------------ #
82
+ # State hashing
83
+ # ------------------------------------------------------------------ #
84
+ def _hash_state_file(self, path: str) -> str:
85
+ """Canonical hash of a JSON file. Returns hex digest or 'missing'."""
86
+ p = Path(path)
87
+ if not p.exists():
88
+ return "missing"
89
+ try:
90
+ data = json.loads(p.read_text(encoding="utf-8"))
91
+ except Exception as exc:
92
+ logger.warning("Could not parse %s for hashing: %s", path, exc)
93
+ return "unreadable"
94
+ normalized = json.dumps(data, sort_keys=True, default=str)
95
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
96
+
97
+ def _hash_file(self, path: str) -> str:
98
+ """Raw SHA-256 of file bytes. Returns hex digest or 'missing'."""
99
+ p = Path(path)
100
+ if not p.exists():
101
+ return "missing"
102
+ try:
103
+ data = p.read_bytes()
104
+ except OSError as exc:
105
+ logger.warning("Could not read %s for hashing: %s", path, exc)
106
+ return "unreadable"
107
+ return hashlib.sha256(data).hexdigest()
108
+
109
+ # ------------------------------------------------------------------ #
110
+ # Pin / check
111
+ # ------------------------------------------------------------------ #
112
+ def pin_state(self, label: str, state_path: str) -> None:
113
+ """Record expected hash of a state file under a label."""
114
+ h = self._hash_state_file(state_path)
115
+ self._pins[f"state::{label}"] = PinRecord(
116
+ kind="state",
117
+ label=label,
118
+ path=os.path.abspath(state_path),
119
+ expected=h,
120
+ captured_at=_now(),
121
+ )
122
+
123
+ def check_state(self, label: str, state_path: str) -> bool:
124
+ """
125
+ Compare current state hash against the pinned one.
126
+ Returns True if unchanged, False if drifted. Never raises.
127
+ """
128
+ key = f"state::{label}"
129
+ pin = self._pins.get(key)
130
+ if pin is None:
131
+ return True
132
+ actual = self._hash_state_file(state_path)
133
+ if actual != pin.expected:
134
+ logger.warning(
135
+ "Drift: state.json changed externally between '%s' and now",
136
+ pin.label,
137
+ )
138
+ return False
139
+ return True
140
+
141
+ def pin_file(self, path: str, label: str) -> None:
142
+ """Record expected hash of an arbitrary file."""
143
+ h = self._hash_file(path)
144
+ self._pins[f"file::{label}"] = PinRecord(
145
+ kind="file",
146
+ label=label,
147
+ path=os.path.abspath(path),
148
+ expected=h,
149
+ captured_at=_now(),
150
+ )
151
+
152
+ def check_file(self, path: str, label: str) -> bool:
153
+ """
154
+ Compare current file hash against the pinned one.
155
+ Returns True if unchanged, False if drifted. Never raises.
156
+ """
157
+ key = f"file::{label}"
158
+ pin = self._pins.get(key)
159
+ if pin is None:
160
+ return True
161
+ actual = self._hash_file(path)
162
+ if actual != pin.expected:
163
+ logger.warning(
164
+ "Drift: file %s changed externally between '%s' and now",
165
+ path,
166
+ pin.label,
167
+ )
168
+ return False
169
+ return True
170
+
171
+ # ------------------------------------------------------------------ #
172
+ # Reporting
173
+ # ------------------------------------------------------------------ #
174
+ def report(self) -> Dict[str, Any]:
175
+ """Return a snapshot of the current pin set."""
176
+ return {
177
+ "project_root": self.project_root,
178
+ "pins": {
179
+ k: {
180
+ "kind": v.kind,
181
+ "label": v.label,
182
+ "path": v.path,
183
+ "expected": v.expected,
184
+ "captured_at": v.captured_at,
185
+ }
186
+ for k, v in self._pins.items()
187
+ },
188
+ }
189
+
190
+
191
+ def _now() -> str:
192
+ import time
193
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
194
+
195
+
196
+ __all__ = ["DriftGuard", "DriftDetected", "PinRecord"]
tools/file_manager.py ADDED
@@ -0,0 +1,119 @@
1
+ import os
2
+ import stat
3
+
4
+ # Prefer O_NOFOLLOW where available (Linux/BSD); fall back to 0 on platforms
5
+ # lacking it, where the fstat/lstat verification below still catches symlinks.
6
+ _O_NOFOLLOW = os.O_NOFOLLOW if hasattr(os, "O_NOFOLLOW") else 0
7
+
8
+
9
+ class FolderSandbox:
10
+
11
+ def __init__(self, root_dir: str):
12
+ self.root_dir = os.path.realpath(os.path.expanduser(root_dir))
13
+
14
+ def _get_absolute_path(self, target_path: str) -> str:
15
+ expanded = os.path.expanduser(target_path)
16
+ if os.path.isabs(expanded):
17
+ return os.path.realpath(expanded)
18
+ return os.path.realpath(os.path.join(self.root_dir, expanded))
19
+
20
+ def _is_safe_path(self, abs_target: str) -> bool:
21
+ """Validates that target file stays inside active project directory anywhere on disk."""
22
+ try:
23
+ return os.path.commonpath([self.root_dir, abs_target]) == self.root_dir
24
+ except ValueError:
25
+ # Handles cross-drive path checks on Windows/mounted volumes
26
+ return False
27
+
28
+ def _open_sandboxed(self, abs_path: str, write: bool):
29
+ """
30
+ Open a file inside the sandbox without following symlinks.
31
+
32
+ Hardens against TOCTOU symlink swaps: the final component is opened
33
+ with O_NOFOLLOW, the opened descriptor is verified to be a regular
34
+ file, and the on-disk path is re-checked against the descriptor to
35
+ detect a symlink swapped in between resolution and open.
36
+ """
37
+ if not self._is_safe_path(abs_path):
38
+ raise PermissionError(
39
+ f"Path '{abs_path}' is outside active workspace root '{self.root_dir}'."
40
+ )
41
+
42
+ if write:
43
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
44
+ else:
45
+ flags = os.O_RDONLY
46
+ flags |= _O_NOFOLLOW
47
+
48
+ try:
49
+ fd = os.open(abs_path, flags, 0o600)
50
+ except OSError as e:
51
+ raise PermissionError(f"Cannot open '{abs_path}': {e}")
52
+
53
+ try:
54
+ st_fd = os.fstat(fd)
55
+ if not stat.S_ISREG(st_fd.st_mode):
56
+ raise PermissionError(
57
+ f"Refusing non-regular file '{abs_path}'."
58
+ )
59
+ # Detect a symlink swapped in at the final component after open.
60
+ try:
61
+ st_path = os.lstat(abs_path)
62
+ except OSError:
63
+ raise PermissionError(f"Path '{abs_path}' disappeared during open.")
64
+ if (st_path.st_dev, st_path.st_ino) != (st_fd.st_dev, st_fd.st_ino):
65
+ raise PermissionError(
66
+ f"Path '{abs_path}' changed between check and open (symlink race)."
67
+ )
68
+ # Re-verify containment against the fully resolved real path.
69
+ if not self._is_safe_path(os.path.realpath(abs_path)):
70
+ raise PermissionError(
71
+ f"Resolved path of '{abs_path}' escapes workspace root '{self.root_dir}'."
72
+ )
73
+ return os.fdopen(fd, "w" if write else "r", encoding="utf-8")
74
+ except Exception:
75
+ os.close(fd)
76
+ raise
77
+
78
+ def _ensure_parent_dir(self, abs_path: str) -> None:
79
+ """Create parent directories only when they resolve inside the sandbox."""
80
+ parent = os.path.dirname(abs_path)
81
+ if os.path.islink(parent):
82
+ raise PermissionError(f"Parent directory '{parent}' is a symlink.")
83
+ if not self._is_safe_path(os.path.realpath(parent)):
84
+ raise PermissionError(
85
+ f"Parent directory '{parent}' escapes workspace root '{self.root_dir}'."
86
+ )
87
+ os.makedirs(parent, exist_ok=True)
88
+
89
+ def safe_read_file(self, path: str):
90
+ abs_path = self._get_absolute_path(path)
91
+
92
+ if not self._is_safe_path(abs_path):
93
+ return f"Error: Path '{path}' is outside active workspace root '{self.root_dir}'."
94
+ if not os.path.exists(abs_path):
95
+ return f"Error: File '{abs_path}' does not exist."
96
+
97
+ try:
98
+ with self._open_sandboxed(abs_path, write=False) as f:
99
+ return f.read()
100
+ except PermissionError as e:
101
+ return f"Error: {e}"
102
+ except Exception as e:
103
+ return f"Error reading file: {e}"
104
+
105
+ def safe_write_code(self, path: str, content: str):
106
+ abs_path = self._get_absolute_path(path)
107
+
108
+ if not self._is_safe_path(abs_path):
109
+ return f"Error: Path '{path}' is outside active workspace root '{self.root_dir}'."
110
+
111
+ try:
112
+ self._ensure_parent_dir(abs_path)
113
+ with self._open_sandboxed(abs_path, write=True) as f:
114
+ f.write(content)
115
+ return f"Successfully wrote to '{abs_path}'"
116
+ except PermissionError as e:
117
+ return f"Error: {e}"
118
+ except Exception as e:
119
+ return f"Error writing file: {e}"
tools/handshake.py ADDED
@@ -0,0 +1,351 @@
1
+ """
2
+ tools/handshake.py
3
+ -------------------
4
+ Assembles a deterministic, budget-aware session context for agents.
5
+
6
+ Sections, in priority order (highest to lowest):
7
+ 1. Task brief — never trimmed
8
+ 2. Active plan — never trimmed below the current subtask
9
+ 3. Structure — public signatures of relevant modules only
10
+ 4. ADR — recent session decisions
11
+ 5. Lessons — A-Score ranked, role-scoped
12
+
13
+ Determinism contract:
14
+ - Given identical inputs (state, index, ADR, lessons), the rendered
15
+ string is byte-identical. This preserves provider-side prompt caching.
16
+ - This function is PURE. It never records trigger counters, never
17
+ writes to the memory database, never mutates state. If a caller wants
18
+ to record that the retrieved lessons were actually injected into a
19
+ prompt, they must call `tools.lesson_manager.record_lesson_trigger()`
20
+ separately, using `result.lesson_node_ids`.
21
+
22
+ Public API:
23
+ - SessionContextResult (dataclass)
24
+ - build_session_context(project_root, state, role, token_budget=None)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Dict, List, Optional
32
+
33
+ from tools.session_adr import SessionADR
34
+ from tools.structure_index import StructureIndex, ModuleInfo
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ DEFAULT_BUDGETS: Dict[str, int] = {
39
+ "researcher": 8000,
40
+ "editor": 6000, # TCA shares this with T2/T3 now — keep smaller
41
+ "reviewer": 6000,
42
+ "default": 5000,
43
+ }
44
+
45
+ _SHARES = {
46
+ "brief": 0.05,
47
+ "plan": 0.20,
48
+ "structure": 0.25,
49
+ "adr": 0.15,
50
+ "lessons": 0.35,
51
+ }
52
+
53
+ _CHARS_PER_TOKEN = 4
54
+ _SAFETY_MARGIN = 0.9
55
+
56
+
57
+ @dataclass
58
+ class SessionContextResult:
59
+ """Output of build_session_context. Deterministic given identical inputs."""
60
+
61
+ text: str = ""
62
+ lesson_node_ids: List[str] = field(default_factory=list)
63
+ sections_present: List[str] = field(default_factory=list)
64
+ estimated_tokens: int = 0
65
+ role: str = "researcher"
66
+
67
+ def __bool__(self) -> bool:
68
+ return bool(self.text)
69
+
70
+
71
+ def build_session_context(
72
+ project_root: str,
73
+ state: dict,
74
+ role: str = "researcher",
75
+ token_budget: Optional[int] = None,
76
+ include_structure: bool = True,
77
+ include_lessons: bool = True,
78
+ include_adr: bool = True,
79
+ ) -> SessionContextResult:
80
+ """
81
+ Assemble the handshake context. Never raises.
82
+
83
+ On any internal error, degrades gracefully — a missing section is
84
+ silently dropped rather than crashing the caller.
85
+ """
86
+ budget = token_budget or DEFAULT_BUDGETS.get(role, DEFAULT_BUDGETS["default"])
87
+ char_budget = int(budget * _CHARS_PER_TOKEN * _SAFETY_MARGIN)
88
+
89
+ parts: List[str] = []
90
+ sections: List[str] = []
91
+
92
+ brief = _render_brief(state)
93
+ if brief:
94
+ parts.append(brief)
95
+ sections.append("brief")
96
+
97
+ plan = _render_plan(state, char_limit=int(char_budget * _SHARES["plan"]))
98
+ if plan:
99
+ parts.append(plan)
100
+ sections.append("plan")
101
+
102
+ if include_structure:
103
+ structure = _render_structure(
104
+ project_root, state, char_limit=int(char_budget * _SHARES["structure"])
105
+ )
106
+ if structure:
107
+ parts.append(structure)
108
+ sections.append("structure")
109
+
110
+ if include_adr:
111
+ adr = _render_adr(
112
+ project_root, token_limit=int(budget * _SHARES["adr"])
113
+ )
114
+ if adr:
115
+ parts.append(adr)
116
+ sections.append("adr")
117
+
118
+ lesson_ids: List[str] = []
119
+ if include_lessons:
120
+ lessons, lesson_ids = _render_lessons(
121
+ state, role, char_limit=int(char_budget * _SHARES["lessons"])
122
+ )
123
+ if lessons:
124
+ parts.append(lessons)
125
+ sections.append("lessons")
126
+
127
+ total = sum(len(p) + 2 for p in parts)
128
+ while total > char_budget and len(parts) > 2:
129
+ parts.pop()
130
+ sections.pop()
131
+ total = sum(len(p) + 2 for p in parts)
132
+
133
+ text = "\n\n".join(parts)
134
+ return SessionContextResult(
135
+ text=text,
136
+ lesson_node_ids=lesson_ids,
137
+ sections_present=sections,
138
+ estimated_tokens=len(text) // _CHARS_PER_TOKEN,
139
+ role=role,
140
+ )
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Section renderers
145
+ # ---------------------------------------------------------------------------
146
+ def _render_brief(state: dict) -> str:
147
+ goal = (
148
+ state.get("goal")
149
+ or state.get("task_prompt")
150
+ or (state.get("task_spec") or {}).get("raw_user_prompt", "")
151
+ or ""
152
+ )
153
+ subtask = state.get("active_subtask") or ""
154
+ target = state.get("target_file") or ""
155
+
156
+ if not goal and not subtask:
157
+ return ""
158
+
159
+ lines = ["## TASK BRIEF"]
160
+ if goal:
161
+ lines.append(f"Goal: {_clip(str(goal), 400)}")
162
+ if subtask:
163
+ lines.append(f"Current subtask: {_clip(str(subtask), 200)}")
164
+ if target:
165
+ lines.append(f"Target file: {target}")
166
+ return "\n".join(lines)
167
+
168
+
169
+ def _render_plan(state: dict, char_limit: int) -> str:
170
+ plan = state.get("plan", []) or []
171
+ if not isinstance(plan, list) or not plan:
172
+ return ""
173
+
174
+ lines = ["## ACTIVE PLAN"]
175
+ total = len(lines[0]) + 1
176
+ for step in plan:
177
+ if not isinstance(step, dict):
178
+ continue
179
+ status = str(step.get("status", "pending")).upper()
180
+ title = str(step.get("title", "")).strip()
181
+ if not title:
182
+ continue
183
+ entry = f"- [{status}] {_clip(title, 160)}"
184
+ if total + len(entry) + 1 > char_limit:
185
+ lines.append(" ... [additional steps truncated]")
186
+ break
187
+ lines.append(entry)
188
+ total += len(entry) + 1
189
+ return "\n".join(lines) if len(lines) > 1 else ""
190
+
191
+
192
+ def _render_structure(project_root: str, state: dict, char_limit: int) -> str:
193
+ try:
194
+ idx = StructureIndex.rebuild(project_root)
195
+ except Exception as exc:
196
+ logger.warning("Structure rebuild failed in handshake: %s", exc)
197
+ return ""
198
+
199
+ relevant = _select_relevant_modules(idx, state)
200
+ if not relevant:
201
+ return ""
202
+
203
+ lines = ["## RELEVANT MODULE SIGNATURES"]
204
+ total = len(lines[0]) + 1
205
+
206
+ for mod_name in sorted(relevant):
207
+ mod = idx.modules.get(mod_name)
208
+ if mod is None or mod.syntax_error:
209
+ continue
210
+ block = _render_module_block(mod)
211
+ if total + len(block) + 1 > char_limit:
212
+ lines.append("... [additional modules truncated]")
213
+ break
214
+ lines.append(block)
215
+ total += len(block) + 1
216
+ return "\n".join(lines) if len(lines) > 1 else ""
217
+
218
+
219
+ def _select_relevant_modules(idx: StructureIndex, state: dict) -> List[str]:
220
+ seeds: set = set()
221
+
222
+ target = state.get("target_file")
223
+ if target:
224
+ mod = _module_for_path(str(target))
225
+ if mod:
226
+ seeds.add(mod)
227
+
228
+ plan = state.get("plan", []) or []
229
+ for step in plan:
230
+ if isinstance(step, dict):
231
+ tf = step.get("target_file")
232
+ if tf:
233
+ mod = _module_for_path(str(tf))
234
+ if mod:
235
+ seeds.add(mod)
236
+
237
+ expanded: set = set(seeds)
238
+ for mod_name in seeds:
239
+ mod = idx.modules.get(mod_name)
240
+ if mod is None:
241
+ continue
242
+ for imp in mod.imports:
243
+ dep = imp.module
244
+ if dep and dep in idx.modules:
245
+ expanded.add(dep)
246
+
247
+ return list(expanded)
248
+
249
+
250
+ def _render_module_block(mod: ModuleInfo) -> str:
251
+ lines = [f"### {mod.name} ({mod.file})"]
252
+ if mod.classes:
253
+ for cname in sorted(mod.classes):
254
+ cls = mod.classes[cname]
255
+ bases = f"({', '.join(cls.bases)})" if cls.bases else ""
256
+ lines.append(f" class {cname}{bases}:")
257
+ for mname in sorted(cls.methods):
258
+ m = cls.methods[mname]
259
+ if mname.startswith("_") and mname not in (
260
+ "__init__", "__enter__", "__exit__"
261
+ ):
262
+ continue
263
+ lines.append(f" {_format_func(m)}")
264
+ if mod.functions:
265
+ for fname in sorted(mod.functions):
266
+ f = mod.functions[fname]
267
+ if fname.startswith("_"):
268
+ continue
269
+ lines.append(f" {_format_func(f)}")
270
+ return "\n".join(lines)
271
+
272
+
273
+ def _format_func(f) -> str:
274
+ prefix = "async " if f.is_async else ""
275
+ params = ", ".join(p.name for p in f.params if p.name != "self")
276
+ ret = f" -> {f.returns}" if f.returns else ""
277
+ return f"{prefix}{f.name}({params}){ret}"
278
+
279
+
280
+ def _render_adr(project_root: str, token_limit: int) -> str:
281
+ try:
282
+ adr = SessionADR(project_root)
283
+ return adr.get_recent_summary(limit=5, token_budget=token_limit)
284
+ except Exception as exc:
285
+ logger.warning("ADR read failed in handshake: %s", exc)
286
+ return ""
287
+
288
+
289
+ def _render_lessons(state: dict, role: str, char_limit: int):
290
+ """
291
+ Role-scoped A-Score lesson retrieval.
292
+
293
+ Returns (rendered_text, node_ids). On failure returns ("", []).
294
+
295
+ IMPORTANT: This function MUST be side-effect-free. It passes
296
+ record_trigger=False so two consecutive calls with identical inputs
297
+ return byte-identical output (prompt-caching determinism).
298
+ """
299
+ try:
300
+ from tools.lesson_manager import get_ranked_lessons_for_role
301
+ except Exception:
302
+ return "", []
303
+
304
+ query = " ".join(filter(None, [
305
+ str(state.get("active_subtask") or ""),
306
+ str(state.get("research_analysis") or ""),
307
+ str(state.get("goal") or ""),
308
+ str(state.get("target_file") or ""),
309
+ ])).strip() or "general development"
310
+
311
+ try:
312
+ _scored, ctx, ids = get_ranked_lessons_for_role(
313
+ role=role,
314
+ query=query,
315
+ limit=5,
316
+ record_trigger=False,
317
+ )
318
+ except Exception as exc:
319
+ logger.warning("Lesson retrieval failed in handshake: %s", exc)
320
+ return "", []
321
+
322
+ if not ctx:
323
+ return "", []
324
+
325
+ if len(ctx) > char_limit:
326
+ ctx = ctx[:char_limit] + "\n...[lessons truncated]"
327
+
328
+ return ctx, list(ids or [])
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Helpers
333
+ # ---------------------------------------------------------------------------
334
+ def _module_for_path(path: str) -> Optional[str]:
335
+ p = path.replace("\\", "/")
336
+ if not p.endswith(".py"):
337
+ return None
338
+ if p.endswith("/__init__.py"):
339
+ p = p[: -len("/__init__.py")]
340
+ elif p.endswith(".py"):
341
+ p = p[:-3]
342
+ return p.replace("/", ".")
343
+
344
+
345
+ def _clip(text: str, limit: int) -> str:
346
+ if len(text) <= limit:
347
+ return text
348
+ return text[: limit - 3] + "..."
349
+
350
+
351
+ __all__ = ["build_session_context", "SessionContextResult", "DEFAULT_BUDGETS"]