local-coding-agent 1.1.0__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 (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,85 @@
1
+ """Speculative Multi-Drafting & Model Racing Engine (R19).
2
+
3
+ Coordinates concurrent speculative drafts across model clients and worker pools,
4
+ accepting the first passing candidate and cancelling competing workers to boost
5
+ first-attempt success rates with low latency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import queue
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from threading import Event
13
+ from typing import Any, Callable, Sequence
14
+
15
+
16
+ class SpeculativeRacer:
17
+ """Coordinates racing between multiple speculative task drafts."""
18
+
19
+ def run(
20
+ self,
21
+ runners: Sequence[Callable[[Event], dict[str, Any]]],
22
+ *,
23
+ cancel_event: Event | None = None,
24
+ ) -> dict[str, Any]:
25
+ if not runners:
26
+ return {"status": "failed", "error": "no runners provided"}
27
+
28
+ if cancel_event is not None and cancel_event.is_set():
29
+ return {"status": "failed", "error": "cancelled"}
30
+
31
+ result_queue: queue.Queue[tuple[int, dict[str, Any]]] = queue.Queue()
32
+ local_cancels: list[Event] = [Event() for _ in runners]
33
+
34
+ def _worker_wrapper(idx: int, runner_fn: Callable[[Event], dict[str, Any]]) -> None:
35
+ runner_cancel = local_cancels[idx]
36
+ try:
37
+ if cancel_event is not None and cancel_event.is_set():
38
+ runner_cancel.set()
39
+ res = runner_fn(runner_cancel)
40
+ result_queue.put((idx, res))
41
+ except Exception as exc:
42
+ result_queue.put((idx, {"status": "failed", "error": str(exc)}))
43
+
44
+ executor = ThreadPoolExecutor(max_workers=len(runners))
45
+ try:
46
+ for i, runner in enumerate(runners):
47
+ executor.submit(_worker_wrapper, i, runner)
48
+
49
+ completed_count = 0
50
+ fallback_result: dict[str, Any] | None = None
51
+
52
+ while completed_count < len(runners):
53
+ if cancel_event is not None and cancel_event.is_set():
54
+ for ev in local_cancels:
55
+ ev.set()
56
+ return {"status": "failed", "error": "cancelled"}
57
+
58
+ try:
59
+ idx, result = result_queue.get(timeout=0.05)
60
+ completed_count += 1
61
+ except queue.Empty:
62
+ continue
63
+
64
+ if result.get("status") == "accepted":
65
+ # Winner found! Cancel all remaining racers
66
+ for j, ev in enumerate(local_cancels):
67
+ if j != idx:
68
+ ev.set()
69
+ audit = result.get("audit")
70
+ if isinstance(audit, list):
71
+ audit.append({
72
+ "event": "speculative_race_winner",
73
+ "draft_index": idx,
74
+ "total_drafts": len(runners),
75
+ })
76
+ return result
77
+
78
+ if fallback_result is None:
79
+ fallback_result = result
80
+
81
+ return fallback_result or {"status": "failed", "error": "no result"}
82
+ finally:
83
+ for ev in local_cancels:
84
+ ev.set()
85
+ executor.shutdown(wait=False, cancel_futures=True)
@@ -0,0 +1,258 @@
1
+ """Tool Output Spill Store for bounding model context size.
2
+
3
+ Adapted from DeepSeek Harness @deepseek-ai/dsh-spill and @deepseek-ai/dsh-spill-policy.
4
+ Manages session-scoped artifact persistence for oversized tool outputs with
5
+ path-traversal protection and secure permissions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ import re
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+
18
+ DEFAULT_MAX_BYTES: int = 30 * 1024 # 30KB
19
+ DEFAULT_MAX_LINES: int = 1000
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SpillRef:
24
+ """Descriptor for a persisted tool output spill artifact."""
25
+
26
+ locator: str
27
+ bytes: int
28
+ lines: int
29
+ preview_head: str
30
+ preview_tail: str
31
+ retrieval_hint: str
32
+
33
+
34
+ class SpillStore:
35
+ """Session-scoped storage backend for oversized tool outputs."""
36
+
37
+ def __init__(self, root_dir: Path | str | None = None) -> None:
38
+ if root_dir is None:
39
+ self.root_dir = (Path.cwd() / ".local_agent" / "spill").resolve()
40
+ else:
41
+ self.root_dir = Path(root_dir).resolve()
42
+
43
+ def _sanitize_session_id(self, session_id: str) -> str:
44
+ """Derive a safe, injective directory segment from an untrusted session ID."""
45
+ if not session_id or session_id in (".", ".."):
46
+ return "session_default"
47
+ # Remove path separators and traversal tokens
48
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id).strip(".")
49
+ if not cleaned:
50
+ cleaned = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
51
+ return f"session_{cleaned}"
52
+
53
+ def _sanitize_file_name(self, name: str) -> str:
54
+ """Derive a safe filename without path separators."""
55
+ base_name = Path(name).name
56
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", base_name).strip(".")
57
+ return cleaned or "output.txt"
58
+
59
+ def _get_session_dir(self, session_id: str) -> Path:
60
+ """Get the absolute session directory and assert it stays within root_dir."""
61
+ safe_seg = self._sanitize_session_id(session_id)
62
+ session_path = (self.root_dir / safe_seg).resolve()
63
+ try:
64
+ session_path.relative_to(self.root_dir)
65
+ except ValueError as err:
66
+ raise ValueError(f"Path traversal detected for session_id '{session_id}'") from err
67
+ return session_path
68
+
69
+ def save_text(
70
+ self,
71
+ session_id: str,
72
+ content: str,
73
+ source_tool: str = "tool",
74
+ suggested_name: str = "output.txt",
75
+ ) -> SpillRef:
76
+ """Persist text content to a session-scoped spill file and return a SpillRef."""
77
+ session_dir = self._get_session_dir(session_id)
78
+ session_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
79
+ if os.name != "nt":
80
+ try:
81
+ os.chmod(session_dir, 0o700)
82
+ except OSError:
83
+ pass
84
+
85
+ safe_name = self._sanitize_file_name(suggested_name)
86
+ file_id = uuid.uuid4().hex[:12]
87
+ target_file = (session_dir / f"{file_id}_{safe_name}").resolve()
88
+
89
+ try:
90
+ target_file.relative_to(self.root_dir)
91
+ except ValueError as err:
92
+ raise ValueError(f"Path traversal detected for target file '{target_file}'") from err
93
+
94
+ encoded = content.encode("utf-8")
95
+ byte_count = len(encoded)
96
+
97
+ # Split preserving newlines for accurate reconstruction
98
+ raw_lines = content.splitlines(keepends=True)
99
+ line_count = len(raw_lines)
100
+
101
+ # Write securely with exclusive creation / owner-only permissions (0600 on POSIX)
102
+ flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
103
+ if hasattr(os, "O_EXCL"):
104
+ flags |= os.O_EXCL
105
+ fd = os.open(str(target_file), flags, 0o600)
106
+ with open(fd, "wb") as f:
107
+ f.write(encoded)
108
+
109
+ if os.name != "nt":
110
+ try:
111
+ os.chmod(target_file, 0o600)
112
+ except OSError:
113
+ pass
114
+
115
+ # Build head & tail previews (up to 20 lines each)
116
+ preview_limit = 20
117
+ preview_head = "".join(raw_lines[:preview_limit])
118
+ preview_tail = "".join(raw_lines[-preview_limit:])
119
+
120
+ locator = target_file.as_posix()
121
+ retrieval_hint = f"read_spill(locator='{locator}', offset_line=0, limit_lines=1000)"
122
+
123
+ return SpillRef(
124
+ locator=locator,
125
+ bytes=byte_count,
126
+ lines=line_count,
127
+ preview_head=preview_head,
128
+ preview_tail=preview_tail,
129
+ retrieval_hint=retrieval_hint,
130
+ )
131
+
132
+ def read_spill(self, locator: str, offset_line: int = 0, limit_lines: int = 1000) -> str:
133
+ """Read a slice of lines from a persisted spill artifact."""
134
+ target_path = Path(locator).resolve()
135
+ try:
136
+ target_path.relative_to(self.root_dir)
137
+ except ValueError as err:
138
+ raise ValueError(
139
+ f"Path traversal detected: locator '{locator}' is outside spill root '{self.root_dir}'"
140
+ ) from err
141
+
142
+ if not target_path.is_file():
143
+ raise FileNotFoundError(f"Spill artifact not found: {locator}")
144
+
145
+ if offset_line < 0:
146
+ offset_line = 0
147
+ if limit_lines < 0:
148
+ limit_lines = 0
149
+
150
+ with open(target_path, "r", encoding="utf-8", errors="replace") as f:
151
+ lines = f.readlines()
152
+
153
+ selected = lines[offset_line : offset_line + limit_lines]
154
+ return "".join(selected)
155
+
156
+ def maybe_spill(
157
+ self,
158
+ session_id: str,
159
+ content: str,
160
+ source_tool: str = "tool",
161
+ max_bytes: int = DEFAULT_MAX_BYTES,
162
+ max_lines: int = DEFAULT_MAX_LINES,
163
+ ) -> tuple[bool, str, SpillRef | None]:
164
+ """Check content against byte and line thresholds, spilling if exceeded.
165
+
166
+ Returns:
167
+ (spilled: bool, resulting_message: str, ref: SpillRef | None)
168
+ """
169
+ encoded_len = len(content.encode("utf-8"))
170
+ raw_lines = content.splitlines(keepends=True)
171
+ line_count = len(raw_lines)
172
+
173
+ if encoded_len <= max_bytes and line_count <= max_lines:
174
+ return False, content, None
175
+
176
+ ref = self.save_text(
177
+ session_id=session_id,
178
+ content=content,
179
+ source_tool=source_tool,
180
+ suggested_name=f"{source_tool}_output.txt",
181
+ )
182
+
183
+ omitted_lines = max(0, line_count - 40)
184
+ notice = (
185
+ f"[OUTPUT TRUNCATED & SPILLED TO STORE]\n"
186
+ f"Tool '{source_tool}' produced {ref.bytes} bytes ({ref.lines} lines), "
187
+ f"exceeding limits ({max_bytes} bytes / {max_lines} lines).\n"
188
+ f"Full output was saved to locator: {ref.locator}\n\n"
189
+ f"--- BEGIN PREVIEW (HEAD) ---\n"
190
+ f"{ref.preview_head.rstrip()}\n"
191
+ f"--- END PREVIEW (HEAD) ---\n"
192
+ f"... [{omitted_lines} lines omitted] ...\n"
193
+ f"--- BEGIN PREVIEW (TAIL) ---\n"
194
+ f"{ref.preview_tail.rstrip()}\n"
195
+ f"--- END PREVIEW (TAIL) ---\n\n"
196
+ f"Retrieval guidance: {ref.retrieval_hint}"
197
+ )
198
+
199
+ return True, notice, ref
200
+
201
+
202
+ # Global default instance & convenience helpers
203
+ _DEFAULT_SPILL_STORE: SpillStore | None = None
204
+
205
+
206
+ def get_default_spill_store() -> SpillStore:
207
+ """Retrieve or lazily initialize the default SpillStore instance."""
208
+ global _DEFAULT_SPILL_STORE
209
+ if _DEFAULT_SPILL_STORE is None:
210
+ _DEFAULT_SPILL_STORE = SpillStore()
211
+ return _DEFAULT_SPILL_STORE
212
+
213
+
214
+ def save_text(
215
+ session_id: str,
216
+ content: str,
217
+ source_tool: str = "tool",
218
+ suggested_name: str = "output.txt",
219
+ ) -> SpillRef:
220
+ """Save text content to the default spill store."""
221
+ return get_default_spill_store().save_text(
222
+ session_id=session_id,
223
+ content=content,
224
+ source_tool=source_tool,
225
+ suggested_name=suggested_name,
226
+ )
227
+
228
+
229
+ def read_spill(locator: str, offset_line: int = 0, limit_lines: int = 1000) -> str:
230
+ """Read a slice of lines from a spill locator."""
231
+ # Check if locator is under an explicit custom root or default root
232
+ store = get_default_spill_store()
233
+ target_path = Path(locator).resolve()
234
+ try:
235
+ target_path.relative_to(store.root_dir)
236
+ return store.read_spill(locator, offset_line, limit_lines)
237
+ except ValueError:
238
+ # Fall back to resolving within locator's parent spill directory
239
+ parent_dir = target_path.parent
240
+ custom_store = SpillStore(root_dir=parent_dir.parent)
241
+ return custom_store.read_spill(locator, offset_line, limit_lines)
242
+
243
+
244
+ def maybe_spill(
245
+ session_id: str,
246
+ content: str,
247
+ source_tool: str = "tool",
248
+ max_bytes: int = DEFAULT_MAX_BYTES,
249
+ max_lines: int = DEFAULT_MAX_LINES,
250
+ ) -> tuple[bool, str, SpillRef | None]:
251
+ """Check content against thresholds using the default spill store."""
252
+ return get_default_spill_store().maybe_spill(
253
+ session_id=session_id,
254
+ content=content,
255
+ source_tool=source_tool,
256
+ max_bytes=max_bytes,
257
+ max_lines=max_lines,
258
+ )
@@ -0,0 +1,255 @@
1
+ """Minimal, stdlib-only statistics collector for delegation runs.
2
+
3
+ This is a lightweight observability seam: it counts outcomes and accumulates
4
+ latencies without any third-party dependency, and can optionally append one
5
+ JSONL record per terminal result. It is harness-agnostic — anything that
6
+ produces a controller/service result can feed it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Mapping
16
+
17
+
18
+ class DelegationStats:
19
+ """Thread-safe counters and latency aggregates over delegation results."""
20
+
21
+ def __init__(self) -> None:
22
+ self._lock = threading.Lock()
23
+ self._started_ns = time.monotonic_ns()
24
+ self.total = 0
25
+ self.by_status: dict[str, int] = {}
26
+ self.by_model: dict[str, int] = {}
27
+ self.by_error_kind: dict[str, int] = {}
28
+ self.latency_count = 0
29
+ self.latency_total_ns = 0
30
+ self.latency_min_ns: int | None = None
31
+ self.latency_max_ns: int | None = None
32
+ self.tool_calls = 0
33
+ self.model_calls = 0
34
+
35
+ def record(
36
+ self,
37
+ result: Mapping[str, Any],
38
+ *,
39
+ model: str | None = None,
40
+ latency_ns: int | None = None,
41
+ ) -> None:
42
+ with self._lock:
43
+ self.total += 1
44
+ status = str(result.get("status", "unknown"))
45
+ self.by_status[status] = self.by_status.get(status, 0) + 1
46
+ if model:
47
+ self.by_model[model] = self.by_model.get(model, 0) + 1
48
+ error = result.get("error")
49
+ if isinstance(error, Mapping) and error.get("kind"):
50
+ kind = str(error["kind"])
51
+ self.by_error_kind[kind] = self.by_error_kind.get(kind, 0) + 1
52
+ audit = result.get("audit")
53
+ if isinstance(audit, (list, tuple)):
54
+ for event in audit:
55
+ if isinstance(event, Mapping):
56
+ name = event.get("event")
57
+ if name == "tool_call":
58
+ self.tool_calls += 1
59
+ elif name == "model_request":
60
+ self.model_calls += 1
61
+ if latency_ns is not None and latency_ns >= 0:
62
+ self.latency_count += 1
63
+ self.latency_total_ns += latency_ns
64
+ self.latency_min_ns = (
65
+ latency_ns
66
+ if self.latency_min_ns is None
67
+ else min(self.latency_min_ns, latency_ns)
68
+ )
69
+ self.latency_max_ns = (
70
+ latency_ns
71
+ if self.latency_max_ns is None
72
+ else max(self.latency_max_ns, latency_ns)
73
+ )
74
+
75
+ def snapshot(self) -> dict[str, Any]:
76
+ with self._lock:
77
+ elapsed_s = (time.monotonic_ns() - self._started_ns) / 1_000_000_000
78
+ latency_avg_ns = (
79
+ self.latency_total_ns / self.latency_count if self.latency_count else None
80
+ )
81
+ return {
82
+ "elapsed_seconds": round(elapsed_s, 3),
83
+ "total": self.total,
84
+ "by_status": dict(self.by_status),
85
+ "by_model": dict(self.by_model),
86
+ "by_error_kind": dict(self.by_error_kind),
87
+ "model_calls": self.model_calls,
88
+ "tool_calls": self.tool_calls,
89
+ "latency": {
90
+ "count": self.latency_count,
91
+ "avg_ms": (
92
+ round(latency_avg_ns / 1_000_000, 3)
93
+ if latency_avg_ns is not None
94
+ else None
95
+ ),
96
+ "min_ms": (
97
+ round(self.latency_min_ns / 1_000_000, 3)
98
+ if self.latency_min_ns is not None
99
+ else None
100
+ ),
101
+ "max_ms": (
102
+ round(self.latency_max_ns / 1_000_000, 3)
103
+ if self.latency_max_ns is not None
104
+ else None
105
+ ),
106
+ },
107
+ }
108
+
109
+
110
+ class JsonlStatsSink:
111
+ """Append one JSON line per record to a UTF-8 file for later inspection."""
112
+
113
+ _write_lock = threading.Lock() # class-level: one lock across all sink instances/process threads.
114
+
115
+ def __init__(self, path: str | Path) -> None:
116
+ self._path = Path(path)
117
+
118
+ def write(self, record: Mapping[str, Any]) -> None:
119
+ with self._write_lock:
120
+ self._path.parent.mkdir(parents=True, exist_ok=True)
121
+ with self._path.open("a", encoding="utf-8") as handle:
122
+ handle.write(json.dumps(dict(record), ensure_ascii=False) + "\n")
123
+
124
+
125
+ class TimedDelegationStats:
126
+ """Convenience wrapper that times a delegate call and records the result."""
127
+
128
+ def __init__(
129
+ self,
130
+ stats: DelegationStats,
131
+ *,
132
+ sink: JsonlStatsSink | None = None,
133
+ ) -> None:
134
+ self.stats = stats
135
+ self.sink = sink
136
+
137
+ def __call__(
138
+ self,
139
+ delegate,
140
+ caller_id: str,
141
+ request,
142
+ *,
143
+ model: str | None = None,
144
+ ) -> Mapping[str, Any]:
145
+ started = time.monotonic_ns()
146
+ result = delegate(caller_id, request)
147
+ latency_ns = time.monotonic_ns() - started
148
+ self.stats.record(result, model=model, latency_ns=latency_ns)
149
+ if self.sink is not None:
150
+ record = {
151
+ "ts": time.time(),
152
+ "caller_id": caller_id,
153
+ "request_id": getattr(request, "request_id", None),
154
+ "model": model,
155
+ "status": result.get("status"),
156
+ "error_kind": (
157
+ result.get("error", {}).get("kind")
158
+ if isinstance(result.get("error"), Mapping)
159
+ else None
160
+ ),
161
+ "latency_ms": round(latency_ns / 1_000_000, 3),
162
+ }
163
+ self.sink.write(record)
164
+ return result
165
+
166
+
167
+ def default_stats_path() -> Path:
168
+ """Shared cross-process stats journal location (relative to the cwd)."""
169
+ return Path(".local-run") / "stats.jsonl"
170
+
171
+
172
+ def append_stats(
173
+ path: str | Path,
174
+ result: Mapping[str, Any],
175
+ *,
176
+ model: str | None = None,
177
+ latency_ns: int | None = None,
178
+ ) -> None:
179
+ """Append one slim, replayable delegation record. Never raises."""
180
+ error = result.get("error")
181
+ record = {
182
+ "ts": time.time(),
183
+ "model": model,
184
+ "status": result.get("status"),
185
+ "error": {"kind": error.get("kind")} if isinstance(error, Mapping) and error.get("kind") else None,
186
+ "latency_ms": round(latency_ns / 1_000_000, 3) if latency_ns is not None else None,
187
+ }
188
+ try:
189
+ JsonlStatsSink(path).write(record)
190
+ except OSError:
191
+ pass # ponytail: telemetry must never break a delegation.
192
+
193
+
194
+ def load_stats(path: str | Path, *, max_records: int = 2000) -> DelegationStats:
195
+ """Rebuild aggregate stats by replaying the JSONL journal written by append_stats."""
196
+ stats = DelegationStats()
197
+ try:
198
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
199
+ except (OSError, UnicodeError):
200
+ return stats
201
+ for line in lines[-max_records:]:
202
+ try:
203
+ rec = json.loads(line)
204
+ except json.JSONDecodeError:
205
+ continue
206
+ if not isinstance(rec, dict):
207
+ continue
208
+ latency_ms = rec.get("latency_ms")
209
+ stats.record(
210
+ rec,
211
+ model=rec.get("model"),
212
+ latency_ns=int(latency_ms * 1_000_000) if isinstance(latency_ms, (int, float)) else None,
213
+ )
214
+ return stats
215
+
216
+
217
+ def merge_stats_snapshots(base: Mapping[str, Any], overlay: Mapping[str, Any]) -> dict[str, Any]:
218
+ """Merge two DelegationStats.snapshot() dicts (counters summed, latency combined)."""
219
+ merged = dict(base)
220
+
221
+ def _sum_latency(a: Mapping[str, Any], b: Mapping[str, Any]) -> dict[str, Any]:
222
+ count = (a.get("count") or 0) + (b.get("count") or 0)
223
+ total_ns = 0.0
224
+ minimums, maximums = [], []
225
+ for part in (a, b):
226
+ if part.get("count"):
227
+ total_ns += part["avg_ms"] * part["count"] * 1_000_000
228
+ if part.get("min_ms") is not None:
229
+ minimums.append(part["min_ms"])
230
+ if part.get("max_ms") is not None:
231
+ maximums.append(part["max_ms"])
232
+ if not count:
233
+ return {"count": 0, "avg_ms": None, "min_ms": None, "max_ms": None}
234
+ avg_ns = total_ns / count
235
+ return {
236
+ "count": count,
237
+ "avg_ms": round(avg_ns / 1_000_000, 3),
238
+ "min_ms": min(minimums) if minimums else None,
239
+ "max_ms": max(maximums) if maximums else None,
240
+ }
241
+
242
+ for key in ("total", "model_calls", "tool_calls"):
243
+ if key in base or key in overlay:
244
+ merged[key] = base.get(key, 0) + overlay.get(key, 0)
245
+ # elapsed_seconds is intentionally dropped: journal-replay uptime and
246
+ # server uptime measure different clocks; summing or max-ing misleads.
247
+ merged.pop("elapsed_seconds", None)
248
+ for key in ("by_status", "by_model", "by_error_kind"):
249
+ counts = dict(base.get(key) or {})
250
+ for name, value in (overlay.get(key) or {}).items():
251
+ counts[name] = counts.get(name, 0) + value
252
+ if key in base or key in overlay:
253
+ merged[key] = counts
254
+ merged["latency"] = _sum_latency(base.get("latency") or {}, overlay.get("latency") or {})
255
+ return merged