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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- 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"]
|