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.
- package/CHANGELOG.md +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- 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(
|
|
69
|
-
|
|
70
|
-
|
|
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
|