tamfis-code 0.2.3__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.
@@ -0,0 +1,364 @@
1
+ """The standalone agent loop: calls an LLM provider directly (via
2
+ providers.py's ProviderManager) and runs its own tool-calling loop locally,
3
+ with no TamfisGPT Remote Workspace backend involved at all.
4
+
5
+ Generalizes local_chat.py's run_local_turn (which already proved the basic
6
+ send-tools -> parse tool_calls -> execute -> append role:"tool" -> resend
7
+ pattern works against HF/NVIDIA NIM/OpenRouter/Ollama) into the primary,
8
+ full-capability loop:
9
+ - streaming + tool-calling combined (local_chat.py's run_local_turn has
10
+ tools but no streaming; stream_local_turn streams but has no tools)
11
+ - the full tool set via mcp.py's MCPServer (read/write/edit/execute/etc),
12
+ not just the four read-only tools -- gated per call through safety.py's
13
+ risk classifier and runner.py's existing resolve_approval_decision
14
+ - an open-ended round loop (a high safety-valve cap, not local_chat.py's
15
+ hard MAX_TOOL_ROUNDS=5) with real termination conditions
16
+
17
+ Emits the same event-dict shape StreamRenderer.handle_event already expects
18
+ (assistant_delta/tool_call_requested/tool_output/file_mutation/
19
+ approval_required/ai_task_completed/ai_task_failed) so render.py needs no
20
+ changes to work with a local loop instead of remote SSE events.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+ from rich.console import Console
31
+
32
+ from . import state as local_state
33
+ from .mcp import MCPServer
34
+ from .providers import ProviderManager, ProviderType
35
+ from .render import StreamRenderer
36
+ from .runner import TaskOutcome, resolve_approval_decision
37
+ from .safety import READ_ONLY_TOOLS, classify_tool_call_risk
38
+
39
+ # A safety-valve ceiling, not a target -- local_chat.py's MAX_TOOL_ROUNDS=5
40
+ # was appropriate for a read-only Q&A loop; a real coding-agent task
41
+ # legitimately needs many more tool calls (read several files, make several
42
+ # edits, run tests, iterate). This exists only to guarantee termination if
43
+ # something is genuinely stuck in a loop, not to cap normal work.
44
+ MAX_AGENT_ROUNDS = 200
45
+
46
+
47
+ def _latest_user_text(messages: list[dict[str, Any]]) -> str:
48
+ for message in reversed(messages):
49
+ if message.get("role") == "user":
50
+ return str(message.get("content") or "").strip().lower()
51
+ return ""
52
+
53
+
54
+ def _is_plain_conversation(messages: list[dict[str, Any]]) -> bool:
55
+ """Return True for turns that cannot reasonably require repository tools.
56
+
57
+ This is intentionally conservative. It prevents greetings and basic
58
+ conversational prompts from sending a large function catalogue to small
59
+ local models such as llama3.2:3b, which may serialize invented function
60
+ calls into ordinary assistant text instead of emitting tool_calls.
61
+ """
62
+ text = _latest_user_text(messages)
63
+ if not text:
64
+ return True
65
+ exact = {
66
+ "hi", "hello", "hey", "hi there", "hello there",
67
+ "good morning", "good afternoon", "good evening",
68
+ "how are you", "how are you?", "thanks", "thank you",
69
+ }
70
+ if text in exact:
71
+ return True
72
+ conversational_prefixes = (
73
+ "who are you", "what can you do", "tell me about yourself",
74
+ )
75
+ return text.startswith(conversational_prefixes)
76
+
77
+
78
+ @dataclass
79
+ class _StreamedToolCall:
80
+ call_id: str = ""
81
+ name: str = ""
82
+ arguments: str = ""
83
+
84
+
85
+ async def _stream_one_completion(
86
+ client, *, model: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]],
87
+ renderer: StreamRenderer,
88
+ ) -> tuple[str, list[_StreamedToolCall]]:
89
+ """Stream one chat-completions call, forwarding text deltas to the
90
+ renderer as they arrive and accumulating any tool_calls deltas by index
91
+ (the standard OpenAI-compatible streaming tool-call pattern: id/name
92
+ only arrive in the first delta for a given index, arguments arrive as
93
+ incremental string fragments across many deltas)."""
94
+ content_parts: list[str] = []
95
+ tool_calls_by_index: dict[int, _StreamedToolCall] = {}
96
+
97
+ request_kwargs: dict[str, Any] = {
98
+ "model": model,
99
+ "messages": messages,
100
+ "stream": True,
101
+ "temperature": 0.2,
102
+ "max_tokens": 4096,
103
+ }
104
+ # Do not send an empty tools array. Some OpenAI-compatible servers and
105
+ # small local models behave differently merely because tool mode is
106
+ # present, even when no tool is useful for the current turn.
107
+ if tools:
108
+ request_kwargs["tools"] = tools
109
+ request_kwargs["tool_choice"] = "auto"
110
+
111
+ stream = await client.chat.completions.create(**request_kwargs)
112
+ async for chunk in stream:
113
+ if not chunk.choices:
114
+ continue
115
+ delta = chunk.choices[0].delta
116
+ if getattr(delta, "content", None):
117
+ content_parts.append(delta.content)
118
+ renderer.handle_event({"event_type": "assistant_delta", "payload": {"content": delta.content}})
119
+ for tc_delta in getattr(delta, "tool_calls", None) or []:
120
+ slot = tool_calls_by_index.setdefault(tc_delta.index, _StreamedToolCall())
121
+ if tc_delta.id:
122
+ slot.call_id = tc_delta.id
123
+ fn = getattr(tc_delta, "function", None)
124
+ if fn is not None:
125
+ if fn.name:
126
+ slot.name = fn.name
127
+ if fn.arguments:
128
+ slot.arguments += fn.arguments
129
+
130
+ ordered_calls = [tool_calls_by_index[i] for i in sorted(tool_calls_by_index)]
131
+ return "".join(content_parts), ordered_calls
132
+
133
+
134
+ async def run_local_agent_turn(
135
+ manager: ProviderManager,
136
+ provider: ProviderType,
137
+ model: Optional[str],
138
+ messages: list[dict[str, Any]],
139
+ console: Console,
140
+ renderer: StreamRenderer,
141
+ *,
142
+ workspace_root: str,
143
+ session_id: int,
144
+ approval_policy: str = "ask",
145
+ interactive: bool = True,
146
+ max_rounds: int = MAX_AGENT_ROUNDS,
147
+ read_only: bool = False,
148
+ ) -> TaskOutcome:
149
+ """Run one user turn to completion against a directly-called provider,
150
+ executing tool calls locally (full tool set, approval-gated) instead of
151
+ delegating to a Remote Workspace backend. Mirrors run_ai_task_and_stream's
152
+ contract (same TaskOutcome shape) so cli.py/interactive.py can drive
153
+ either the local or (while it still exists) remote path interchangeably.
154
+
155
+ `read_only=True` restricts both the tool schema offered to the model AND
156
+ (defense in depth, in case a model requests a tool it wasn't offered)
157
+ execution itself to safety.READ_ONLY_TOOLS -- used for chat/audit/plan
158
+ modes, which must never mutate the workspace.
159
+ """
160
+ # Emit lifecycle events before any provider/network operation. Without
161
+ # these, the Rich live panel remains at its constructor default (idle)
162
+ # while get_client()/chat.completions.create() is resolving or waiting.
163
+ renderer.handle_event({"event_type": "task_started", "payload": {"mode": "local"}})
164
+ renderer.handle_event({"event_type": "context_loading", "payload": {"workspace_root": workspace_root}})
165
+
166
+ mcp_server = MCPServer(workspace_root=workspace_root, session_id=session_id)
167
+ if _is_plain_conversation(messages):
168
+ tools: list[dict[str, Any]] = []
169
+ elif read_only:
170
+ tools = mcp_server.tool_schemas_openai(names=list(READ_ONLY_TOOLS))
171
+ else:
172
+ tools = mcp_server.tool_schemas_openai()
173
+
174
+ # Plain conversation must not carry the full repository prompt. Besides
175
+ # wasting context, small local models can close an OpenAI-compatible
176
+ # stream without producing visible text when presented with a large coding
177
+ # system prompt for a trivial greeting. Repository-aware turns still get
178
+ # the complete workspace prompt (git branch/dirty status, instruction file
179
+ # contents -- replaces the context tamgpt6 used to assemble server-side).
180
+ if _is_plain_conversation(messages):
181
+ system_prompt = (
182
+ "You are TamfisGPT Code. Respond naturally and concisely to the "
183
+ "user. Do not invent or call tools for ordinary conversation."
184
+ )
185
+ else:
186
+ from .workspace import build_system_prompt
187
+ system_prompt = build_system_prompt(session_id, Path(workspace_root))
188
+ working_messages = [{"role": "system", "content": system_prompt}, *messages]
189
+ session_approved_risks: set[str] = set()
190
+ renderer.handle_event({"event_type": "context_reused", "payload": {"workspace_root": workspace_root}})
191
+
192
+ for _round in range(max_rounds):
193
+ renderer.handle_event({"event_type": "routing_started", "payload": {"requested_provider": provider.value}})
194
+ client = manager.get_client(provider)
195
+ if not client:
196
+ renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": f"Provider {provider.value} is not available (no client / no valid credentials)."}})
197
+ return TaskOutcome(status="failed", error=f"Provider {provider.value} is not available (no client / no valid credentials).")
198
+
199
+ resolved_provider = provider if provider != ProviderType.AUTO else manager._select_best_provider()
200
+ config = manager.PROVIDERS[resolved_provider]
201
+ resolved_model = model or config.default_model
202
+ renderer.handle_event({
203
+ "event_type": "model_selected",
204
+ "payload": {"provider": resolved_provider.value, "model": resolved_model},
205
+ })
206
+ renderer.handle_event({
207
+ "event_type": "provider_request_started",
208
+ "payload": {"provider": resolved_provider.value, "model": resolved_model, "round": _round + 1},
209
+ })
210
+
211
+ try:
212
+ content, tool_calls = await _stream_one_completion(
213
+ client, model=resolved_model, messages=working_messages, tools=tools, renderer=renderer,
214
+ )
215
+ except Exception as exc:
216
+ renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": str(exc)}})
217
+ return TaskOutcome(status="failed", error=str(exc))
218
+
219
+ if not tool_calls:
220
+ # Some OpenAI-compatible servers occasionally terminate a streamed
221
+ # response with no content even though the same request succeeds
222
+ # non-streaming. Never silently report completion with a blank
223
+ # answer: retry once without streaming and render that canonical
224
+ # response. This also gives a clear error if the provider returned
225
+ # neither text nor tool calls.
226
+ if not content.strip():
227
+ fallback_kwargs: dict[str, Any] = {
228
+ "model": resolved_model,
229
+ "messages": working_messages,
230
+ "stream": False,
231
+ "temperature": 0.2,
232
+ "max_tokens": 4096,
233
+ }
234
+ if tools:
235
+ fallback_kwargs["tools"] = tools
236
+ fallback_kwargs["tool_choice"] = "auto"
237
+ try:
238
+ response = await client.chat.completions.create(**fallback_kwargs)
239
+ message = response.choices[0].message if response.choices else None
240
+ fallback_content = str(getattr(message, "content", None) or "")
241
+ if fallback_content:
242
+ content = fallback_content
243
+ renderer.handle_event({
244
+ "event_type": "assistant_delta",
245
+ "payload": {"content": fallback_content},
246
+ })
247
+ except Exception as exc:
248
+ renderer.handle_event({
249
+ "event_type": "ai_task_failed",
250
+ "payload": {"error": f"Provider returned an empty stream; fallback failed: {exc}"},
251
+ })
252
+ return TaskOutcome(
253
+ status="failed",
254
+ error=f"Provider returned an empty stream; fallback failed: {exc}",
255
+ )
256
+ if not content.strip():
257
+ error = "Provider completed without assistant text or tool calls."
258
+ renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": error}})
259
+ return TaskOutcome(status="failed", error=error)
260
+ renderer.handle_event({"event_type": "ai_task_completed", "payload": {"status": "completed"}})
261
+ return TaskOutcome(status="completed", summary=content)
262
+
263
+ working_messages.append({
264
+ "role": "assistant", "content": content or "",
265
+ "tool_calls": [
266
+ {"id": tc.call_id, "type": "function", "function": {"name": tc.name, "arguments": tc.arguments}}
267
+ for tc in tool_calls
268
+ ],
269
+ })
270
+
271
+ for tc in tool_calls:
272
+ try:
273
+ arguments = json.loads(tc.arguments or "{}")
274
+ except json.JSONDecodeError:
275
+ arguments = {}
276
+
277
+ risk = classify_tool_call_risk(tc.name, arguments, workspace_root=workspace_root)
278
+ renderer.handle_event({"event_type": "tool_call_requested", "payload": {"name": tc.name, "arguments": arguments}})
279
+
280
+ if read_only and tc.name not in READ_ONLY_TOOLS:
281
+ result = {"error": f"'{tc.name}' is not available in read-only mode.", "success": False}
282
+ working_messages.append({"role": "tool", "tool_call_id": tc.call_id, "content": json.dumps(result)})
283
+ renderer.handle_event({"event_type": "tool_output", "payload": {"tool": tc.name, "result": result}})
284
+ continue
285
+
286
+ if risk != "read_only" and risk not in session_approved_risks:
287
+ renderer.handle_event({
288
+ "event_type": "approval_required",
289
+ "payload": {
290
+ "command": f"{tc.name}({json.dumps(arguments, default=str)})", "risk_level": risk,
291
+ "working_directory": workspace_root, "reason": "The agent requested this command.",
292
+ },
293
+ })
294
+ decision = resolve_approval_decision(console, f"{tc.name}({json.dumps(arguments, default=str)})", risk, approval_policy, interactive)
295
+ if decision == "approve_session":
296
+ session_approved_risks.add(risk)
297
+ elif decision == "deny":
298
+ result = {"error": "Denied by approval policy -- try a different, less risky approach.", "success": False}
299
+ working_messages.append({"role": "tool", "tool_call_id": tc.call_id, "content": json.dumps(result)})
300
+ renderer.handle_event({"event_type": "tool_output", "payload": {"tool": tc.name, "result": result}})
301
+ continue
302
+
303
+ result = await mcp_server.call_tool(tc.name, arguments)
304
+ renderer.handle_event({"event_type": "tool_output", "payload": {"tool": tc.name, "result": result}})
305
+ working_messages.append({"role": "tool", "tool_call_id": tc.call_id, "content": json.dumps(result, default=str)})
306
+
307
+ if tc.name in {"write_file", "edit_file"} and result.get("success"):
308
+ state = local_state.get_session_state(session_id)
309
+ if state.modified_files:
310
+ mutation = state.modified_files[-1]
311
+ renderer.handle_event({"event_type": "file_mutation", "payload": {
312
+ "path": mutation["path"], "lines_added": mutation["lines_added"],
313
+ "lines_removed": mutation["lines_removed"], "mutation_id": mutation["mutation_id"],
314
+ }})
315
+
316
+ message = f"(Stopped after {max_rounds} tool-call rounds without a final answer -- this usually means the task needs to be narrowed.)"
317
+ renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": message}})
318
+ return TaskOutcome(status="failed", error=message)
319
+
320
+
321
+ async def run_local_shell_command(
322
+ console: Console, *, workspace_root: str, session_id: int, command: str,
323
+ approval_policy: str, interactive: bool,
324
+ ) -> TaskOutcome:
325
+ """Standalone equivalent of runner.py's run_shell_command -- executes an
326
+ explicit `$ <command>` / `/run` / `/shell` REPL command locally via
327
+ MCPServer's execute_command tool, gated through the same local risk
328
+ classifier and approval flow as any other tool call, instead of
329
+ submitting it to a Remote command queue."""
330
+ from .safety import classify_command_risk
331
+
332
+ action = local_state.start_action(
333
+ session_id, action_type="shell_command", purpose="Run an explicit local shell command",
334
+ risk="policy_classified", detail=command,
335
+ )
336
+ console.print(f"[bold]$[/bold] {command}")
337
+
338
+ risk = classify_command_risk(command)
339
+ if risk != "read_only":
340
+ decision = resolve_approval_decision(console, command, risk, approval_policy, interactive)
341
+ if decision == "deny":
342
+ local_state.finish_action(session_id, action.id, status="failed", summary="denied")
343
+ console.print("[dim]Denied.[/dim]")
344
+ return TaskOutcome(status="denied", error="Denied by approval policy")
345
+
346
+ mcp_server = MCPServer(workspace_root=workspace_root, session_id=session_id)
347
+ result = await mcp_server.call_tool("execute_command", {"command": command})
348
+ payload = result.get("result") if isinstance(result.get("result"), dict) else result
349
+ stdout = str(payload.get("stdout") or "")
350
+ stderr = str(payload.get("stderr") or "")
351
+ exit_code = payload.get("return_code")
352
+ ok = bool(result.get("success")) and exit_code == 0
353
+ body = stdout.strip()
354
+ if stderr.strip():
355
+ body = (body + "\n" + stderr.strip()).strip()
356
+ if not body:
357
+ body = "Command completed successfully with no output" if ok else f"Command failed with exit code {exit_code}"
358
+ from rich.panel import Panel
359
+ console.print(Panel(body, title=f"exit {exit_code}", border_style="green" if ok else "red"))
360
+
361
+ outcome = TaskOutcome(status="completed", summary=stdout) if ok else TaskOutcome(status="failed", error=stderr or f"exit code {exit_code}")
362
+ local_state.finish_action(session_id, action.id, status=outcome.status, summary=f"exit={exit_code}")
363
+ local_state.checkpoint(session_id, reason=f"command_{outcome.status}", summary=f"exit={exit_code}")
364
+ return outcome
tamfis_code/safety.py ADDED
@@ -0,0 +1,164 @@
1
+ """Local risk classification and mutation-ledger recording for the
2
+ standalone agent loop.
3
+
4
+ Before this module existed, risk classification, approval gating, and the
5
+ file-mutation ledger all lived server-side in the TamfisGPT Remote Workspace
6
+ backend (tamgpt6) -- this CLI only ever *rendered* a server-supplied
7
+ `risk_level` and *responded to* a server-emitted `approval_required` event
8
+ (see runner.py's `resolve_approval_decision`, which is a pure policy-vs-risk
9
+ decision table and was never a classifier). Now that tamfis-code runs its
10
+ own agent loop with no remote backend behind it, something has to take over
11
+ both of those jobs locally -- that's what this module is for.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import re
18
+ import uuid
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ from . import state as local_state
24
+
25
+ RISK_READ_ONLY = "read_only"
26
+ RISK_MEDIUM = "medium"
27
+ RISK_DANGEROUS = "dangerous"
28
+
29
+ READ_ONLY_TOOLS = {"read_file", "list_directory", "search_code", "get_git_info"}
30
+ MUTATING_FILE_TOOLS = {"write_file", "edit_file"}
31
+
32
+ MAX_MUTATION_HISTORY = 200
33
+
34
+ # Patterns that make a shell command dangerous regardless of approval_policy
35
+ # leniency -- destructive, irreversible, or credential-exposing. This is a
36
+ # heuristic allowlist-of-concerns, not a sandbox: it reduces the chance of an
37
+ # unreviewed catastrophic command slipping through "safe"/"accept-edits"
38
+ # policies, it does not replace real sandboxing (out of scope here, see the
39
+ # rebuild plan's explicit-non-goals section).
40
+ _DANGEROUS_COMMAND_PATTERNS = [
41
+ re.compile(r"\brm\s+(-\w*r\w*f\w*|-\w*f\w*r\w*)\b"), # rm -rf / rm -fr and letter-order variants
42
+ re.compile(r"\bgit\s+push\b[^\n]*--force\b"),
43
+ re.compile(r"\bgit\s+reset\s+--hard\b"),
44
+ re.compile(r"\bgit\s+clean\s+-\w*f\w*d?\b"),
45
+ re.compile(r"\bsudo\b"),
46
+ re.compile(r"\bchmod\s+-R\s+777\b"),
47
+ re.compile(r"\bdd\s+if="),
48
+ re.compile(r"\bmkfs(\.\w+)?\b"),
49
+ re.compile(r":\(\)\s*\{[^}]*\}\s*;\s*:"), # classic fork bomb
50
+ re.compile(r"\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(ba)?sh\b"),
51
+ re.compile(r">\s*/dev/sd[a-z]\b"),
52
+ re.compile(r"\.ssh/(id_|authorized_keys)|\.aws/credentials|(^|\s)\.env\b"),
53
+ re.compile(r"\bshutdown\b|\breboot\b|\bhalt\b"),
54
+ ]
55
+
56
+
57
+ def classify_command_risk(command: str) -> str:
58
+ """Heuristic risk tier for an `execute_command` tool call."""
59
+ text = command or ""
60
+ for pattern in _DANGEROUS_COMMAND_PATTERNS:
61
+ if pattern.search(text):
62
+ return RISK_DANGEROUS
63
+ return RISK_MEDIUM
64
+
65
+
66
+ def classify_path_risk(path: str, workspace_root: str) -> str:
67
+ """`dangerous` if the target resolves outside workspace_root, else `medium`.
68
+
69
+ Mirrors the workspace-boundary check `mcp.py`'s `_write_file` never had
70
+ (see the rebuild plan's Phase 2 goal) -- a path that escapes the
71
+ workspace is exactly the case a local agent loop has no server-side
72
+ backstop for anymore.
73
+ """
74
+ try:
75
+ candidate = Path(path)
76
+ if not candidate.is_absolute():
77
+ candidate = Path(workspace_root) / candidate
78
+ resolved = candidate.resolve()
79
+ root = Path(workspace_root).resolve()
80
+ except (OSError, ValueError, RuntimeError):
81
+ return RISK_DANGEROUS
82
+ if resolved != root and root not in resolved.parents:
83
+ return RISK_DANGEROUS
84
+ return RISK_MEDIUM
85
+
86
+
87
+ def classify_tool_call_risk(name: str, arguments: dict[str, Any], *, workspace_root: str) -> str:
88
+ """Single entry point the standalone loop consults before executing any
89
+ tool call -- feeds `runner.py`'s existing `resolve_approval_decision`
90
+ exactly the way a server-supplied `risk_level` used to."""
91
+ if name in READ_ONLY_TOOLS:
92
+ return RISK_READ_ONLY
93
+ if name in MUTATING_FILE_TOOLS:
94
+ path = str(arguments.get("path") or "")
95
+ return classify_path_risk(path, workspace_root) if path else RISK_DANGEROUS
96
+ if name == "execute_command":
97
+ return classify_command_risk(str(arguments.get("command") or ""))
98
+ if name == "browser":
99
+ return RISK_MEDIUM
100
+ return RISK_DANGEROUS # unknown tool name -- fail safe, never default to permissive
101
+
102
+
103
+ def _unified_diff(path: str, original_content: Optional[str], new_content: Optional[str]) -> str:
104
+ original_lines = (original_content or "").splitlines(keepends=True)
105
+ new_lines = (new_content or "").splitlines(keepends=True)
106
+ return "".join(difflib.unified_diff(original_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{path}"))
107
+
108
+
109
+ def record_mutation(
110
+ session_id: int, *, path: str, operation: str,
111
+ original_content: Optional[str], new_content: Optional[str],
112
+ ) -> dict[str, Any]:
113
+ """Append a local mutation-ledger entry to SessionState.modified_files.
114
+
115
+ Replaces the remote backend's ledger, which this client used to only
116
+ ever *observe* via `file_mutation` SSE events (see render.py/runner.py's
117
+ prior handling) -- now the tool handler that performs the write is the
118
+ one that must record it, since there's no server doing that anymore.
119
+ `original_content=None` means this mutation created the file (revert ==
120
+ delete); otherwise it's the exact pre-mutation bytes needed to restore.
121
+ """
122
+ diff_text = _unified_diff(path, original_content, new_content)
123
+ added = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++"))
124
+ removed = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---"))
125
+
126
+ state = local_state.get_session_state(session_id)
127
+ entry = {
128
+ "mutation_id": f"mut_{uuid.uuid4().hex[:12]}",
129
+ "path": path,
130
+ "operation": operation,
131
+ "lines_added": added,
132
+ "lines_removed": removed,
133
+ "unified_diff": diff_text,
134
+ "original_content": original_content,
135
+ "revert_status": "none",
136
+ "created_at": datetime.now(timezone.utc).isoformat(),
137
+ }
138
+ state.modified_files = (state.modified_files + [entry])[-MAX_MUTATION_HISTORY:]
139
+ local_state.save_session_state(session_id, modified_files=state.modified_files)
140
+ return entry
141
+
142
+
143
+ def revert_mutation(session_id: int, mutation_id: str) -> dict[str, Any]:
144
+ """Restore a file to its content before the given mutation, using the
145
+ ledger's own stored pre-mutation snapshot -- no server round-trip."""
146
+ state = local_state.get_session_state(session_id)
147
+ entry = next((m for m in state.modified_files if m.get("mutation_id") == mutation_id), None)
148
+ if entry is None:
149
+ raise ValueError(f"No recorded mutation with id {mutation_id!r} in this session")
150
+ if entry.get("revert_status") == "reverted":
151
+ return entry
152
+
153
+ path = Path(entry["path"])
154
+ original_content = entry.get("original_content")
155
+ if original_content is None:
156
+ if path.exists():
157
+ path.unlink()
158
+ else:
159
+ path.parent.mkdir(parents=True, exist_ok=True)
160
+ path.write_text(original_content, encoding="utf-8")
161
+
162
+ entry["revert_status"] = "reverted"
163
+ local_state.save_session_state(session_id, modified_files=state.modified_files)
164
+ return entry