superlocalmemory 3.4.19 → 3.4.21

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 (170) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  124. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  125. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  126. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  127. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  128. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  129. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  130. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  131. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  132. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  133. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  134. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  135. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  136. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  137. package/src/superlocalmemory/storage/models.py +4 -0
  138. package/src/superlocalmemory/ui/css/brain.css +409 -0
  139. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  140. package/src/superlocalmemory/ui/index.html +459 -1345
  141. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  142. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  143. package/src/superlocalmemory/ui/js/init.js +48 -39
  144. package/src/superlocalmemory/ui/js/memories.js +88 -2
  145. package/src/superlocalmemory/ui/js/modal.js +71 -1
  146. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  147. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  148. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  149. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  153. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  154. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  155. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  157. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  159. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  160. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  161. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  162. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  163. package/src/superlocalmemory/ui/js/learning.js +0 -435
  164. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  165. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  166. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  167. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  168. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  169. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  170. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -65,9 +65,20 @@ class WorkerPool:
65
65
  # Public API
66
66
  # ------------------------------------------------------------------
67
67
 
68
- def recall(self, query: str, limit: int = 10) -> dict:
69
- """Run recall in worker subprocess. Returns result dict."""
70
- return self._send({"cmd": "recall", "query": query, "limit": limit})
68
+ def recall(
69
+ self, query: str, limit: int = 10, session_id: str = "",
70
+ ) -> dict:
71
+ """Run recall in worker subprocess. Returns result dict.
72
+
73
+ S9-DASH-02: ``session_id`` threads through to ``engine.recall``
74
+ so the outcome-queue gets a pending_outcomes row for this
75
+ recall. Without it, hook-based signals have no outcome to
76
+ attach to.
77
+ """
78
+ return self._send({
79
+ "cmd": "recall", "query": query, "limit": limit,
80
+ "session_id": session_id or "",
81
+ })
71
82
 
72
83
  def store(self, content: str, metadata: dict | None = None) -> dict:
73
84
  """Run store in worker subprocess. Returns result dict."""
@@ -44,7 +44,7 @@ import json
44
44
  import logging
45
45
  from collections import defaultdict
46
46
  from dataclasses import dataclass
47
- from datetime import datetime
47
+ from datetime import datetime, timezone
48
48
  from typing import TYPE_CHECKING, Any, Protocol
49
49
 
50
50
  from superlocalmemory.storage.models import _new_id
@@ -176,7 +176,7 @@ def _parse_date(raw: str | None) -> datetime | None:
176
176
  def _temporal_midpoint(dates: list[datetime]) -> str:
177
177
  """Compute ISO-8601 midpoint of a list of datetimes."""
178
178
  if not dates:
179
- return datetime.now().isoformat()
179
+ return datetime.now(timezone.utc).isoformat()
180
180
  ts = [d.timestamp() for d in dates]
181
181
  mid = sum(ts) / len(ts)
182
182
  return datetime.fromtimestamp(mid).isoformat()
@@ -0,0 +1,321 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-11 §Budget
4
+
5
+ """Evolution budget enforcement.
6
+
7
+ Hard caps per MASTER-PLAN §4.4 + LLD-11:
8
+ - Wall-time per cycle: 30 minutes (1800 seconds)
9
+ - LLM calls per cycle: 10
10
+ - Cycles per day per profile: 3
11
+ - Single-flight via per-profile lock file resolved with
12
+ ``safe_resolve_identifier`` (LLD-00 §4)
13
+
14
+ All four constraints are non-negotiable — crossing any of them raises
15
+ ``BudgetExhausted`` so the caller can abort the cycle safely without
16
+ poisoning ``action_outcomes`` or the recall pipeline.
17
+
18
+ Author: Varun Pratap Bhardwaj / Qualixar
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import fcntl
24
+ import logging
25
+ import os
26
+ import sqlite3
27
+ import time
28
+ import uuid
29
+ from contextlib import contextmanager
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Iterator
33
+
34
+ from superlocalmemory.core.security_primitives import safe_resolve_identifier
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # M-P-07: path-component hints for filesystems where ``fcntl.flock`` is
40
+ # known to degrade to a no-op. We do NOT abort — some users accept the
41
+ # risk — but we emit a one-shot warning so a double-cycle is at least
42
+ # attributable to a known root cause. If/when we ship a threading-only
43
+ # fallback, this list becomes the trigger for switching modes.
44
+ _SYNC_ROOT_HINTS: tuple[str, ...] = (
45
+ "iCloud Drive",
46
+ "Library/Mobile Documents", # macOS iCloud backing store
47
+ "OneDrive",
48
+ "Dropbox",
49
+ "Google Drive",
50
+ "pCloudDrive",
51
+ )
52
+ _WARNED_SYNC_PATHS: set[str] = set()
53
+
54
+
55
+ def _detect_sync_root(path: Path) -> str | None:
56
+ """Return the matching sync-root hint if ``path`` lives under one."""
57
+ try:
58
+ parts = tuple(path.resolve().parts)
59
+ except Exception: # pragma: no cover — path resolution failures
60
+ parts = path.parts
61
+ joined = "/".join(parts)
62
+ for hint in _SYNC_ROOT_HINTS:
63
+ if hint in joined:
64
+ return hint
65
+ return None
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Contract constants (non-negotiable per MASTER-PLAN §4.4)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ MAX_WALL_TIME_SEC: int = 1800 # 30 minutes per cycle
73
+ MAX_LLM_CALLS_PER_CYCLE: int = 10 # 10 LLM calls per cycle
74
+ MAX_CYCLES_PER_DAY: int = 3 # 3 cycles per profile per UTC day
75
+
76
+
77
+ class BudgetExhausted(RuntimeError):
78
+ """Raised when a budget dimension is crossed.
79
+
80
+ The ``dimension`` attribute records which cap was hit so callers can
81
+ log structured metrics without string-parsing the message.
82
+ """
83
+
84
+ def __init__(self, dimension: str, detail: str = "") -> None:
85
+ self.dimension = dimension
86
+ suffix = f": {detail}" if detail else ""
87
+ super().__init__(f"budget exhausted [{dimension}]{suffix}")
88
+
89
+
90
+ class EvolutionBudget:
91
+ """Per-profile, per-cycle budget gate.
92
+
93
+ Usage::
94
+
95
+ budget = EvolutionBudget(profile_id="default",
96
+ learning_db=Path("~/.slm/learning.db"),
97
+ lock_dir=Path("~/.superlocalmemory"))
98
+ with budget.cycle():
99
+ budget.check_time()
100
+ budget.charge_llm_call()
101
+ ...
102
+
103
+ The ``cycle()`` context manager acquires a single-flight lock (via
104
+ ``safe_resolve_identifier`` on the profile name) and enforces the
105
+ 3-cycles-per-day cap. Inside the cycle, callers must call
106
+ ``check_time()`` before each expensive step and ``charge_llm_call()``
107
+ before each LLM dispatch.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ profile_id: str,
114
+ learning_db: Path | str,
115
+ lock_dir: Path | str,
116
+ ) -> None:
117
+ self._profile_id = profile_id
118
+ self._learning_db = Path(learning_db)
119
+ self._lock_dir = Path(lock_dir)
120
+ self._lock_dir.mkdir(parents=True, exist_ok=True)
121
+ # Resolve lock path through the LLD-00 §4 safe helper so a malicious
122
+ # profile_id cannot escape the lock directory. The helper regex
123
+ # rejects '.' so we hand it the identifier portion only, then
124
+ # append the fixed ``.lock`` suffix to the validated path.
125
+ safe_stem = safe_resolve_identifier(
126
+ self._lock_dir, f"evolution-{profile_id}",
127
+ )
128
+ self._lock_path = safe_stem.with_suffix(".lock")
129
+ # M-P-07: warn once per lock-path when we detect the lock sits on
130
+ # a known sync-backed filesystem. ``fcntl.flock`` silently
131
+ # degrades on iCloud/OneDrive/Dropbox — two concurrent cycles
132
+ # would each acquire and burn LLM budget. Documentation-only for
133
+ # now; a future release may refuse to run until the lock_dir is
134
+ # moved off the sync root.
135
+ try:
136
+ root_hint = _detect_sync_root(self._lock_path)
137
+ except Exception: # pragma: no cover — defensive
138
+ root_hint = None
139
+ if root_hint is not None:
140
+ key = str(self._lock_path)
141
+ if key not in _WARNED_SYNC_PATHS:
142
+ _WARNED_SYNC_PATHS.add(key)
143
+ logger.warning(
144
+ "evolution lock at %s lives under %r — fcntl.flock "
145
+ "may silently no-op on sync-backed filesystems. "
146
+ "Concurrent cycles could double-bill LLM cost. Move "
147
+ "the lock_dir off the sync root to make single-flight "
148
+ "enforceable.",
149
+ key, root_hint,
150
+ )
151
+
152
+ self._cycle_start_mono: float | None = None
153
+ self._llm_calls_this_cycle: int = 0
154
+ self._lock_fd: int | None = None
155
+
156
+ # ------------------------------------------------------------------
157
+ # Per-day cycle accounting (sqlite-backed via evolution_config.last_cycle_at
158
+ # + cycles_this_week; today-count is derived from last_cycle_at day)
159
+ # ------------------------------------------------------------------
160
+
161
+ def _count_cycles_today(self) -> int:
162
+ """Count cycles recorded for ``profile_id`` on the current UTC day.
163
+
164
+ Reads from ``evolution_llm_cost_log`` — ``cycle_id`` distinct values
165
+ scoped to today. Evolution cycles stamp a ``cycle_id`` on every
166
+ cost-log row they emit, so distinct cycle_ids == distinct cycles.
167
+
168
+ A cycle with zero LLM calls still needs to count; the budget stamps
169
+ a zero-row sentinel via ``_record_cycle_start`` on acquire.
170
+ """
171
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
172
+ conn = sqlite3.connect(self._learning_db)
173
+ try:
174
+ row = conn.execute(
175
+ "SELECT COUNT(DISTINCT cycle_id) "
176
+ "FROM evolution_llm_cost_log "
177
+ "WHERE profile_id=? AND substr(ts,1,10)=? "
178
+ " AND cycle_id IS NOT NULL",
179
+ (self._profile_id, today),
180
+ ).fetchone()
181
+ return int(row[0]) if row and row[0] is not None else 0
182
+ finally:
183
+ conn.close()
184
+
185
+ def _record_cycle_start(self, cycle_id: str) -> None:
186
+ """Write a sentinel row so this cycle is counted toward the daily cap.
187
+
188
+ Tokens/cost are zero — the real LLM cost rows land as
189
+ ``charge_llm_call`` is invoked by the dispatcher.
190
+
191
+ H-16 (Stage 8): profile_id must be a non-empty string. Enforced
192
+ here so a mis-constructed EvolutionBudget fails at the first
193
+ write instead of silently attributing cost to an empty bucket.
194
+ """
195
+ if not isinstance(self._profile_id, str) or not self._profile_id.strip():
196
+ raise ValueError(
197
+ "EvolutionBudget.profile_id must be a non-empty string "
198
+ f"(got {self._profile_id!r})"
199
+ )
200
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
201
+ conn = sqlite3.connect(self._learning_db)
202
+ try:
203
+ conn.execute(
204
+ "INSERT INTO evolution_llm_cost_log "
205
+ "(profile_id, ts, model, tokens_in, tokens_out, cost_usd, cycle_id) "
206
+ "VALUES (?,?,?,?,?,?,?)",
207
+ (self._profile_id, now, "cycle-start", 0, 0, 0.0, cycle_id),
208
+ )
209
+ conn.commit()
210
+ finally:
211
+ conn.close()
212
+
213
+ # ------------------------------------------------------------------
214
+ # Public runtime checks
215
+ # ------------------------------------------------------------------
216
+
217
+ def check_time(self) -> None:
218
+ """Raise ``BudgetExhausted`` if wall-time cap exceeded."""
219
+ if self._cycle_start_mono is None:
220
+ raise RuntimeError("check_time() called outside cycle()")
221
+ elapsed = time.monotonic() - self._cycle_start_mono
222
+ if elapsed > MAX_WALL_TIME_SEC:
223
+ raise BudgetExhausted(
224
+ "wall_time",
225
+ f"elapsed {elapsed:.1f}s > {MAX_WALL_TIME_SEC}s",
226
+ )
227
+
228
+ def charge_llm_call(self) -> None:
229
+ """Charge one LLM call toward the per-cycle cap.
230
+
231
+ Raises ``BudgetExhausted`` AFTER the cap is already exhausted.
232
+ Call this BEFORE dispatching the LLM so the cap is protective.
233
+ """
234
+ if self._cycle_start_mono is None:
235
+ raise RuntimeError("charge_llm_call() called outside cycle()")
236
+ if self._llm_calls_this_cycle >= MAX_LLM_CALLS_PER_CYCLE:
237
+ raise BudgetExhausted(
238
+ "llm_calls",
239
+ f"charged {self._llm_calls_this_cycle} "
240
+ f"(cap {MAX_LLM_CALLS_PER_CYCLE})",
241
+ )
242
+ self._llm_calls_this_cycle += 1
243
+
244
+ # ------------------------------------------------------------------
245
+ # Context manager — single-flight + daily cap + wall-time init
246
+ # ------------------------------------------------------------------
247
+
248
+ @contextmanager
249
+ def cycle(self, cycle_id: str | None = None) -> Iterator["EvolutionBudget"]:
250
+ """Acquire single-flight lock + enforce daily cap + start wall timer."""
251
+ # Daily cap check BEFORE taking the lock — a blocked cycle must
252
+ # not hold the file lock while other cycles wait.
253
+ today_count = self._count_cycles_today()
254
+ if today_count >= MAX_CYCLES_PER_DAY:
255
+ raise BudgetExhausted(
256
+ "cycles_per_day",
257
+ f"profile={self._profile_id} today={today_count} "
258
+ f"cap={MAX_CYCLES_PER_DAY}",
259
+ )
260
+
261
+ # Single-flight lock (non-blocking flock). A second concurrent
262
+ # acquire raises BlockingIOError — surface as BudgetExhausted.
263
+ fd = os.open(str(self._lock_path), os.O_CREAT | os.O_RDWR, 0o600)
264
+ try:
265
+ try:
266
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
267
+ except BlockingIOError as e:
268
+ os.close(fd)
269
+ raise BudgetExhausted(
270
+ "single_flight",
271
+ f"another cycle holds {self._lock_path}",
272
+ ) from e
273
+ self._lock_fd = fd
274
+ except BudgetExhausted:
275
+ raise
276
+ except Exception:
277
+ try:
278
+ os.close(fd)
279
+ except OSError:
280
+ pass
281
+ raise
282
+
283
+ # Write pid marker (best-effort, never fails the acquire).
284
+ try:
285
+ os.write(fd, f"{os.getpid()}:{self._profile_id}\n".encode())
286
+ except OSError:
287
+ pass
288
+
289
+ cid = cycle_id or (
290
+ datetime.now(timezone.utc).strftime("cyc-%Y%m%d-%H%M%S-")
291
+ + uuid.uuid4().hex[:8]
292
+ )
293
+ try:
294
+ self._record_cycle_start(cid)
295
+ except sqlite3.Error as e:
296
+ # Release lock before propagating — a failed cycle-record should
297
+ # not leave the lock dangling.
298
+ try:
299
+ fcntl.flock(fd, fcntl.LOCK_UN)
300
+ finally:
301
+ os.close(fd)
302
+ self._lock_fd = None
303
+ raise RuntimeError(
304
+ f"failed to record cycle start: {e}",
305
+ ) from e
306
+
307
+ self._cycle_start_mono = time.monotonic()
308
+ self._llm_calls_this_cycle = 0
309
+
310
+ try:
311
+ yield self
312
+ finally:
313
+ self._cycle_start_mono = None
314
+ try:
315
+ fcntl.flock(fd, fcntl.LOCK_UN)
316
+ finally:
317
+ try:
318
+ os.close(fd)
319
+ except OSError:
320
+ pass
321
+ self._lock_fd = None