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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/security/verification_firewall.py
|
|
3
|
+
----------------------------------------
|
|
4
|
+
Agent self-reports are ADVISORY. Only external signals are AUTHORITATIVE.
|
|
5
|
+
|
|
6
|
+
Problem this solves (August 2026 incident):
|
|
7
|
+
An agent in a loop hallucinated "test passed" and the pipeline believed it,
|
|
8
|
+
because `state.get("compiler_output")` was populated by the agent itself.
|
|
9
|
+
The agent then spent tokens on simulated work instead of real work.
|
|
10
|
+
|
|
11
|
+
Solution:
|
|
12
|
+
build_success MUST come from run_universal_build(), never from state.
|
|
13
|
+
apply_result.ok MUST be verified via fs-hash before/after, not trusted.
|
|
14
|
+
Anything the agent claims about success must be cross-checked.
|
|
15
|
+
|
|
16
|
+
This module provides those cross-checks.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fs_hash(path: str) -> str:
|
|
25
|
+
"""SHA-256 of file content, or empty string if file doesn't exist."""
|
|
26
|
+
if not os.path.exists(path):
|
|
27
|
+
return ""
|
|
28
|
+
try:
|
|
29
|
+
with open(path, "rb") as f:
|
|
30
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
31
|
+
except OSError:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def verify_file_changed(path: str, before_hash: str) -> tuple[bool, str]:
|
|
36
|
+
"""
|
|
37
|
+
Verify that the file actually changed after the agent claimed a write.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
(changed, current_hash)
|
|
41
|
+
"""
|
|
42
|
+
after_hash = fs_hash(path)
|
|
43
|
+
changed = (after_hash != before_hash)
|
|
44
|
+
return changed, after_hash
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify_compiler_output(
|
|
48
|
+
compiler_output_from_state: str,
|
|
49
|
+
actual_compiler_output: str,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Cross-check what state says vs what the compiler actually said.
|
|
53
|
+
|
|
54
|
+
In a healthy run, these match. In a confused-deputy attack, they diverge.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
{"match": bool, "state_claimed": str, "actual": str, "tampered": bool}
|
|
58
|
+
"""
|
|
59
|
+
# Normalize whitespace for comparison
|
|
60
|
+
state_norm = (compiler_output_from_state or "").strip()
|
|
61
|
+
actual_norm = (actual_compiler_output or "").strip()
|
|
62
|
+
|
|
63
|
+
match = state_norm == actual_norm
|
|
64
|
+
tampered = bool(state_norm) and bool(actual_norm) and not match
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"match": match,
|
|
68
|
+
"state_claimed": state_norm[:200],
|
|
69
|
+
"actual": actual_norm[:200],
|
|
70
|
+
"tampered": tampered,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_build_result(
|
|
75
|
+
claimed_success: bool,
|
|
76
|
+
actual_success: bool,
|
|
77
|
+
actual_log: str,
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""
|
|
80
|
+
Cross-check what state/build_success says vs what run_universal_build returned.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
{"consistent": bool, "trusted_success": bool, "warnings": [str]}
|
|
84
|
+
"""
|
|
85
|
+
warnings = []
|
|
86
|
+
|
|
87
|
+
if claimed_success != actual_success:
|
|
88
|
+
warnings.append(
|
|
89
|
+
f"build_success mismatch: state={claimed_success} "
|
|
90
|
+
f"but run_universal_build={actual_success}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
trusted_success = actual_success # Only trust run_universal_build
|
|
94
|
+
|
|
95
|
+
if claimed_success and not actual_success:
|
|
96
|
+
warnings.append(
|
|
97
|
+
"AGENT CLAIMED SUCCESS BUT COMPILER FAILED. "
|
|
98
|
+
"This is the 'simulated test passed' incident pattern."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"consistent": claimed_success == actual_success,
|
|
103
|
+
"trusted_success": trusted_success,
|
|
104
|
+
"warnings": warnings,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = [
|
|
109
|
+
"fs_hash",
|
|
110
|
+
"verify_file_changed",
|
|
111
|
+
"verify_compiler_output",
|
|
112
|
+
"verify_build_result",
|
|
113
|
+
]
|
tools/security_filter.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/security_filter.py
|
|
3
|
+
------------------------
|
|
4
|
+
Detects dangerous code patterns before file write.
|
|
5
|
+
|
|
6
|
+
Two categories of patterns:
|
|
7
|
+
|
|
8
|
+
1. ALWAYS_BLOCKED — remote code execution, arbitrary deserialization,
|
|
9
|
+
subprocess spawning. Blocked in every mode, including RSI.
|
|
10
|
+
|
|
11
|
+
2. CLEANUP_ALLOWED — file deletion primitives (unlink, rmtree, rmdir).
|
|
12
|
+
Blocked in normal mode (agent editing a user project could destroy
|
|
13
|
+
data). Allowed in RSI mode (framework itself needs these for
|
|
14
|
+
rollback, temp cleanup, and atomic file operations).
|
|
15
|
+
|
|
16
|
+
Mode is selected by config.RSI_MODE. The filter never distinguishes
|
|
17
|
+
between new and existing files — a cleanup call is either allowed or
|
|
18
|
+
not, regardless of context. This is simpler and more predictable than
|
|
19
|
+
the previous diff-based approach.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import re
|
|
24
|
+
from typing import List, Tuple
|
|
25
|
+
|
|
26
|
+
_LINE_COMMENT_RE = re.compile(r'^\s*(#|//)')
|
|
27
|
+
_TRAILING_COMMENT_RE = re.compile(r'\s+(#|//).*$')
|
|
28
|
+
_BLOCK_COMMENT_RE = re.compile(r'/\*.*?\*/', re.DOTALL)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Category 1: always blocked. RCE, deserialization, process spawning.
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
ALWAYS_BLOCKED_PATTERNS = {
|
|
35
|
+
r'\beval\s*\(': "eval()",
|
|
36
|
+
r'\bexec\s*\(': "exec()",
|
|
37
|
+
r'\bcompile\s*\(': "compile() (builds executable code objects)",
|
|
38
|
+
r'__import__\s*\(': "__import__()",
|
|
39
|
+
r'os\.popen\s*\(': "os.popen()",
|
|
40
|
+
r'os\.exec[lv]p?e?\s*\(': "os.exec*() family",
|
|
41
|
+
r'os\.spawn[lv]p?e?\s*\(': "os.spawn*() family",
|
|
42
|
+
r'pty\.spawn\s*\(': "pty.spawn()",
|
|
43
|
+
r'ctypes\.(CDLL|WinDLL|windll|cdll)': "ctypes native code loading",
|
|
44
|
+
r'pickle\.loads?\s*\(': "pickle.load()/loads() (arbitrary deserialization)",
|
|
45
|
+
r'marshal\.loads?\s*\(': "marshal.load()/loads()",
|
|
46
|
+
r'getattr\s*\(\s*(os|subprocess|shutil|pickle|marshal|ctypes)\s*,': "dynamic getattr(module, ...) - possible obfuscated dangerous call",
|
|
47
|
+
r'os\.system\s*\(': "os.system()",
|
|
48
|
+
r'subprocess\.(run|Popen|call|check_output|check_call)\s*\(': "subprocess call",
|
|
49
|
+
|
|
50
|
+
r'rm\s+-rf': "rm -rf",
|
|
51
|
+
r'Remove-Item\s+.*-Recurse': "Remove-Item -Recurse",
|
|
52
|
+
r'del\s+/s': "del /s",
|
|
53
|
+
|
|
54
|
+
r'def\s+test_.*inspect': "Inspection test definition (test_inspect)",
|
|
55
|
+
r'raise\s+Exception\s*\(.*(os\.listdir|glob)\s*\(': "Raising exception to dump workspace files",
|
|
56
|
+
r'assert\s+False\s*,\s*.*(inspect|workspace)': "Intentional assert failure to dump workspace context",
|
|
57
|
+
r'sys\.stderr\.write\s*\(.*open\s*\(': "Writing file content to stderr for log leakage",
|
|
58
|
+
r'FILE_CONTENT_START': "Context dumping string markers detected",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Category 2: cleanup primitives. Blocked in normal mode, allowed in RSI.
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
CLEANUP_PATTERNS = {
|
|
66
|
+
r'os\.remove\s*\(': "os.remove()",
|
|
67
|
+
r'os\.unlink\s*\(': "os.unlink()",
|
|
68
|
+
r'os\.rmdir\s*\(': "os.rmdir()",
|
|
69
|
+
r'os\.removedirs\s*\(': "os.removedirs()",
|
|
70
|
+
r'shutil\.rmtree\s*\(': "shutil.rmtree()",
|
|
71
|
+
r'\.unlink\s*\(': "Path.unlink()",
|
|
72
|
+
r'shutil\.move\s*\(.*\.{2}[/\\]': "shutil.move with parent directory traversal",
|
|
73
|
+
r'os\.rename\s*\(.*\.{2}[/\\]': "os.rename with parent directory traversal",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _strip_comments_only(code: str) -> str:
|
|
78
|
+
"""Strip single-line and block comments. Triple-quoted strings intact."""
|
|
79
|
+
cleaned = _BLOCK_COMMENT_RE.sub('', code)
|
|
80
|
+
lines = []
|
|
81
|
+
for line in cleaned.splitlines():
|
|
82
|
+
if _LINE_COMMENT_RE.match(line):
|
|
83
|
+
continue
|
|
84
|
+
lines.append(_TRAILING_COMMENT_RE.sub('', line))
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _regex_scan(code: str, patterns: dict) -> List[str]:
|
|
89
|
+
cleaned = _strip_comments_only(code)
|
|
90
|
+
hits = []
|
|
91
|
+
for pattern, label in patterns.items():
|
|
92
|
+
if re.search(pattern, cleaned, re.IGNORECASE | re.DOTALL):
|
|
93
|
+
hits.append(label)
|
|
94
|
+
return hits
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_DANGEROUS_CALL_NAMES = {"eval", "exec", "compile", "__import__"}
|
|
98
|
+
|
|
99
|
+
# AST-level dangerous call chains. Split into the same two categories.
|
|
100
|
+
_ALWAYS_BLOCKED_ATTR_CHAINS = {
|
|
101
|
+
("os", "system"): "os.system()",
|
|
102
|
+
("os", "popen"): "os.popen()",
|
|
103
|
+
("os", "execl"): "os.exec*() family",
|
|
104
|
+
("os", "execle"): "os.exec*() family",
|
|
105
|
+
("os", "execlp"): "os.exec*() family",
|
|
106
|
+
("os", "execlpe"): "os.exec*() family",
|
|
107
|
+
("os", "execv"): "os.exec*() family",
|
|
108
|
+
("os", "execve"): "os.exec*() family",
|
|
109
|
+
("os", "execvp"): "os.exec*() family",
|
|
110
|
+
("os", "execvpe"): "os.exec*() family",
|
|
111
|
+
("os", "spawnl"): "os.spawn*() family",
|
|
112
|
+
("os", "spawnv"): "os.spawn*() family",
|
|
113
|
+
("subprocess", "run"): "subprocess call",
|
|
114
|
+
("subprocess", "Popen"): "subprocess call",
|
|
115
|
+
("subprocess", "call"): "subprocess call",
|
|
116
|
+
("subprocess", "check_output"): "subprocess call",
|
|
117
|
+
("subprocess", "check_call"): "subprocess call",
|
|
118
|
+
("pickle", "load"): "pickle.load()/loads() (arbitrary deserialization)",
|
|
119
|
+
("pickle", "loads"): "pickle.load()/loads() (arbitrary deserialization)",
|
|
120
|
+
("marshal", "load"): "marshal.load()/loads()",
|
|
121
|
+
("marshal", "loads"): "marshal.load()/loads()",
|
|
122
|
+
("pty", "spawn"): "pty.spawn()",
|
|
123
|
+
("ctypes", "CDLL"): "ctypes native code loading",
|
|
124
|
+
("ctypes", "WinDLL"): "ctypes native code loading",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_CLEANUP_ATTR_CHAINS = {
|
|
128
|
+
("os", "remove"): "os.remove()",
|
|
129
|
+
("os", "unlink"): "os.unlink()",
|
|
130
|
+
("os", "rmdir"): "os.rmdir()",
|
|
131
|
+
("os", "removedirs"): "os.removedirs()",
|
|
132
|
+
("shutil", "rmtree"): "shutil.rmtree()",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_GETATTR_WATCHED_MODULES = {"os", "subprocess", "shutil", "pickle", "marshal", "ctypes"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _attr_chain(node: ast.AST) -> Tuple[str, ...]:
|
|
139
|
+
parts = []
|
|
140
|
+
while isinstance(node, ast.Attribute):
|
|
141
|
+
parts.append(node.attr)
|
|
142
|
+
node = node.value
|
|
143
|
+
if isinstance(node, ast.Name):
|
|
144
|
+
parts.append(node.id)
|
|
145
|
+
return tuple(reversed(parts))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ast_scan(code: str, allow_cleanup: bool) -> List[str]:
|
|
149
|
+
try:
|
|
150
|
+
tree = ast.parse(code)
|
|
151
|
+
except (SyntaxError, ValueError):
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
hits = []
|
|
155
|
+
for node in ast.walk(tree):
|
|
156
|
+
if not isinstance(node, ast.Call):
|
|
157
|
+
continue
|
|
158
|
+
func = node.func
|
|
159
|
+
|
|
160
|
+
if isinstance(func, ast.Name):
|
|
161
|
+
if func.id in _DANGEROUS_CALL_NAMES:
|
|
162
|
+
hits.append(f"{func.id}() (AST)")
|
|
163
|
+
continue
|
|
164
|
+
if func.id == "getattr" and node.args:
|
|
165
|
+
first_arg = node.args[0]
|
|
166
|
+
if isinstance(first_arg, ast.Name) and first_arg.id in _GETATTR_WATCHED_MODULES:
|
|
167
|
+
hits.append(f"dynamic getattr({first_arg.id}, ...) - possible obfuscated call (AST)")
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if isinstance(func, ast.Attribute):
|
|
171
|
+
chain = _attr_chain(func)
|
|
172
|
+
if len(chain) >= 2:
|
|
173
|
+
key = (chain[-2], chain[-1])
|
|
174
|
+
if key in _ALWAYS_BLOCKED_ATTR_CHAINS:
|
|
175
|
+
hits.append(f"{_ALWAYS_BLOCKED_ATTR_CHAINS[key]} (AST)")
|
|
176
|
+
elif not allow_cleanup and key in _CLEANUP_ATTR_CHAINS:
|
|
177
|
+
hits.append(f"{_CLEANUP_ATTR_CHAINS[key]} (AST)")
|
|
178
|
+
|
|
179
|
+
return hits
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find_dangerous_code(code: str, allow_cleanup: bool = False) -> list:
|
|
183
|
+
"""
|
|
184
|
+
Detect dangerous code patterns.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
code: source text to scan.
|
|
188
|
+
allow_cleanup: if True, file deletion primitives (unlink, rmtree,
|
|
189
|
+
rmdir, remove) are permitted. Used in RSI mode where the
|
|
190
|
+
framework itself contains legitimate cleanup code.
|
|
191
|
+
|
|
192
|
+
Returns a deduplicated list of human-readable labels.
|
|
193
|
+
"""
|
|
194
|
+
if not isinstance(code, str) or not code.strip():
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
hits = []
|
|
198
|
+
hits.extend(_regex_scan(code, ALWAYS_BLOCKED_PATTERNS))
|
|
199
|
+
if not allow_cleanup:
|
|
200
|
+
hits.extend(_regex_scan(code, CLEANUP_PATTERNS))
|
|
201
|
+
hits.extend(_ast_scan(code, allow_cleanup))
|
|
202
|
+
|
|
203
|
+
seen = set()
|
|
204
|
+
deduped = []
|
|
205
|
+
for h in hits:
|
|
206
|
+
if h not in seen:
|
|
207
|
+
seen.add(h)
|
|
208
|
+
deduped.append(h)
|
|
209
|
+
return deduped
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def is_safe(code: str, allow_cleanup: bool = False) -> bool:
|
|
213
|
+
return len(find_dangerous_code(code, allow_cleanup=allow_cleanup)) == 0
|
tools/session_adr.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/session_adr.py
|
|
3
|
+
---------------------
|
|
4
|
+
Compressed session history (Architecture Decision Records) for VegeX.
|
|
5
|
+
|
|
6
|
+
Storage layout:
|
|
7
|
+
.vegex/session_adr.jsonl append-only, one JSON object per line
|
|
8
|
+
.vegex/session_adr.compact.json derived, written periodically
|
|
9
|
+
|
|
10
|
+
Raw JSONL is the source of truth. Compact JSON is regenerated from the raw
|
|
11
|
+
when the raw file exceeds RAW_COMPACT_THRESHOLD lines, or on explicit call.
|
|
12
|
+
Deleting the compact file is safe — it will be rebuilt on next read.
|
|
13
|
+
|
|
14
|
+
Public API:
|
|
15
|
+
- ADREntry (dataclass)
|
|
16
|
+
- SessionADR(root).append(entry)
|
|
17
|
+
- SessionADR(root).get_recent_summary(limit=5, token_budget=300) -> str
|
|
18
|
+
- SessionADR(root).get_recent_entries(limit=20) -> List[ADREntry]
|
|
19
|
+
- SessionADR(root).compact(keep_raw=200)
|
|
20
|
+
- SessionADR(root).stats()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import tempfile
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field, asdict
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Constants
|
|
38
|
+
RAW_COMPACT_THRESHOLD = 400 # compact when JSONL exceeds this many lines
|
|
39
|
+
DEFAULT_KEEP_RAW = 200
|
|
40
|
+
SCHEMA_VERSION = "1.0.0"
|
|
41
|
+
|
|
42
|
+
# Rough token estimator. Deliberately conservative (4 chars/token) with an
|
|
43
|
+
# additional 10% safety margin applied at format time. Real tokenizers vary
|
|
44
|
+
# by ±20% across providers — this is for budgeting, not billing.
|
|
45
|
+
_CHARS_PER_TOKEN = 4
|
|
46
|
+
_BUDGET_SAFETY_MARGIN = 0.9
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# ADR entry
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
@dataclass
|
|
53
|
+
class ADREntry:
|
|
54
|
+
session_id: str
|
|
55
|
+
cycle: int
|
|
56
|
+
timestamp: str # ISO-8601 UTC
|
|
57
|
+
action: str # "research" | "edit" | "review" | "refactor" | "bugfix" | "promote"
|
|
58
|
+
outcome: str # "accepted" | "rejected" | "reverted" | "info"
|
|
59
|
+
files_touched: List[str] = field(default_factory=list)
|
|
60
|
+
contract_changes: List[str] = field(default_factory=list)
|
|
61
|
+
decisions: List[str] = field(default_factory=list)
|
|
62
|
+
duration_ms: int = 0
|
|
63
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def to_json_line(self) -> str:
|
|
66
|
+
return json.dumps(asdict(self), ensure_ascii=False)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, d: Dict[str, Any]) -> "ADREntry":
|
|
70
|
+
return cls(
|
|
71
|
+
session_id=str(d.get("session_id", "")),
|
|
72
|
+
cycle=int(d.get("cycle", 0)),
|
|
73
|
+
timestamp=str(d.get("timestamp", "")),
|
|
74
|
+
action=str(d.get("action", "unknown")),
|
|
75
|
+
outcome=str(d.get("outcome", "info")),
|
|
76
|
+
files_touched=list(d.get("files_touched", []) or []),
|
|
77
|
+
contract_changes=list(d.get("contract_changes", []) or []),
|
|
78
|
+
decisions=list(d.get("decisions", []) or []),
|
|
79
|
+
duration_ms=int(d.get("duration_ms", 0) or 0),
|
|
80
|
+
extra=dict(d.get("extra", {}) or {}),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Manager
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
class SessionADR:
|
|
88
|
+
def __init__(self, project_root: str) -> None:
|
|
89
|
+
self.root = Path(project_root).resolve()
|
|
90
|
+
self.dir = self.root / ".vegex"
|
|
91
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
self.raw_path = self.dir / "session_adr.jsonl"
|
|
93
|
+
self.compact_path = self.dir / "session_adr.compact.json"
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
# Append
|
|
97
|
+
# ------------------------------------------------------------------ #
|
|
98
|
+
def append(self, entry: ADREntry) -> None:
|
|
99
|
+
"""Append one entry. Uses O_APPEND for atomicity on POSIX."""
|
|
100
|
+
line = entry.to_json_line() + "\n"
|
|
101
|
+
try:
|
|
102
|
+
fd = os.open(self.raw_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
103
|
+
try:
|
|
104
|
+
os.write(fd, line.encode("utf-8"))
|
|
105
|
+
finally:
|
|
106
|
+
os.close(fd)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
logger.warning("ADR append failed: %s", exc)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Opportunistic compaction
|
|
112
|
+
try:
|
|
113
|
+
if self._line_count() >= RAW_COMPACT_THRESHOLD:
|
|
114
|
+
self.compact()
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
logger.warning("ADR compact check failed: %s", exc)
|
|
117
|
+
|
|
118
|
+
def _line_count(self) -> int:
|
|
119
|
+
try:
|
|
120
|
+
with open(self.raw_path, "rb") as f:
|
|
121
|
+
return sum(1 for _ in f)
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------ #
|
|
126
|
+
# Read
|
|
127
|
+
# ------------------------------------------------------------------ #
|
|
128
|
+
def get_recent_entries(self, limit: int = 20) -> List[ADREntry]:
|
|
129
|
+
"""Read the tail of the raw JSONL. Corrupt lines are skipped."""
|
|
130
|
+
if not self.raw_path.exists():
|
|
131
|
+
return []
|
|
132
|
+
try:
|
|
133
|
+
with open(self.raw_path, "r", encoding="utf-8") as f:
|
|
134
|
+
lines = f.readlines()
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
logger.warning("ADR read failed: %s", exc)
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
entries: List[ADREntry] = []
|
|
140
|
+
for line in lines[-limit:]:
|
|
141
|
+
line = line.strip()
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
entries.append(ADREntry.from_dict(json.loads(line)))
|
|
146
|
+
except Exception:
|
|
147
|
+
continue
|
|
148
|
+
return entries
|
|
149
|
+
|
|
150
|
+
def get_recent_summary(
|
|
151
|
+
self,
|
|
152
|
+
limit: int = 5,
|
|
153
|
+
token_budget: int = 300,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Render the last `limit` entries as a compact LLM-injectable string.
|
|
157
|
+
|
|
158
|
+
Returns empty string if there are no entries.
|
|
159
|
+
"""
|
|
160
|
+
entries = self.get_recent_entries(limit=limit)
|
|
161
|
+
if not entries:
|
|
162
|
+
return ""
|
|
163
|
+
|
|
164
|
+
char_budget = int(token_budget * _CHARS_PER_TOKEN * _BUDGET_SAFETY_MARGIN)
|
|
165
|
+
|
|
166
|
+
# Format newest-first, drop entries when we exceed budget
|
|
167
|
+
rendered: List[str] = ["## RECENT SESSION DECISIONS", ""]
|
|
168
|
+
for e in reversed(entries):
|
|
169
|
+
block = self._format_entry(e)
|
|
170
|
+
if sum(len(l) + 1 for l in rendered) + len(block) > char_budget:
|
|
171
|
+
break
|
|
172
|
+
rendered.append(block)
|
|
173
|
+
|
|
174
|
+
if len(rendered) <= 2:
|
|
175
|
+
return "" # nothing fit
|
|
176
|
+
return "\n".join(rendered)
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _format_entry(e: ADREntry) -> str:
|
|
180
|
+
ts_short = e.timestamp[:16].replace("T", " ") if e.timestamp else "?"
|
|
181
|
+
lines = [f"[cycle {e.cycle} | {ts_short}] {e.action} → {e.outcome}"]
|
|
182
|
+
if e.files_touched:
|
|
183
|
+
lines.append(f" files: {', '.join(e.files_touched[:4])}")
|
|
184
|
+
if e.contract_changes:
|
|
185
|
+
lines.append(f" contract: {'; '.join(e.contract_changes[:3])}")
|
|
186
|
+
if e.decisions:
|
|
187
|
+
for d in e.decisions[:2]:
|
|
188
|
+
short = d if len(d) <= 140 else d[:137] + "..."
|
|
189
|
+
lines.append(f" - {short}")
|
|
190
|
+
return "\n".join(lines) + "\n"
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------ #
|
|
193
|
+
# Compaction
|
|
194
|
+
# ------------------------------------------------------------------ #
|
|
195
|
+
def compact(self, keep_raw: int = DEFAULT_KEEP_RAW) -> Dict[str, Any]:
|
|
196
|
+
"""
|
|
197
|
+
Prune the raw JSONL to the last `keep_raw` entries, and write a
|
|
198
|
+
compact summary of every entry ever seen. Idempotent.
|
|
199
|
+
"""
|
|
200
|
+
if not self.raw_path.exists():
|
|
201
|
+
return {"status": "no_raw"}
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
with open(self.raw_path, "r", encoding="utf-8") as f:
|
|
205
|
+
all_lines = [l for l in f if l.strip()]
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
return {"status": "read_error", "error": str(exc)}
|
|
208
|
+
|
|
209
|
+
total = len(all_lines)
|
|
210
|
+
kept_lines = all_lines[-keep_raw:] if total > keep_raw else all_lines
|
|
211
|
+
|
|
212
|
+
# All entries (for compact json)
|
|
213
|
+
all_entries: List[ADREntry] = []
|
|
214
|
+
for line in all_lines:
|
|
215
|
+
try:
|
|
216
|
+
all_entries.append(ADREntry.from_dict(json.loads(line)))
|
|
217
|
+
except Exception:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Pruned raw
|
|
221
|
+
try:
|
|
222
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.dir), suffix=".jsonl.tmp")
|
|
223
|
+
try:
|
|
224
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
225
|
+
f.writelines(kept_lines)
|
|
226
|
+
os.replace(tmp, self.raw_path)
|
|
227
|
+
except Exception:
|
|
228
|
+
try:
|
|
229
|
+
os.unlink(tmp)
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
raise
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
return {"status": "write_raw_error", "error": str(exc)}
|
|
235
|
+
|
|
236
|
+
# Compact file
|
|
237
|
+
compact_data = {
|
|
238
|
+
"schema_version": SCHEMA_VERSION,
|
|
239
|
+
"compacted_at": _utc_iso(),
|
|
240
|
+
"total_entries_ever": total,
|
|
241
|
+
"kept_in_raw": len(kept_lines),
|
|
242
|
+
"entries": [asdict(e) for e in all_entries],
|
|
243
|
+
}
|
|
244
|
+
try:
|
|
245
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.dir), suffix=".json.tmp")
|
|
246
|
+
try:
|
|
247
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
248
|
+
json.dump(compact_data, f, ensure_ascii=False, indent=2)
|
|
249
|
+
os.replace(tmp, self.compact_path)
|
|
250
|
+
except Exception:
|
|
251
|
+
try:
|
|
252
|
+
os.unlink(tmp)
|
|
253
|
+
except OSError:
|
|
254
|
+
pass
|
|
255
|
+
raise
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
return {"status": "write_compact_error", "error": str(exc)}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
"status": "ok",
|
|
261
|
+
"total_entries": total,
|
|
262
|
+
"kept_in_raw": len(kept_lines),
|
|
263
|
+
"pruned": total - len(kept_lines),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------ #
|
|
267
|
+
# Stats / lifecycle
|
|
268
|
+
# ------------------------------------------------------------------ #
|
|
269
|
+
def stats(self) -> Dict[str, Any]:
|
|
270
|
+
return {
|
|
271
|
+
"raw_path": str(self.raw_path),
|
|
272
|
+
"raw_exists": self.raw_path.exists(),
|
|
273
|
+
"raw_lines": self._line_count(),
|
|
274
|
+
"compact_exists": self.compact_path.exists(),
|
|
275
|
+
"compact_threshold": RAW_COMPACT_THRESHOLD,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Entry builder (convenience)
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
def build_entry(
|
|
283
|
+
session_id: str,
|
|
284
|
+
cycle: int,
|
|
285
|
+
action: str,
|
|
286
|
+
outcome: str,
|
|
287
|
+
files_touched: Optional[Iterable[str]] = None,
|
|
288
|
+
contract_changes: Optional[Iterable[str]] = None,
|
|
289
|
+
decisions: Optional[Iterable[str]] = None,
|
|
290
|
+
duration_ms: int = 0,
|
|
291
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
292
|
+
) -> ADREntry:
|
|
293
|
+
return ADREntry(
|
|
294
|
+
session_id=session_id or str(uuid.uuid4()),
|
|
295
|
+
cycle=int(cycle),
|
|
296
|
+
timestamp=_utc_iso(),
|
|
297
|
+
action=action,
|
|
298
|
+
outcome=outcome,
|
|
299
|
+
files_touched=list(files_touched or []),
|
|
300
|
+
contract_changes=list(contract_changes or []),
|
|
301
|
+
decisions=list(decisions or []),
|
|
302
|
+
duration_ms=int(duration_ms),
|
|
303
|
+
extra=dict(extra or {}),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _utc_iso() -> str:
|
|
308
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
__all__ = ["ADREntry", "SessionADR", "build_entry"]
|