superlocalmemory 3.7.8 → 3.8.0
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/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +69 -0
- package/README.md +199 -29
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/agents/slm-governance-advisor.md +80 -0
- package/plugin-src/agents/slm-loop-runner.md +71 -0
- package/plugin-src/agents/slm-memory-advisor.md +10 -5
- package/plugin-src/agents/slm-optimize-advisor.md +9 -3
- package/plugin-src/commands/slm-loop.md +31 -0
- package/plugin-src/hooks/hooks.json +79 -0
- package/plugin-src/manifest.json +7 -2
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/rules/CLAUDE.md.fragment +8 -8
- package/plugin-src/scripts/slm-launch +46 -7
- package/plugin-src/settings.json +9 -0
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +94 -10
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/loop_cmd.py +192 -0
- package/src/superlocalmemory/cli/main.py +39 -4
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +17 -2
- package/src/superlocalmemory/core/embeddings.py +12 -1
- package/src/superlocalmemory/core/engine.py +17 -1
- package/src/superlocalmemory/core/engine_ingestion.py +29 -0
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +2 -0
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +35 -11
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +114 -1
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +243 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +21 -49
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +8 -1
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +168 -26
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +34 -10
- package/src/superlocalmemory/server/routes/behavioral.py +13 -12
- package/src/superlocalmemory/server/routes/brain.py +21 -5
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +436 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +9 -4
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +135 -17
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +14 -14
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +95 -15
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +13 -6
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/v3_api.py +183 -69
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +384 -56
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +53 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +87 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +306 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +47 -31
- package/src/superlocalmemory/ui/js/dashboard.js +314 -63
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +780 -0
- package/src/superlocalmemory/ui/js/od-brain.js +779 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +593 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +887 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
- package/src/superlocalmemory/ui/js/od-shell.js +593 -0
- package/src/superlocalmemory/ui/js/od-skills.js +573 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +2 -2
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -118,6 +118,22 @@ class RateLimiter:
|
|
|
118
118
|
del self._requests[k]
|
|
119
119
|
return len(stale)
|
|
120
120
|
|
|
121
|
+
def configure(
|
|
122
|
+
self,
|
|
123
|
+
max_requests: int | None = None,
|
|
124
|
+
window_seconds: int | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Reconfigure limits at runtime (thread-safe).
|
|
127
|
+
|
|
128
|
+
Recorded request timestamps are preserved; only the ceilings change,
|
|
129
|
+
so a raised limit takes effect immediately for the current window.
|
|
130
|
+
"""
|
|
131
|
+
with self._lock:
|
|
132
|
+
if max_requests is not None:
|
|
133
|
+
self.max_requests = max(1, int(max_requests))
|
|
134
|
+
if window_seconds is not None:
|
|
135
|
+
self.window = max(1, int(window_seconds))
|
|
136
|
+
|
|
121
137
|
def get_stats(self) -> dict:
|
|
122
138
|
"""Return a snapshot of limiter state."""
|
|
123
139
|
with self._lock:
|
|
@@ -133,3 +149,80 @@ class RateLimiter:
|
|
|
133
149
|
# ---------------------------------------------------------------------------
|
|
134
150
|
write_limiter = RateLimiter(max_requests=WRITE_LIMIT, window_seconds=WINDOW_SECONDS)
|
|
135
151
|
read_limiter = RateLimiter(max_requests=READ_LIMIT, window_seconds=WINDOW_SECONDS)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Runtime-configurable limits (task #47) — dashboard-editable thresholds.
|
|
156
|
+
#
|
|
157
|
+
# The enforcement middleware builds its own limiter instances; it registers
|
|
158
|
+
# them here by role so a single set_limits() call reconfigures every live
|
|
159
|
+
# limiter at once (no restart). Loopback limiters derive from write/read,
|
|
160
|
+
# matching the startup derivation in unified_daemon.
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
_MANAGED: List[Tuple[str, "RateLimiter"]] = []
|
|
164
|
+
_MANAGED_LOCK = threading.Lock()
|
|
165
|
+
_CURRENT: Dict[str, int] = {
|
|
166
|
+
"write": WRITE_LIMIT, "read": READ_LIMIT, "window": WINDOW_SECONDS,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _loopback_write(write: int) -> int:
|
|
171
|
+
return max(300, int(write) * 10)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _loopback_read(read: int) -> int:
|
|
175
|
+
return max(2000, int(read) * 20)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def register_managed(role: str, limiter: "RateLimiter") -> None:
|
|
179
|
+
"""Register an enforcement limiter so set_limits() can reconfigure it.
|
|
180
|
+
|
|
181
|
+
role is one of: 'write', 'read', 'lb_write', 'lb_read'.
|
|
182
|
+
"""
|
|
183
|
+
with _MANAGED_LOCK:
|
|
184
|
+
_MANAGED.append((role, limiter))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def reset_managed() -> None:
|
|
188
|
+
"""Drop all registered limiters (app teardown / test isolation)."""
|
|
189
|
+
with _MANAGED_LOCK:
|
|
190
|
+
_MANAGED.clear()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_limits() -> Dict[str, int]:
|
|
194
|
+
"""Return the current effective write/read/window limits."""
|
|
195
|
+
with _MANAGED_LOCK:
|
|
196
|
+
return dict(_CURRENT)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def set_limits(
|
|
200
|
+
write: int | None = None,
|
|
201
|
+
read: int | None = None,
|
|
202
|
+
window: int | None = None,
|
|
203
|
+
) -> Dict[str, int]:
|
|
204
|
+
"""Update limits and reconfigure every registered limiter live.
|
|
205
|
+
|
|
206
|
+
Partial updates are supported (pass only what changes). Loopback
|
|
207
|
+
limiters re-derive from the new write/read. Returns the new effective
|
|
208
|
+
limits. Values are floored at 1.
|
|
209
|
+
"""
|
|
210
|
+
with _MANAGED_LOCK:
|
|
211
|
+
if write is not None:
|
|
212
|
+
_CURRENT["write"] = max(1, int(write))
|
|
213
|
+
if read is not None:
|
|
214
|
+
_CURRENT["read"] = max(1, int(read))
|
|
215
|
+
if window is not None:
|
|
216
|
+
_CURRENT["window"] = max(1, int(window))
|
|
217
|
+
w, r, win = _CURRENT["write"], _CURRENT["read"], _CURRENT["window"]
|
|
218
|
+
lb_w, lb_r = _loopback_write(w), _loopback_read(r)
|
|
219
|
+
for role, limiter in _MANAGED:
|
|
220
|
+
if role == "write":
|
|
221
|
+
limiter.configure(max_requests=w, window_seconds=win)
|
|
222
|
+
elif role == "read":
|
|
223
|
+
limiter.configure(max_requests=r, window_seconds=win)
|
|
224
|
+
elif role == "lb_write":
|
|
225
|
+
limiter.configure(max_requests=lb_w, window_seconds=win)
|
|
226
|
+
elif role == "lb_read":
|
|
227
|
+
limiter.configure(max_requests=lb_r, window_seconds=win)
|
|
228
|
+
return dict(_CURRENT)
|
|
@@ -175,7 +175,10 @@ def stop_adapter(name: str) -> dict:
|
|
|
175
175
|
proc.terminate()
|
|
176
176
|
proc.wait(timeout=10)
|
|
177
177
|
except ImportError:
|
|
178
|
-
os.kill(pid, 15)
|
|
178
|
+
# No psutil: best-effort termination. On Windows os.kill(pid, 15) maps to
|
|
179
|
+
# TerminateProcess (a hard kill, not a graceful SIGTERM) — psutil's
|
|
180
|
+
# terminate() does the same there, so behavior is equivalent either way.
|
|
181
|
+
os.kill(pid, 15)
|
|
179
182
|
except Exception:
|
|
180
183
|
pass
|
|
181
184
|
|
|
@@ -42,7 +42,7 @@ def store_credential(service: str, key: str, value: str) -> bool:
|
|
|
42
42
|
except Exception:
|
|
43
43
|
pass
|
|
44
44
|
|
|
45
|
-
# Fallback:
|
|
45
|
+
# Fallback: restricted-permission file (0600) when no OS keychain is present
|
|
46
46
|
try:
|
|
47
47
|
cred_dir = _credential_dir()
|
|
48
48
|
cred_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -65,6 +65,7 @@ TECH_CATEGORIES: dict[str, list[str]] = {
|
|
|
65
65
|
_SCHEMA = """
|
|
66
66
|
CREATE TABLE IF NOT EXISTS transferable_patterns (
|
|
67
67
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
profile_id TEXT NOT NULL DEFAULT 'default',
|
|
68
69
|
pattern_type TEXT NOT NULL DEFAULT 'preference',
|
|
69
70
|
key TEXT NOT NULL,
|
|
70
71
|
value TEXT NOT NULL,
|
|
@@ -75,7 +76,7 @@ CREATE TABLE IF NOT EXISTS transferable_patterns (
|
|
|
75
76
|
contradictions TEXT DEFAULT '[]',
|
|
76
77
|
first_seen TEXT,
|
|
77
78
|
last_seen TEXT,
|
|
78
|
-
UNIQUE(pattern_type, key)
|
|
79
|
+
UNIQUE(profile_id, pattern_type, key)
|
|
79
80
|
);
|
|
80
81
|
|
|
81
82
|
CREATE TABLE IF NOT EXISTS memories (
|
|
@@ -115,25 +116,29 @@ class CrossProjectAggregator:
|
|
|
115
116
|
# Step 2: Merge with temporal decay
|
|
116
117
|
merged = self._merge_with_decay(profile_patterns)
|
|
117
118
|
|
|
118
|
-
# Step 3: Detect contradictions
|
|
119
|
+
# Step 3: Detect contradictions (scoped to the target profile)
|
|
119
120
|
for key, pattern_data in merged.items():
|
|
120
121
|
pattern_data["contradictions"] = self._detect_contradictions(
|
|
121
|
-
key, pattern_data
|
|
122
|
+
key, pattern_data, target_profile
|
|
122
123
|
)
|
|
123
124
|
|
|
124
|
-
# Step 4: Store and return
|
|
125
|
-
self._store_patterns(merged)
|
|
125
|
+
# Step 4: Store and return (scoped to the target profile)
|
|
126
|
+
self._store_patterns(merged, target_profile)
|
|
126
127
|
return [{"key": k, **v} for k, v in merged.items()]
|
|
127
128
|
|
|
128
|
-
def get_preferences(
|
|
129
|
-
|
|
129
|
+
def get_preferences(
|
|
130
|
+
self,
|
|
131
|
+
profile_id: str = "default",
|
|
132
|
+
min_confidence: float = 0.6,
|
|
133
|
+
) -> dict[str, dict]:
|
|
134
|
+
"""Retrieve stored transferable preferences for *profile_id* above *min_confidence*."""
|
|
130
135
|
conn = sqlite3.connect(str(self._db_path))
|
|
131
136
|
conn.row_factory = sqlite3.Row
|
|
132
137
|
try:
|
|
133
138
|
cur = conn.execute(
|
|
134
139
|
"SELECT * FROM transferable_patterns "
|
|
135
|
-
"WHERE confidence >= ? ORDER BY confidence DESC",
|
|
136
|
-
(min_confidence
|
|
140
|
+
"WHERE profile_id = ? AND confidence >= ? ORDER BY confidence DESC",
|
|
141
|
+
(profile_id, min_confidence),
|
|
137
142
|
)
|
|
138
143
|
result: dict[str, dict] = {}
|
|
139
144
|
for row in cur.fetchall():
|
|
@@ -277,18 +282,18 @@ class CrossProjectAggregator:
|
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
def _detect_contradictions(
|
|
280
|
-
self, pattern_key: str, pattern_data: dict
|
|
285
|
+
self, pattern_key: str, pattern_data: dict, profile_id: str = "default"
|
|
281
286
|
) -> list[str]:
|
|
282
287
|
contradictions: list[str] = []
|
|
283
288
|
|
|
284
|
-
# Check stored value vs new value
|
|
289
|
+
# Check stored value vs new value, scoped to this profile.
|
|
285
290
|
conn = sqlite3.connect(str(self._db_path))
|
|
286
291
|
conn.row_factory = sqlite3.Row
|
|
287
292
|
try:
|
|
288
293
|
cur = conn.execute(
|
|
289
294
|
"SELECT value, last_seen FROM transferable_patterns "
|
|
290
|
-
"WHERE key = ? AND pattern_type = 'preference'",
|
|
291
|
-
(pattern_key
|
|
295
|
+
"WHERE profile_id = ? AND key = ? AND pattern_type = 'preference'",
|
|
296
|
+
(profile_id, pattern_key),
|
|
292
297
|
)
|
|
293
298
|
row = cur.fetchone()
|
|
294
299
|
if row:
|
|
@@ -316,18 +321,21 @@ class CrossProjectAggregator:
|
|
|
316
321
|
finally:
|
|
317
322
|
conn.close()
|
|
318
323
|
|
|
319
|
-
def _store_patterns(
|
|
324
|
+
def _store_patterns(
|
|
325
|
+
self, merged: dict[str, dict], profile_id: str = "default"
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Persist merged patterns for *profile_id*. Each profile has isolated rows."""
|
|
320
328
|
conn = sqlite3.connect(str(self._db_path))
|
|
321
329
|
now = datetime.now(UTC).isoformat()
|
|
322
330
|
try:
|
|
323
331
|
for key, data in merged.items():
|
|
324
332
|
conn.execute(
|
|
325
333
|
"""INSERT INTO transferable_patterns
|
|
326
|
-
(pattern_type, key, value, confidence,
|
|
327
|
-
profiles_seen, decay_factor,
|
|
328
|
-
first_seen, last_seen)
|
|
329
|
-
VALUES ('preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
330
|
-
ON CONFLICT(pattern_type, key) DO UPDATE SET
|
|
334
|
+
(profile_id, pattern_type, key, value, confidence,
|
|
335
|
+
evidence_count, profiles_seen, decay_factor,
|
|
336
|
+
contradictions, first_seen, last_seen)
|
|
337
|
+
VALUES (?, 'preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
338
|
+
ON CONFLICT(profile_id, pattern_type, key) DO UPDATE SET
|
|
331
339
|
value = excluded.value,
|
|
332
340
|
confidence = excluded.confidence,
|
|
333
341
|
evidence_count = excluded.evidence_count,
|
|
@@ -337,6 +345,7 @@ class CrossProjectAggregator:
|
|
|
337
345
|
last_seen = excluded.last_seen
|
|
338
346
|
""",
|
|
339
347
|
(
|
|
348
|
+
profile_id,
|
|
340
349
|
key,
|
|
341
350
|
data["value"],
|
|
342
351
|
data["confidence"],
|
|
@@ -114,8 +114,14 @@ def _tool_event_hit(
|
|
|
114
114
|
memory_conn: sqlite3.Connection,
|
|
115
115
|
played_at: datetime,
|
|
116
116
|
fact_ids: list[str],
|
|
117
|
+
profile_id: str | None = None,
|
|
117
118
|
) -> bool:
|
|
118
|
-
"""True iff any tool_events row references any fact_id within +30 s.
|
|
119
|
+
"""True iff any tool_events row references any fact_id within +30 s.
|
|
120
|
+
|
|
121
|
+
Tenant-scoped: without a profile predicate, one tenant's reward signal
|
|
122
|
+
would be reinforced by another tenant's tool events firing in the same
|
|
123
|
+
30 s window — cross-contaminated behavioral learning.
|
|
124
|
+
"""
|
|
119
125
|
if not fact_ids:
|
|
120
126
|
return False
|
|
121
127
|
start = played_at.isoformat(timespec="seconds")
|
|
@@ -139,12 +145,24 @@ def _tool_event_hit(
|
|
|
139
145
|
# in Python — typically 0-few rows in a 30 s window, far cheaper than
|
|
140
146
|
# N LIKE passes against a growing table. Still O(rows_in_window * len(fact_ids))
|
|
141
147
|
# worst case but the constant factor is tiny (a substring check).
|
|
148
|
+
# Add the profile predicate only when tool_events actually has the column
|
|
149
|
+
# (schema varies by install).
|
|
150
|
+
scope_sql, scope_params = "", ()
|
|
151
|
+
if profile_id is not None:
|
|
152
|
+
try:
|
|
153
|
+
cols = {r[1] for r in memory_conn.execute(
|
|
154
|
+
"PRAGMA table_info(tool_events)").fetchall()}
|
|
155
|
+
if "profile_id" in cols:
|
|
156
|
+
scope_sql = " AND profile_id = ?"
|
|
157
|
+
scope_params = (profile_id,)
|
|
158
|
+
except sqlite3.Error:
|
|
159
|
+
pass
|
|
142
160
|
try:
|
|
143
161
|
candidate_rows = memory_conn.execute(
|
|
144
162
|
"SELECT payload_json FROM tool_events "
|
|
145
163
|
"WHERE occurred_at BETWEEN ? AND ? "
|
|
146
|
-
" AND payload_json IS NOT NULL",
|
|
147
|
-
(start, end),
|
|
164
|
+
" AND payload_json IS NOT NULL" + scope_sql,
|
|
165
|
+
(start, end, *scope_params),
|
|
148
166
|
).fetchall()
|
|
149
167
|
except sqlite3.Error:
|
|
150
168
|
return False
|
|
@@ -165,6 +183,7 @@ def _requery_detected(
|
|
|
165
183
|
memory_conn: sqlite3.Connection,
|
|
166
184
|
played_at: datetime,
|
|
167
185
|
query_id: str,
|
|
186
|
+
profile_id: str | None = None,
|
|
168
187
|
) -> bool:
|
|
169
188
|
"""True iff a follow-up query within 30 s has matching NFC topic sig.
|
|
170
189
|
|
|
@@ -193,14 +212,28 @@ def _requery_detected(
|
|
|
193
212
|
if tbl is None:
|
|
194
213
|
return False
|
|
195
214
|
|
|
215
|
+
# Tenant scope (guarded — tool_events schema varies): without it, another
|
|
216
|
+
# profile's recall events in the same window count as this profile's
|
|
217
|
+
# requeries, corrupting reward attribution.
|
|
218
|
+
scope_sql, scope_params = "", ()
|
|
219
|
+
if profile_id is not None:
|
|
220
|
+
try:
|
|
221
|
+
cols = {r[1] for r in memory_conn.execute(
|
|
222
|
+
"PRAGMA table_info(tool_events)").fetchall()}
|
|
223
|
+
if "profile_id" in cols:
|
|
224
|
+
scope_sql = " AND profile_id = ?"
|
|
225
|
+
scope_params = (profile_id,)
|
|
226
|
+
except sqlite3.Error:
|
|
227
|
+
pass
|
|
228
|
+
|
|
196
229
|
# Read *query* text from tool_events payload for within-window events;
|
|
197
230
|
# compute topic sig on each, compare against the original query's sig.
|
|
198
231
|
try:
|
|
199
232
|
rows = memory_conn.execute(
|
|
200
233
|
"SELECT payload_json FROM tool_events "
|
|
201
234
|
"WHERE occurred_at > ? AND occurred_at <= ? "
|
|
202
|
-
" AND tool_name = 'recall' LIMIT 20",
|
|
203
|
-
(start, end),
|
|
235
|
+
" AND tool_name = 'recall'" + scope_sql + " LIMIT 20",
|
|
236
|
+
(start, end, *scope_params),
|
|
204
237
|
).fetchall()
|
|
205
238
|
except sqlite3.Error:
|
|
206
239
|
return False
|
|
@@ -212,9 +245,9 @@ def _requery_detected(
|
|
|
212
245
|
try:
|
|
213
246
|
seed_row = memory_conn.execute(
|
|
214
247
|
"SELECT payload_json FROM tool_events "
|
|
215
|
-
"WHERE occurred_at <= ? AND tool_name = 'recall' "
|
|
248
|
+
"WHERE occurred_at <= ? AND tool_name = 'recall'" + scope_sql + " "
|
|
216
249
|
"ORDER BY occurred_at DESC LIMIT 1",
|
|
217
|
-
(played_at.isoformat(timespec="seconds"),),
|
|
250
|
+
(played_at.isoformat(timespec="seconds"), *scope_params),
|
|
218
251
|
).fetchone()
|
|
219
252
|
except sqlite3.Error:
|
|
220
253
|
seed_row = None
|
|
@@ -288,12 +321,12 @@ def settle_stale_plays(
|
|
|
288
321
|
reward: float | None = None
|
|
289
322
|
kind = "default"
|
|
290
323
|
if memory_conn is not None and _tool_event_hit(
|
|
291
|
-
memory_conn, played, top3,
|
|
324
|
+
memory_conn, played, top3, profile_id=str(profile_id),
|
|
292
325
|
):
|
|
293
326
|
reward = 1.0
|
|
294
327
|
kind = "proxy_position"
|
|
295
328
|
elif memory_conn is not None and _requery_detected(
|
|
296
|
-
memory_conn, played, row["query_id"],
|
|
329
|
+
memory_conn, played, row["query_id"], profile_id=str(profile_id),
|
|
297
330
|
):
|
|
298
331
|
reward = 0.0
|
|
299
332
|
kind = "proxy_requery"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""SuperLocalMemory bounded loops.
|
|
2
|
+
|
|
3
|
+
Agent loops that stop when an independent gate passes — not when the agent
|
|
4
|
+
says it is done — with the run's history persisted in SLM's durable memory.
|
|
5
|
+
|
|
6
|
+
Public API::
|
|
7
|
+
|
|
8
|
+
from superlocalmemory.loops import (
|
|
9
|
+
Bounds, Rung, Status, Verdict, LapResult, Outcome,
|
|
10
|
+
run_bounded_loop, InMemoryLedger, SLMMemoryLedger, open_engine_store,
|
|
11
|
+
)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from superlocalmemory.loops.engine import run_bounded_loop
|
|
17
|
+
from superlocalmemory.loops.ledger import (
|
|
18
|
+
InMemoryLedger,
|
|
19
|
+
LedgerEntry,
|
|
20
|
+
LedgerStore,
|
|
21
|
+
SLMMemoryLedger,
|
|
22
|
+
engine_backed_ledger,
|
|
23
|
+
open_engine_store,
|
|
24
|
+
)
|
|
25
|
+
from superlocalmemory.loops.models import (
|
|
26
|
+
Bounds,
|
|
27
|
+
LapResult,
|
|
28
|
+
Outcome,
|
|
29
|
+
Rung,
|
|
30
|
+
Status,
|
|
31
|
+
Verdict,
|
|
32
|
+
)
|
|
33
|
+
from superlocalmemory.loops.rules import (
|
|
34
|
+
no_progress,
|
|
35
|
+
rung_requires_approval,
|
|
36
|
+
stop_condition_met,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Bounds",
|
|
41
|
+
"Rung",
|
|
42
|
+
"Status",
|
|
43
|
+
"Verdict",
|
|
44
|
+
"LapResult",
|
|
45
|
+
"Outcome",
|
|
46
|
+
"run_bounded_loop",
|
|
47
|
+
"LedgerEntry",
|
|
48
|
+
"LedgerStore",
|
|
49
|
+
"InMemoryLedger",
|
|
50
|
+
"SLMMemoryLedger",
|
|
51
|
+
"engine_backed_ledger",
|
|
52
|
+
"open_engine_store",
|
|
53
|
+
"no_progress",
|
|
54
|
+
"rung_requires_approval",
|
|
55
|
+
"stop_condition_met",
|
|
56
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Cumulative budget accounting for a bounded-loop run.
|
|
2
|
+
|
|
3
|
+
The meter tracks laps, tokens, and wall-clock time and reports when any bound
|
|
4
|
+
in effect has been exceeded. It is intentionally tiny and side-effect-free
|
|
5
|
+
apart from its own internal counters, and the clock is injected so tests are
|
|
6
|
+
deterministic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from superlocalmemory.loops.models import Bounds
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BudgetMeter:
|
|
17
|
+
"""Accumulate spend across laps and answer "have we gone over?".
|
|
18
|
+
|
|
19
|
+
``now`` is a zero-argument callable returning monotonic-ish seconds
|
|
20
|
+
(``time.monotonic`` in production, a fake in tests). The start time is
|
|
21
|
+
captured at construction so wall-clock enforcement needs no globals.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, now: Callable[[], float]) -> None:
|
|
25
|
+
self._now = now
|
|
26
|
+
self._start = now()
|
|
27
|
+
self._tokens = 0
|
|
28
|
+
|
|
29
|
+
def spend(self, tokens: int) -> None:
|
|
30
|
+
"""Record token spend for a completed lap (negative values ignored)."""
|
|
31
|
+
if tokens > 0:
|
|
32
|
+
self._tokens += tokens
|
|
33
|
+
|
|
34
|
+
def exceeded(self, lap: int, bounds: Bounds) -> tuple[bool, str]:
|
|
35
|
+
"""Return ``(tripped, reason)`` for the bounds checked before a lap runs.
|
|
36
|
+
|
|
37
|
+
Checked in priority order: iteration cap, token budget, wall-clock.
|
|
38
|
+
``lap`` is the 1-based number of the lap about to run, so exceeding
|
|
39
|
+
``max_iterations`` is reported when the (max+1)-th lap is attempted.
|
|
40
|
+
"""
|
|
41
|
+
if lap > bounds.max_iterations:
|
|
42
|
+
return True, "max-iterations"
|
|
43
|
+
# >= so a budget of N never permits an (N+1)-th lap's worth of spend:
|
|
44
|
+
# once cumulative tokens reach the ceiling, the next lap is refused.
|
|
45
|
+
if bounds.max_tokens is not None and self._tokens >= bounds.max_tokens:
|
|
46
|
+
return True, "token-budget"
|
|
47
|
+
if bounds.max_wallclock_s is not None:
|
|
48
|
+
elapsed = self._now() - self._start
|
|
49
|
+
if elapsed > bounds.max_wallclock_s:
|
|
50
|
+
return True, "wallclock"
|
|
51
|
+
return False, ""
|
|
52
|
+
|
|
53
|
+
def snapshot(self) -> dict:
|
|
54
|
+
"""Point-in-time spend, suitable for a ledger entry."""
|
|
55
|
+
return {
|
|
56
|
+
"tokens": self._tokens,
|
|
57
|
+
"wallclock_s": round(self._now() - self._start, 3),
|
|
58
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The bounded-loop orchestrator.
|
|
2
|
+
|
|
3
|
+
``run_bounded_loop`` executes a loop for a single goal under a fixed set of
|
|
4
|
+
:class:`Bounds`. Its one non-negotiable invariant: **the independent gate
|
|
5
|
+
decides when the loop is finished — never the agent.** A runner's
|
|
6
|
+
``agent_claimed_done`` flag is written to the ledger for audit and is never
|
|
7
|
+
read when deciding to terminate.
|
|
8
|
+
|
|
9
|
+
Each lap, in strict order:
|
|
10
|
+
|
|
11
|
+
1. Poll the kill switch (highest priority — checked before any work).
|
|
12
|
+
2. Check the budget bounds (iteration cap, tokens, wall-clock).
|
|
13
|
+
3. Run the agent's proposer for one lap.
|
|
14
|
+
4. Accumulate token spend.
|
|
15
|
+
5. Ask the *independent* gate for a verdict.
|
|
16
|
+
6. Decide: a passing gate (plus any required approval) ends the run; an
|
|
17
|
+
exhausted no-progress window halts it; otherwise continue.
|
|
18
|
+
|
|
19
|
+
Runner and gate are plain callables taking the 1-based lap number, so this
|
|
20
|
+
engine carries no subprocess, sandbox, or framework machinery — SLM loops
|
|
21
|
+
converge on a checkable memory/verification condition, and heavier isolation
|
|
22
|
+
belongs to the standalone bounded-loops engine, not here.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import Callable, Optional
|
|
32
|
+
|
|
33
|
+
from superlocalmemory.loops.budget import BudgetMeter
|
|
34
|
+
from superlocalmemory.loops.ledger import InMemoryLedger, LedgerEntry, LedgerStore
|
|
35
|
+
from superlocalmemory.loops.models import (
|
|
36
|
+
Bounds,
|
|
37
|
+
LapResult,
|
|
38
|
+
Outcome,
|
|
39
|
+
Rung,
|
|
40
|
+
Status,
|
|
41
|
+
Verdict,
|
|
42
|
+
)
|
|
43
|
+
from superlocalmemory.loops.rules import (
|
|
44
|
+
no_progress,
|
|
45
|
+
rung_requires_approval,
|
|
46
|
+
stop_condition_met,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
RunnerFn = Callable[[int], LapResult]
|
|
50
|
+
GateFn = Callable[[int], Verdict]
|
|
51
|
+
ApproverFn = Callable[[Verdict], bool]
|
|
52
|
+
KillSwitchFn = Callable[[], bool]
|
|
53
|
+
ClockFn = Callable[[], str]
|
|
54
|
+
|
|
55
|
+
_KILL_ENV = "SLM_LOOP_KILL"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _utc_now_iso() -> str:
|
|
59
|
+
return datetime.now(timezone.utc).isoformat()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _env_killed() -> bool:
|
|
63
|
+
return bool(os.environ.get(_KILL_ENV))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_bounded_loop(
|
|
67
|
+
name: str,
|
|
68
|
+
*,
|
|
69
|
+
bounds: Bounds,
|
|
70
|
+
runner: RunnerFn,
|
|
71
|
+
gate: GateFn,
|
|
72
|
+
rung: Rung = Rung.L1,
|
|
73
|
+
ledger: Optional[LedgerStore] = None,
|
|
74
|
+
approver: Optional[ApproverFn] = None,
|
|
75
|
+
killswitch: Optional[KillSwitchFn] = None,
|
|
76
|
+
clock: Optional[ClockFn] = None,
|
|
77
|
+
monotonic: Optional[Callable[[], float]] = None,
|
|
78
|
+
run_id: Optional[str] = None,
|
|
79
|
+
) -> Outcome:
|
|
80
|
+
"""Run one bounded loop and return its :class:`Outcome`.
|
|
81
|
+
|
|
82
|
+
Only ``name``, ``bounds``, ``runner`` and ``gate`` are required. Every
|
|
83
|
+
other dependency is injected for determinism in tests; sensible defaults
|
|
84
|
+
(UTC clock, monotonic timer, env-var kill switch, in-memory ledger) apply
|
|
85
|
+
otherwise.
|
|
86
|
+
"""
|
|
87
|
+
run_id = run_id or f"{name}-{uuid.uuid4().hex[:8]}"
|
|
88
|
+
ledger = ledger if ledger is not None else InMemoryLedger()
|
|
89
|
+
clock = clock or _utc_now_iso
|
|
90
|
+
monotonic = monotonic or time.monotonic
|
|
91
|
+
killswitch = killswitch or _env_killed
|
|
92
|
+
budget = BudgetMeter(monotonic)
|
|
93
|
+
|
|
94
|
+
lap_changes: list[bool] = []
|
|
95
|
+
lap = 0
|
|
96
|
+
|
|
97
|
+
def emit(decision: str, verdict: Verdict, result: LapResult | None = None) -> None:
|
|
98
|
+
ledger.record(
|
|
99
|
+
LedgerEntry(
|
|
100
|
+
run_id=run_id,
|
|
101
|
+
name=name,
|
|
102
|
+
lap=lap,
|
|
103
|
+
ts=clock(),
|
|
104
|
+
decision=decision,
|
|
105
|
+
passed=verdict.passed,
|
|
106
|
+
detail=verdict.detail,
|
|
107
|
+
# The agent's own claim is recorded for audit only — never used
|
|
108
|
+
# to terminate (see the loop invariant). Log is capped so a
|
|
109
|
+
# verbose runner can't bloat the ledger row.
|
|
110
|
+
agent_claimed_done=bool(result.agent_claimed_done) if result else False,
|
|
111
|
+
runner_log=(result.log or "")[:2000] if result else "",
|
|
112
|
+
budget=budget.snapshot(),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
while True:
|
|
117
|
+
lap += 1
|
|
118
|
+
|
|
119
|
+
# 1. Kill switch — before any work (so laps reports completed laps).
|
|
120
|
+
if killswitch():
|
|
121
|
+
emit("killed", Verdict(False, "kill switch tripped"))
|
|
122
|
+
return Outcome(Status.KILLED, "killed", lap - 1, run_id)
|
|
123
|
+
|
|
124
|
+
# 2. Budget bounds — before running the agent.
|
|
125
|
+
tripped, why = budget.exceeded(lap, bounds)
|
|
126
|
+
if tripped:
|
|
127
|
+
emit("halt", Verdict(False, why))
|
|
128
|
+
return Outcome(Status.HALT, why, lap - 1, run_id)
|
|
129
|
+
|
|
130
|
+
# 3. Run the proposer for one lap.
|
|
131
|
+
try:
|
|
132
|
+
result = runner(lap)
|
|
133
|
+
except Exception as exc: # runner failure is a terminal ERROR
|
|
134
|
+
detail = f"runner error: {type(exc).__name__}: {exc}"
|
|
135
|
+
emit("error", Verdict(False, detail))
|
|
136
|
+
return Outcome(Status.ERROR, detail, lap, run_id)
|
|
137
|
+
|
|
138
|
+
# 4. Accumulate spend.
|
|
139
|
+
budget.spend(result.tokens)
|
|
140
|
+
lap_changes.append(result.changed)
|
|
141
|
+
|
|
142
|
+
# 5. Independent gate — agent's own claim is never consulted here.
|
|
143
|
+
try:
|
|
144
|
+
verdict = gate(lap)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
detail = f"gate error: {type(exc).__name__}: {exc}"
|
|
147
|
+
emit("error", Verdict(False, detail), result)
|
|
148
|
+
return Outcome(Status.ERROR, detail, lap, run_id)
|
|
149
|
+
|
|
150
|
+
# 6. Decide.
|
|
151
|
+
if stop_condition_met(verdict):
|
|
152
|
+
if rung_requires_approval(rung, bounds):
|
|
153
|
+
granted = bool(approver(verdict)) if approver is not None else False
|
|
154
|
+
if not granted:
|
|
155
|
+
emit("pause", verdict, result)
|
|
156
|
+
return Outcome(Status.PAUSE, "awaiting-approval", lap, run_id)
|
|
157
|
+
emit("done", verdict, result)
|
|
158
|
+
return Outcome(Status.DONE, "gate-passed", lap, run_id)
|
|
159
|
+
|
|
160
|
+
if no_progress(lap_changes, bounds.no_progress_window):
|
|
161
|
+
emit("halt", Verdict(False, "no-progress"), result)
|
|
162
|
+
return Outcome(Status.HALT, "no-progress", lap, run_id)
|
|
163
|
+
|
|
164
|
+
emit("continue", verdict, result)
|