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
@@ -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
+ ]
@@ -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"]