superlocalmemory 3.8.3 → 3.8.6
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 +76 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|
|
14
14
|
import hashlib
|
|
15
15
|
import hmac
|
|
16
16
|
import logging
|
|
17
|
+
import sqlite3
|
|
18
|
+
from pathlib import Path
|
|
17
19
|
from typing import TYPE_CHECKING, Any
|
|
18
20
|
|
|
19
21
|
if TYPE_CHECKING:
|
|
@@ -212,6 +214,70 @@ def _behavioral_entities(results: list[Any], limit: int = 20) -> list[str]:
|
|
|
212
214
|
_RANKING_MODES: frozenset[str] = frozenset({"off", "v1", "v2", "v2-ensemble"})
|
|
213
215
|
|
|
214
216
|
|
|
217
|
+
class _ReadOnlyLearningView:
|
|
218
|
+
"""Minimal learning-model reader that cannot initialise or mutate a DB."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, db_path: Path) -> None:
|
|
221
|
+
self._db_path = db_path.resolve()
|
|
222
|
+
|
|
223
|
+
def _connection(self) -> sqlite3.Connection:
|
|
224
|
+
connection = sqlite3.connect(
|
|
225
|
+
f"{self._db_path.as_uri()}?mode=ro",
|
|
226
|
+
uri=True,
|
|
227
|
+
timeout=0.25,
|
|
228
|
+
)
|
|
229
|
+
connection.row_factory = sqlite3.Row
|
|
230
|
+
connection.execute("PRAGMA query_only=ON")
|
|
231
|
+
connection.execute("PRAGMA busy_timeout=250")
|
|
232
|
+
return connection
|
|
233
|
+
|
|
234
|
+
def count_signals(self, profile_id: str) -> int:
|
|
235
|
+
connection = self._connection()
|
|
236
|
+
try:
|
|
237
|
+
row = connection.execute(
|
|
238
|
+
"SELECT COUNT(*) AS count FROM learning_signals "
|
|
239
|
+
"WHERE profile_id = ?",
|
|
240
|
+
(profile_id,),
|
|
241
|
+
).fetchone()
|
|
242
|
+
return int(row["count"]) if row else 0
|
|
243
|
+
finally:
|
|
244
|
+
connection.close()
|
|
245
|
+
|
|
246
|
+
def count_feedback(self, profile_id: str) -> int:
|
|
247
|
+
"""Count legacy feedback without running schema initialization."""
|
|
248
|
+
connection = self._connection()
|
|
249
|
+
try:
|
|
250
|
+
row = connection.execute(
|
|
251
|
+
"SELECT COUNT(*) AS count FROM learning_feedback "
|
|
252
|
+
"WHERE profile_id = ?",
|
|
253
|
+
(profile_id,),
|
|
254
|
+
).fetchone()
|
|
255
|
+
return int(row["count"]) if row else 0
|
|
256
|
+
finally:
|
|
257
|
+
connection.close()
|
|
258
|
+
|
|
259
|
+
def load_active_model(self, profile_id: str) -> dict[str, Any] | None:
|
|
260
|
+
connection = self._connection()
|
|
261
|
+
try:
|
|
262
|
+
row = connection.execute(
|
|
263
|
+
"SELECT state_bytes, bytes_sha256, feature_names, trained_at, "
|
|
264
|
+
"model_version FROM learning_model_state "
|
|
265
|
+
"WHERE profile_id = ? AND is_active = 1 LIMIT 1",
|
|
266
|
+
(profile_id,),
|
|
267
|
+
).fetchone()
|
|
268
|
+
if row is None:
|
|
269
|
+
return None
|
|
270
|
+
return {
|
|
271
|
+
"state_bytes": bytes(row["state_bytes"]),
|
|
272
|
+
"bytes_sha256": row["bytes_sha256"],
|
|
273
|
+
"feature_names": row["feature_names"],
|
|
274
|
+
"trained_at": row["trained_at"],
|
|
275
|
+
"model_version": row["model_version"],
|
|
276
|
+
}
|
|
277
|
+
finally:
|
|
278
|
+
connection.close()
|
|
279
|
+
|
|
280
|
+
|
|
215
281
|
def _resolve_ranking_mode(env: "dict[str, str] | os._Environ[str]") -> str:
|
|
216
282
|
"""Map the ``SLM_RANKING`` env var to a canonical mode.
|
|
217
283
|
|
|
@@ -239,6 +305,7 @@ def apply_ranking(
|
|
|
239
305
|
*,
|
|
240
306
|
config: Any = None,
|
|
241
307
|
pipeline_version: str = "v2-ensemble",
|
|
308
|
+
record_signals: bool = False,
|
|
242
309
|
) -> "RecallResponse":
|
|
243
310
|
"""Run the ranking pipeline at the requested version.
|
|
244
311
|
|
|
@@ -273,6 +340,7 @@ def apply_ranking(
|
|
|
273
340
|
try:
|
|
274
341
|
response = apply_v2_bandit_ensemble(
|
|
275
342
|
response, query, profile_id, query_id,
|
|
343
|
+
record_signals=record_signals,
|
|
276
344
|
)
|
|
277
345
|
except Exception as exc: # pragma: no cover — defensive
|
|
278
346
|
logger.debug("apply_ranking ensemble step skipped: %s", exc)
|
|
@@ -297,14 +365,16 @@ def apply_adaptive_ranking(
|
|
|
297
365
|
Phase 3 (200+): LightGBM ML-based reranking.
|
|
298
366
|
"""
|
|
299
367
|
from superlocalmemory.infra.data_root import state_path
|
|
300
|
-
from superlocalmemory.learning.feedback import FeedbackCollector
|
|
301
|
-
|
|
302
368
|
learning_db = state_path("learning.db")
|
|
303
369
|
if not learning_db.exists():
|
|
304
370
|
return response
|
|
305
371
|
|
|
306
|
-
|
|
307
|
-
|
|
372
|
+
try:
|
|
373
|
+
signal_count = _ReadOnlyLearningView(learning_db).count_feedback(pid)
|
|
374
|
+
except sqlite3.Error:
|
|
375
|
+
# A pre-learning database may not have this optional table yet.
|
|
376
|
+
# Recall remains a query and cannot create it on demand.
|
|
377
|
+
return response
|
|
308
378
|
|
|
309
379
|
if signal_count < 50:
|
|
310
380
|
return response # Phase 1: no change
|
|
@@ -388,7 +458,6 @@ def apply_v2_adaptive_ranking(
|
|
|
388
458
|
from pathlib import Path as _P
|
|
389
459
|
|
|
390
460
|
from superlocalmemory.infra.data_root import state_path
|
|
391
|
-
from superlocalmemory.learning.database import LearningDatabase
|
|
392
461
|
from superlocalmemory.learning.model_cache import load_active
|
|
393
462
|
from superlocalmemory.learning.ranker import AdaptiveRanker
|
|
394
463
|
|
|
@@ -397,7 +466,7 @@ def apply_v2_adaptive_ranking(
|
|
|
397
466
|
if not db_path.exists():
|
|
398
467
|
return response
|
|
399
468
|
|
|
400
|
-
db =
|
|
469
|
+
db = _ReadOnlyLearningView(db_path)
|
|
401
470
|
signal_count = db.count_signals(profile_id)
|
|
402
471
|
active = load_active(db, profile_id)
|
|
403
472
|
|
|
@@ -487,6 +556,7 @@ def apply_v2_bandit_ensemble(
|
|
|
487
556
|
query_id: str,
|
|
488
557
|
*,
|
|
489
558
|
learning_db_path: Any = None,
|
|
559
|
+
record_signals: bool = False,
|
|
490
560
|
) -> RecallResponse:
|
|
491
561
|
"""Apply contextual bandit + optional LGBM ensemble rerank. Safe on error."""
|
|
492
562
|
import os as _os
|
|
@@ -521,12 +591,14 @@ def apply_v2_bandit_ensemble(
|
|
|
521
591
|
entity_count = 0
|
|
522
592
|
# Use query_context hints if available on the engine — cheap fallback.
|
|
523
593
|
bandit = ContextualBandit(db_path, profile_id)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
query_id
|
|
594
|
+
context = {
|
|
595
|
+
"query_type": response.query_type,
|
|
596
|
+
"entity_count": entity_count,
|
|
597
|
+
}
|
|
598
|
+
choice = (
|
|
599
|
+
bandit.choose(context, query_id)
|
|
600
|
+
if record_signals
|
|
601
|
+
else bandit.choose_readonly(context)
|
|
530
602
|
)
|
|
531
603
|
|
|
532
604
|
# --- 2. apply channel weights -------------------------------------
|
|
@@ -536,9 +608,8 @@ def apply_v2_bandit_ensemble(
|
|
|
536
608
|
active_model = None
|
|
537
609
|
signal_count = 0
|
|
538
610
|
try:
|
|
539
|
-
from superlocalmemory.learning.database import LearningDatabase
|
|
540
611
|
from superlocalmemory.learning.model_cache import load_active
|
|
541
|
-
db =
|
|
612
|
+
db = _ReadOnlyLearningView(db_path)
|
|
542
613
|
signal_count = db.count_signals(profile_id)
|
|
543
614
|
active_model = load_active(db, profile_id)
|
|
544
615
|
except Exception as exc:
|
|
@@ -561,28 +632,32 @@ def apply_v2_bandit_ensemble(
|
|
|
561
632
|
logger.debug("v2 bandit ensemble_rerank skipped: %s", exc)
|
|
562
633
|
final_results = weighted
|
|
563
634
|
|
|
564
|
-
#
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
635
|
+
# Recall is a query. Implicit learning signals are deliberately
|
|
636
|
+
# disabled on this path: even a non-blocking enqueue eventually writes
|
|
637
|
+
# canonical/learning state and turns dashboard polling into contention.
|
|
638
|
+
# An explicit feedback command owns durable learning signals instead.
|
|
639
|
+
if record_signals:
|
|
640
|
+
try:
|
|
641
|
+
top20 = final_results[:20]
|
|
642
|
+
candidates = tuple(
|
|
643
|
+
SignalCandidate(
|
|
644
|
+
fact_id=r.fact.fact_id,
|
|
645
|
+
channel_scores=dict(r.channel_scores or {}),
|
|
646
|
+
cross_encoder_score=None,
|
|
647
|
+
result_dict={"fact_id": r.fact.fact_id,
|
|
648
|
+
"score": r.score},
|
|
649
|
+
)
|
|
650
|
+
for r in top20
|
|
574
651
|
)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
except Exception as exc:
|
|
585
|
-
logger.debug("v2 bandit signal enqueue skipped: %s", exc)
|
|
652
|
+
enqueue(SignalBatch(
|
|
653
|
+
profile_id=profile_id,
|
|
654
|
+
query_id=query_id,
|
|
655
|
+
query_text=query,
|
|
656
|
+
candidates=candidates,
|
|
657
|
+
query_context=query_context,
|
|
658
|
+
))
|
|
659
|
+
except Exception as exc:
|
|
660
|
+
logger.debug("v2 bandit signal enqueue skipped: %s", exc)
|
|
586
661
|
|
|
587
662
|
return RecallResponse(
|
|
588
663
|
query=response.query,
|
|
@@ -665,15 +740,6 @@ def run_recall(
|
|
|
665
740
|
agent hot path skips the internal round and delegates refinement to the
|
|
666
741
|
calling LLM. ``fast=False`` forces the internal agentic round.
|
|
667
742
|
"""
|
|
668
|
-
# Pre-operation hooks
|
|
669
|
-
hook_ctx = {
|
|
670
|
-
"operation": "recall",
|
|
671
|
-
"agent_id": agent_id,
|
|
672
|
-
"profile_id": profile_id,
|
|
673
|
-
"query_preview": query[:100],
|
|
674
|
-
}
|
|
675
|
-
hooks.run_pre("recall", hook_ctx)
|
|
676
|
-
|
|
677
743
|
m = mode or config.mode
|
|
678
744
|
|
|
679
745
|
# v3.8.2: resolve the client-driven-agentic default when a caller left
|
|
@@ -772,30 +838,6 @@ def run_recall(
|
|
|
772
838
|
logger.debug("Agentic sufficiency skipped: %s", exc)
|
|
773
839
|
|
|
774
840
|
_mark("agentic")
|
|
775
|
-
# V3.2: Log access for recalled facts (Phase 1)
|
|
776
|
-
if access_log and response.results:
|
|
777
|
-
try:
|
|
778
|
-
fact_ids = [r.fact.fact_id for r in response.results]
|
|
779
|
-
access_log.store_access_batch(
|
|
780
|
-
fact_ids=fact_ids,
|
|
781
|
-
profile_id=profile_id,
|
|
782
|
-
access_type="recall",
|
|
783
|
-
)
|
|
784
|
-
except Exception as exc:
|
|
785
|
-
logger.debug("Access log batch store failed: %s", exc)
|
|
786
|
-
|
|
787
|
-
# Query telemetry is permitted, but result-derived entities are not fed
|
|
788
|
-
# back as preferences. Retrieval exposure is not positive feedback.
|
|
789
|
-
try:
|
|
790
|
-
_get_behavioral_tracker(db).record_query(
|
|
791
|
-
query=query,
|
|
792
|
-
query_type=response.query_type,
|
|
793
|
-
entities=[],
|
|
794
|
-
profile_id=profile_id,
|
|
795
|
-
)
|
|
796
|
-
except Exception as exc:
|
|
797
|
-
logger.debug("Behavioral tracking: %s", exc)
|
|
798
|
-
|
|
799
841
|
# S8-ARC-04 (v3.4.22): unified ranking entry point. Single env-var
|
|
800
842
|
# (SLM_RANKING=off|v1|v2|v2-ensemble) controls the pipeline. Legacy
|
|
801
843
|
# SLM_V2_PIPELINE_DISABLED + SLM_BANDIT_DISABLED still honoured for
|
|
@@ -807,7 +849,7 @@ def run_recall(
|
|
|
807
849
|
mode = _resolve_ranking_mode(_os.environ)
|
|
808
850
|
response = apply_ranking(
|
|
809
851
|
response, query, profile_id, query_id,
|
|
810
|
-
config=config, pipeline_version=mode,
|
|
852
|
+
config=config, pipeline_version=mode, record_signals=False,
|
|
811
853
|
)
|
|
812
854
|
except Exception as exc:
|
|
813
855
|
logger.debug("Ranking pipeline skipped: %s", exc)
|
|
@@ -817,11 +859,6 @@ def run_recall(
|
|
|
817
859
|
# mutation here. Those state transitions require a separately authenticated
|
|
818
860
|
# positive/negative outcome; merely returning a result is an exposure.
|
|
819
861
|
|
|
820
|
-
# Post-operation hooks (audit, trust signal, learning)
|
|
821
|
-
hook_ctx["result_count"] = len(response.results)
|
|
822
|
-
hook_ctx["query_type"] = response.query_type
|
|
823
|
-
hooks.run_post("recall", hook_ctx)
|
|
824
|
-
|
|
825
862
|
from superlocalmemory.core.score_contract import finalize_score_contract
|
|
826
863
|
finalize_score_contract(response)
|
|
827
864
|
|
|
@@ -110,7 +110,11 @@ class AgentRegistry:
|
|
|
110
110
|
try:
|
|
111
111
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
112
112
|
self._agents = data.get("agents", {})
|
|
113
|
-
|
|
113
|
+
# D-01: write_locks from a prior process are meaningless after a
|
|
114
|
+
# process boundary — the agents that held them are dead. Starting
|
|
115
|
+
# with no inherited locks prevents mcp_client (and any other agent)
|
|
116
|
+
# from being permanently blocked on every daemon restart.
|
|
117
|
+
self._write_locks = {}
|
|
114
118
|
except (json.JSONDecodeError, KeyError):
|
|
115
119
|
logger.warning("Corrupt registry file at %s — starting fresh.", self._path)
|
|
116
120
|
self._agents = {}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Journal-first service boundary for a durable, immediately-queryable remember."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from superlocalmemory.storage.admission_journal import (
|
|
15
|
+
Actor,
|
|
16
|
+
AdmissionJournal,
|
|
17
|
+
AdmissionJournalUnavailable,
|
|
18
|
+
PreparedAdmission,
|
|
19
|
+
RememberRequest,
|
|
20
|
+
TerminalAdmissionError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AdmissionRejected(RuntimeError):
|
|
25
|
+
"""Canonical admission was not committed; the journal records its outcome."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, error_code: str, *, retryable: bool = False) -> None:
|
|
28
|
+
super().__init__(error_code)
|
|
29
|
+
self.error_code = error_code
|
|
30
|
+
self.retryable = retryable
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class RememberReceipt:
|
|
35
|
+
"""Canonical receipt returned only after admission is queryable."""
|
|
36
|
+
|
|
37
|
+
payload: dict[str, Any]
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_mapping(cls, receipt: Mapping[str, Any]) -> RememberReceipt:
|
|
41
|
+
return cls(payload=dict(receipt))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class RememberAdmissionCommand:
|
|
46
|
+
"""Typed foreground input for a coordinator's bounded canonical transaction."""
|
|
47
|
+
|
|
48
|
+
journal_id: str
|
|
49
|
+
request_hash: str
|
|
50
|
+
request: RememberRequest
|
|
51
|
+
profile_id: str
|
|
52
|
+
idempotency_key: str
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_prepared(
|
|
56
|
+
cls, prepared: PreparedAdmission, request: RememberRequest
|
|
57
|
+
) -> RememberAdmissionCommand:
|
|
58
|
+
return cls(
|
|
59
|
+
journal_id=prepared.journal_id,
|
|
60
|
+
request_hash=prepared.request_hash,
|
|
61
|
+
request=request,
|
|
62
|
+
profile_id=prepared.profile_id,
|
|
63
|
+
idempotency_key=prepared.idempotency_key,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RememberCoordinator(Protocol):
|
|
68
|
+
def submit(self, command: RememberAdmissionCommand, *, wait_ms: int) -> Any: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RememberService:
|
|
72
|
+
"""Apply the journaling pattern without doing enrichment inside admission."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, journal: AdmissionJournal, coordinator: RememberCoordinator) -> None:
|
|
75
|
+
self._journal = journal
|
|
76
|
+
self._coordinator = coordinator
|
|
77
|
+
|
|
78
|
+
def remember(
|
|
79
|
+
self, request: RememberRequest, actor: Actor, *, deadline_ms: int
|
|
80
|
+
) -> RememberReceipt:
|
|
81
|
+
if deadline_ms <= 0:
|
|
82
|
+
raise ValueError("deadline_ms must be greater than zero")
|
|
83
|
+
deadline = time.monotonic() + deadline_ms / 1_000
|
|
84
|
+
prepared = self._journal.prepare(
|
|
85
|
+
request,
|
|
86
|
+
actor,
|
|
87
|
+
deadline=deadline,
|
|
88
|
+
)
|
|
89
|
+
if prepared.original_receipt is not None:
|
|
90
|
+
return RememberReceipt.from_mapping(prepared.original_receipt)
|
|
91
|
+
if prepared.state == "rejected":
|
|
92
|
+
raise AdmissionRejected(prepared.error_code or "COMMAND_REJECTED")
|
|
93
|
+
|
|
94
|
+
command_request = self._journal.request_for(
|
|
95
|
+
prepared,
|
|
96
|
+
deadline=deadline,
|
|
97
|
+
)
|
|
98
|
+
dispatched = self._journal.mark_dispatched(
|
|
99
|
+
prepared.journal_id,
|
|
100
|
+
deadline=deadline,
|
|
101
|
+
)
|
|
102
|
+
if dispatched.original_receipt is not None:
|
|
103
|
+
return RememberReceipt.from_mapping(dispatched.original_receipt)
|
|
104
|
+
try:
|
|
105
|
+
result = self._coordinator.submit(
|
|
106
|
+
RememberAdmissionCommand.from_prepared(prepared, command_request),
|
|
107
|
+
wait_ms=_remaining_milliseconds(deadline),
|
|
108
|
+
)
|
|
109
|
+
except TerminalAdmissionError as exc:
|
|
110
|
+
self._journal.mark_rejected(
|
|
111
|
+
prepared.journal_id,
|
|
112
|
+
exc.error_code,
|
|
113
|
+
deadline=deadline,
|
|
114
|
+
)
|
|
115
|
+
raise AdmissionRejected(exc.error_code) from exc
|
|
116
|
+
state = _result_value(result, "state")
|
|
117
|
+
receipt = _result_value(result, "receipt") or {}
|
|
118
|
+
if state in {"committed", "duplicate"}:
|
|
119
|
+
if not isinstance(receipt, Mapping):
|
|
120
|
+
raise AdmissionRejected("COMMAND_REJECTED: canonical result had no receipt")
|
|
121
|
+
committed = self._journal.mark_committed(
|
|
122
|
+
prepared.journal_id,
|
|
123
|
+
receipt,
|
|
124
|
+
deadline=deadline,
|
|
125
|
+
)
|
|
126
|
+
return RememberReceipt.from_mapping(committed.original_receipt or receipt)
|
|
127
|
+
|
|
128
|
+
error_code = str(_result_value(result, "error_code") or "COMMAND_REJECTED")
|
|
129
|
+
if state == "rejected":
|
|
130
|
+
self._journal.mark_rejected(
|
|
131
|
+
prepared.journal_id,
|
|
132
|
+
error_code,
|
|
133
|
+
deadline=deadline,
|
|
134
|
+
)
|
|
135
|
+
raise AdmissionRejected(error_code, retryable=state != "rejected")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _result_value(result: Any, key: str) -> Any:
|
|
139
|
+
if isinstance(result, Mapping):
|
|
140
|
+
return result.get(key)
|
|
141
|
+
return getattr(result, key, None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _remaining_seconds(deadline: float) -> float:
|
|
145
|
+
remaining = deadline - time.monotonic()
|
|
146
|
+
if remaining <= 0:
|
|
147
|
+
raise AdmissionJournalUnavailable("remember admission deadline expired")
|
|
148
|
+
return remaining
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _remaining_milliseconds(deadline: float) -> int:
|
|
152
|
+
return max(1, math.ceil(_remaining_seconds(deadline) * 1_000))
|