superlocalmemory 4.0.4 → 4.0.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 +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- 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 +3 -2
- 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 +2 -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 +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import sqlite3
|
|
10
|
+
import uuid
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
@@ -76,6 +77,30 @@ def _invalidate_context_cache_for_fact(
|
|
|
76
77
|
pass
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def purge_profile_context_cache(engine: Any, profile_id: str) -> None:
|
|
81
|
+
"""Best-effort full-profile cache purge after a truth lifecycle change.
|
|
82
|
+
|
|
83
|
+
Apply and rollback can flip which of two immutable facts is current. A
|
|
84
|
+
fact-ID substring delete is therefore insufficient: any cached prose for
|
|
85
|
+
the profile could contain the old current truth. Read-side admission
|
|
86
|
+
remains the authoritative backstop if this post-commit cleanup is busy.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
db_parent = Path(engine._db.db_path).parent
|
|
90
|
+
except AttributeError:
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
from superlocalmemory.core.context_cache import purge_profile_from_cache_db
|
|
94
|
+
|
|
95
|
+
for cache_path in (
|
|
96
|
+
db_parent / "context-cache" / "active_brain_cache.db",
|
|
97
|
+
db_parent / "active_brain_cache.db",
|
|
98
|
+
):
|
|
99
|
+
purge_profile_from_cache_db(cache_path, profile_id)
|
|
100
|
+
except Exception:
|
|
101
|
+
logger.warning("Profile context cache purge failed after correction transition")
|
|
102
|
+
|
|
103
|
+
|
|
79
104
|
def _sync_vector_ann(
|
|
80
105
|
retrieval: Any,
|
|
81
106
|
fact_id: str,
|
|
@@ -173,6 +198,83 @@ def _converge_update_projections(
|
|
|
173
198
|
logger.warning("Derived projection update sync failed for %s", fact_id[:16])
|
|
174
199
|
|
|
175
200
|
|
|
201
|
+
def _project_correction_successor(
|
|
202
|
+
engine: Any,
|
|
203
|
+
fact_id: str,
|
|
204
|
+
content: str,
|
|
205
|
+
profile_id: str,
|
|
206
|
+
embedding: list[float] | None,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Project a new successor without touching predecessor projections."""
|
|
209
|
+
retrieval = getattr(engine, "_retrieval_engine", None)
|
|
210
|
+
bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
|
|
211
|
+
if bm25 is not None:
|
|
212
|
+
try:
|
|
213
|
+
bm25.add(fact_id, content, profile_id)
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
logger.warning("BM25 successor projection failed for %s: %s", fact_id[:16], exc)
|
|
216
|
+
if retrieval is not None:
|
|
217
|
+
_sync_vector_ann(retrieval, fact_id, profile_id, embedding, operation="update")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _correction_ledger_available(engine: Any) -> bool:
|
|
221
|
+
try:
|
|
222
|
+
rows = engine._db.execute(
|
|
223
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
|
|
224
|
+
)
|
|
225
|
+
return bool(rows)
|
|
226
|
+
except Exception:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _propose_correction_case(
|
|
231
|
+
engine: Any,
|
|
232
|
+
*,
|
|
233
|
+
profile_id: str,
|
|
234
|
+
scope: str,
|
|
235
|
+
predecessor_fact_id: str,
|
|
236
|
+
successor_fact_id: str,
|
|
237
|
+
trusted_actor_id: str,
|
|
238
|
+
idempotency_key: str,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""Record a review-required M042 proposal after successor persistence.
|
|
241
|
+
|
|
242
|
+
The ledger deliberately lives on a separate short transaction from the
|
|
243
|
+
canonical writer. A retry with the same deterministic identifiers is
|
|
244
|
+
idempotent; the predecessor remains untouched if the ledger is unavailable.
|
|
245
|
+
"""
|
|
246
|
+
from superlocalmemory.storage.correction_cases import (
|
|
247
|
+
CorrectionActor,
|
|
248
|
+
CorrectionCaseStore,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
actor = CorrectionActor(
|
|
252
|
+
actor_id=trusted_actor_id,
|
|
253
|
+
actor_kind="host_authenticated",
|
|
254
|
+
trust_tier="trusted",
|
|
255
|
+
)
|
|
256
|
+
case_id = uuid.uuid5(
|
|
257
|
+
uuid.NAMESPACE_URL,
|
|
258
|
+
f"slm-correction:{profile_id}:{predecessor_fact_id}:{successor_fact_id}",
|
|
259
|
+
).hex
|
|
260
|
+
store = CorrectionCaseStore(
|
|
261
|
+
engine._db.db_path,
|
|
262
|
+
is_profile_active=lambda candidate: candidate == profile_id,
|
|
263
|
+
is_actor_trusted=lambda candidate: candidate == actor,
|
|
264
|
+
)
|
|
265
|
+
case = store.propose(
|
|
266
|
+
case_id=case_id,
|
|
267
|
+
profile_id=profile_id,
|
|
268
|
+
scope=scope,
|
|
269
|
+
predecessor_fact_id=predecessor_fact_id,
|
|
270
|
+
successor_fact_id=successor_fact_id,
|
|
271
|
+
reason_code="direct_content_correction",
|
|
272
|
+
actor=actor,
|
|
273
|
+
idempotency_key=idempotency_key,
|
|
274
|
+
)
|
|
275
|
+
return {"case_id": case.case_id, "status": case.status, "version": case.version}
|
|
276
|
+
|
|
277
|
+
|
|
176
278
|
def _purge_delete_projections(
|
|
177
279
|
engine: Any,
|
|
178
280
|
fact_id: str,
|
|
@@ -498,7 +600,7 @@ def update_fact_authorized(
|
|
|
498
600
|
canonical_runtime: Any | None = None,
|
|
499
601
|
idempotency_key: str | None = None,
|
|
500
602
|
) -> dict[str, Any]:
|
|
501
|
-
"""
|
|
603
|
+
"""Create a review-required correction successor without rewriting history."""
|
|
502
604
|
if not content or not content.strip():
|
|
503
605
|
return {"ok": False, "error": "content cannot be empty"}
|
|
504
606
|
content = content.strip()
|
|
@@ -517,42 +619,70 @@ def update_fact_authorized(
|
|
|
517
619
|
)
|
|
518
620
|
if not rows:
|
|
519
621
|
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
520
|
-
|
|
521
|
-
|
|
622
|
+
source = engine._db.get_fact(fact_id)
|
|
623
|
+
if source is None:
|
|
624
|
+
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
625
|
+
if source.content == content:
|
|
626
|
+
return {"ok": True, "fact_id": fact_id, "content": content, "unchanged": True}
|
|
627
|
+
if not _correction_ledger_available(engine):
|
|
628
|
+
return {"ok": False, "error": "review-gated correction ledger is unavailable"}
|
|
629
|
+
|
|
630
|
+
request_key = idempotency_key or uuid.uuid4().hex
|
|
631
|
+
successor_fact_id = uuid.uuid5(
|
|
632
|
+
uuid.NAMESPACE_URL,
|
|
633
|
+
f"slm-correction-successor:{profile_id}:{fact_id}:{request_key}",
|
|
634
|
+
).hex[:16]
|
|
522
635
|
embedding: list[float] | None = None
|
|
636
|
+
fisher_mean: list[float] | None = None
|
|
637
|
+
fisher_variance: list[float] | None = None
|
|
523
638
|
if engine._embedder:
|
|
524
639
|
try:
|
|
525
640
|
embedding = engine._embedder.embed(content)
|
|
526
641
|
if embedding:
|
|
527
|
-
updates["embedding"] = embedding
|
|
528
642
|
fisher_mean, fisher_variance = (
|
|
529
643
|
engine._embedder.compute_fisher_params(embedding)
|
|
530
644
|
)
|
|
531
|
-
updates["fisher_mean"] = fisher_mean
|
|
532
|
-
updates["fisher_variance"] = fisher_variance
|
|
533
645
|
except Exception as exc:
|
|
534
|
-
logger.warning("
|
|
535
|
-
if canonical_runtime is
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
646
|
+
logger.warning("Correction successor embedding refresh failed: %s", exc)
|
|
647
|
+
if canonical_runtime is None:
|
|
648
|
+
return {
|
|
649
|
+
"ok": False,
|
|
650
|
+
"retryable": True,
|
|
651
|
+
"error": "canonical correction writer is temporarily unavailable",
|
|
652
|
+
}
|
|
653
|
+
result = dict(canonical_runtime.create_correction_successor(
|
|
654
|
+
profile_id,
|
|
655
|
+
fact_id,
|
|
656
|
+
successor_fact_id,
|
|
657
|
+
content,
|
|
658
|
+
embedding=embedding,
|
|
659
|
+
fisher_mean=fisher_mean,
|
|
660
|
+
fisher_variance=fisher_variance,
|
|
661
|
+
trusted_actor_id=trusted_actor_id,
|
|
662
|
+
idempotency_key=request_key,
|
|
663
|
+
))
|
|
664
|
+
if not result.get("ok"):
|
|
665
|
+
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
666
|
+
successor_fact_id = str(result["successor_fact_id"])
|
|
549
667
|
|
|
550
668
|
engine._hooks.run_post("update", context)
|
|
551
669
|
logger.info(
|
|
552
|
-
"
|
|
553
|
-
fact_id[:16], trusted_actor_id, source_agent_id,
|
|
670
|
+
"CORRECTION_PROPOSED predecessor=%s successor=%s actor=%s source_agent=%s",
|
|
671
|
+
fact_id[:16], successor_fact_id[:16], trusted_actor_id, source_agent_id,
|
|
554
672
|
)
|
|
555
|
-
return {
|
|
673
|
+
return {
|
|
674
|
+
"ok": True,
|
|
675
|
+
"fact_id": successor_fact_id,
|
|
676
|
+
"content": content,
|
|
677
|
+
"predecessor_fact_id": fact_id,
|
|
678
|
+
"successor_fact_id": successor_fact_id,
|
|
679
|
+
"correction_case": {
|
|
680
|
+
"case_id": result["case_id"],
|
|
681
|
+
"status": result["status"],
|
|
682
|
+
"version": result["version"],
|
|
683
|
+
},
|
|
684
|
+
"review_required": True,
|
|
685
|
+
}
|
|
556
686
|
|
|
557
687
|
|
|
558
|
-
__all__ = ["delete_fact_authorized", "update_fact_authorized"]
|
|
688
|
+
__all__ = ["delete_fact_authorized", "purge_profile_context_cache", "update_fact_authorized"]
|
|
@@ -327,20 +327,16 @@ class _ReadOnlyLearningView:
|
|
|
327
327
|
def _resolve_ranking_mode(env: "dict[str, str] | os._Environ[str]") -> str:
|
|
328
328
|
"""Map the ``SLM_RANKING`` env var to a canonical mode.
|
|
329
329
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
330
|
+
``SLM_RANKING`` is an explicit operator policy. In 4.0.5 the absence of
|
|
331
|
+
that policy is deliberately ``off``: old learning signals must not begin
|
|
332
|
+
altering recall merely because a user upgrades. The legacy disable flags
|
|
333
|
+
remain harmless compatibility inputs, but cannot implicitly enable a
|
|
334
|
+
ranking mode.
|
|
333
335
|
"""
|
|
334
336
|
raw = (env.get("SLM_RANKING", "") or "").strip().lower()
|
|
335
337
|
if raw in _RANKING_MODES:
|
|
336
338
|
return raw
|
|
337
|
-
|
|
338
|
-
# v2 disabled → fall back to v1 adaptive only.
|
|
339
|
-
return "v1"
|
|
340
|
-
if (env.get("SLM_BANDIT_DISABLED", "0") or "0").strip() == "1":
|
|
341
|
-
# Bandit disabled → v2 without ensemble.
|
|
342
|
-
return "v2"
|
|
343
|
-
return "v2-ensemble"
|
|
339
|
+
return "off"
|
|
344
340
|
|
|
345
341
|
|
|
346
342
|
def apply_ranking(
|
|
@@ -238,18 +238,39 @@ def _handle_update_memory(
|
|
|
238
238
|
content: str,
|
|
239
239
|
source_agent_id: str = "system",
|
|
240
240
|
) -> dict:
|
|
241
|
-
"""Update a fact after capability-derived authorization.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
241
|
+
"""Update a fact after capability-derived authorization.
|
|
242
|
+
|
|
243
|
+
CORRECTIONS CANNOT BE PERFORMED FROM THIS WORKER, AND THAT IS BY DESIGN.
|
|
244
|
+
|
|
245
|
+
Since corrections became a review-gated lifecycle (M042) rather than an
|
|
246
|
+
in-place edit, update_fact_authorized() requires a canonical correction
|
|
247
|
+
writer. That writer is the daemon's single-writer mutation boundary
|
|
248
|
+
(CanonicalRememberRuntime, owned by unified_daemon and handed to the HTTP
|
|
249
|
+
route). A worker subprocess cannot own it: CanonicalRememberRuntime.ready
|
|
250
|
+
requires a live worker of its own, so constructing one here would nest
|
|
251
|
+
worker processes and hand a second writer the ownership context that is
|
|
252
|
+
supposed to be exclusive.
|
|
253
|
+
|
|
254
|
+
Previously this called through anyway, and mutations.py answered
|
|
255
|
+
"canonical correction writer is temporarily unavailable" with
|
|
256
|
+
retryable=True. Nothing about it was temporary — with the daemon down that
|
|
257
|
+
path can NEVER succeed, so a caller could retry forever. This is the
|
|
258
|
+
honest, non-retryable answer with the actual remedy: the daemon-backed
|
|
259
|
+
route (used automatically whenever the daemon is running) does support
|
|
260
|
+
corrections.
|
|
261
|
+
"""
|
|
262
|
+
return {
|
|
263
|
+
"ok": False,
|
|
264
|
+
"retryable": False,
|
|
265
|
+
"error": (
|
|
266
|
+
"Corrections are review-gated and require the SuperLocalMemory "
|
|
267
|
+
"daemon, which owns the single correction writer. Start it with "
|
|
268
|
+
"`slm serve start` and retry — updates route through the daemon "
|
|
269
|
+
"automatically when it is running."
|
|
270
|
+
),
|
|
271
|
+
"remedy": "slm serve start",
|
|
272
|
+
"fact_id": fact_id,
|
|
273
|
+
}
|
|
253
274
|
|
|
254
275
|
|
|
255
276
|
def _handle_summarize(texts: list[str], mode: str) -> dict:
|
|
@@ -105,7 +105,7 @@ class DaemonAlreadyServing(RuntimeError):
|
|
|
105
105
|
"""
|
|
106
106
|
|
|
107
107
|
|
|
108
|
-
class CanonicalMutationConflict(ValueError):
|
|
108
|
+
class CanonicalMutationConflict(WriteCoordinatorError, ValueError):
|
|
109
109
|
"""A mutation retry key was reused for different immutable input."""
|
|
110
110
|
|
|
111
111
|
|
|
@@ -337,6 +337,12 @@ class CanonicalRememberRuntime:
|
|
|
337
337
|
self.coordinator.register_handler(CommandKind.ADMISSION, self._handle_admission)
|
|
338
338
|
self.coordinator.register_handler(CommandKind.DELETE_FACT, self._handle_mutation)
|
|
339
339
|
self.coordinator.register_handler(CommandKind.UPDATE_FACT, self._handle_mutation)
|
|
340
|
+
self.coordinator.register_handler(CommandKind.PROPOSE_CORRECTION, self._handle_mutation)
|
|
341
|
+
self.coordinator.register_handler(CommandKind.APPLY_CORRECTION, self._handle_mutation)
|
|
342
|
+
self.coordinator.register_handler(CommandKind.REJECT_CORRECTION, self._handle_mutation)
|
|
343
|
+
self.coordinator.register_handler(
|
|
344
|
+
CommandKind.ROLLBACK_CORRECTION, self._handle_mutation,
|
|
345
|
+
)
|
|
340
346
|
self.coordinator.register_handler(CommandKind.ARCHIVE_FACT, self._handle_mutation)
|
|
341
347
|
self.coordinator.register_handler(CommandKind.MERGE_FACT, self._handle_mutation)
|
|
342
348
|
self.coordinator.register_handler(CommandKind.SET_FACT_SCOPE, self._handle_mutation)
|
|
@@ -484,6 +490,66 @@ class CanonicalRememberRuntime:
|
|
|
484
490
|
idempotency_key=idempotency_key,
|
|
485
491
|
)
|
|
486
492
|
|
|
493
|
+
def create_correction_successor(
|
|
494
|
+
self,
|
|
495
|
+
profile_id: str,
|
|
496
|
+
fact_id: str,
|
|
497
|
+
successor_fact_id: str,
|
|
498
|
+
content: str,
|
|
499
|
+
*,
|
|
500
|
+
embedding: list[float] | None = None,
|
|
501
|
+
fisher_mean: list[float] | None = None,
|
|
502
|
+
fisher_variance: list[float] | None = None,
|
|
503
|
+
trusted_actor_id: str = "canonical-runtime",
|
|
504
|
+
idempotency_key: str | None = None,
|
|
505
|
+
) -> Mapping[str, Any]:
|
|
506
|
+
"""Atomically create an immutable, review-required successor case."""
|
|
507
|
+
return self._submit_mutation(
|
|
508
|
+
CommandKind.PROPOSE_CORRECTION,
|
|
509
|
+
profile_id,
|
|
510
|
+
{
|
|
511
|
+
"fact_id": fact_id,
|
|
512
|
+
"successor_fact_id": successor_fact_id,
|
|
513
|
+
"content": content,
|
|
514
|
+
"embedding": _json_roundtrip({"value": embedding})["value"],
|
|
515
|
+
"fisher_mean": _json_roundtrip({"value": fisher_mean})["value"],
|
|
516
|
+
"fisher_variance": _json_roundtrip({"value": fisher_variance})["value"],
|
|
517
|
+
"trusted_actor_id": trusted_actor_id,
|
|
518
|
+
},
|
|
519
|
+
idempotency_key=idempotency_key,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def transition_correction(
|
|
523
|
+
self,
|
|
524
|
+
profile_id: str,
|
|
525
|
+
case_id: str,
|
|
526
|
+
*,
|
|
527
|
+
action: str,
|
|
528
|
+
expected_version: int,
|
|
529
|
+
actor_id: str,
|
|
530
|
+
event_valid_until: str | None = None,
|
|
531
|
+
idempotency_key: str | None = None,
|
|
532
|
+
) -> Mapping[str, Any]:
|
|
533
|
+
"""Apply, reject, or roll back one server-authorized correction case."""
|
|
534
|
+
command = {
|
|
535
|
+
"apply": CommandKind.APPLY_CORRECTION,
|
|
536
|
+
"reject": CommandKind.REJECT_CORRECTION,
|
|
537
|
+
"rollback": CommandKind.ROLLBACK_CORRECTION,
|
|
538
|
+
}.get(action)
|
|
539
|
+
if command is None:
|
|
540
|
+
raise ValueError("correction action must be apply, reject, or rollback")
|
|
541
|
+
return self._submit_mutation(
|
|
542
|
+
command,
|
|
543
|
+
profile_id,
|
|
544
|
+
{
|
|
545
|
+
"case_id": case_id,
|
|
546
|
+
"expected_version": expected_version,
|
|
547
|
+
"trusted_actor_id": actor_id,
|
|
548
|
+
"event_valid_until": event_valid_until,
|
|
549
|
+
},
|
|
550
|
+
idempotency_key=idempotency_key,
|
|
551
|
+
)
|
|
552
|
+
|
|
487
553
|
def archive_fact(
|
|
488
554
|
self, profile_id: str, fact_id: str, *, idempotency_key: str | None = None,
|
|
489
555
|
) -> Mapping[str, Any]:
|
|
@@ -569,6 +635,10 @@ class CanonicalRememberRuntime:
|
|
|
569
635
|
)
|
|
570
636
|
try:
|
|
571
637
|
return dict(self.coordinator.submit(command, timeout=2.0).receipt)
|
|
638
|
+
except CanonicalMutationConflict:
|
|
639
|
+
# A deterministic lifecycle conflict is not a writer outage. Let
|
|
640
|
+
# HTTP/CLI/MCP report the actionable 409/validation result.
|
|
641
|
+
raise
|
|
572
642
|
except CommandConflictError as exc:
|
|
573
643
|
raise CanonicalMutationConflict(
|
|
574
644
|
"idempotency key belongs to a different mutation request"
|
|
@@ -686,7 +756,9 @@ class CanonicalRememberRuntime:
|
|
|
686
756
|
if profile_id != self._profile_id:
|
|
687
757
|
raise ValueError("mutation command targets a different profile")
|
|
688
758
|
with self._db._bind_coordinator_connection(conn, capability):
|
|
689
|
-
receipt = _execute_mutation(
|
|
759
|
+
receipt = _execute_mutation(
|
|
760
|
+
self._db, command.kind, profile_id, payload, connection=conn
|
|
761
|
+
)
|
|
690
762
|
receipt["operation_id"] = f"mutation:{command.kind.value}:{command.command_id}"
|
|
691
763
|
return WriteResult.from_receipt(command, receipt)
|
|
692
764
|
|
|
@@ -730,8 +802,18 @@ def _execute_mutation(
|
|
|
730
802
|
kind: CommandKind,
|
|
731
803
|
profile_id: str,
|
|
732
804
|
payload: Mapping[str, Any],
|
|
805
|
+
*,
|
|
806
|
+
connection: Any,
|
|
733
807
|
) -> dict[str, Any]:
|
|
734
808
|
"""Dispatch the finite mutation set; no policy, hooks, models, or I/O."""
|
|
809
|
+
if kind is CommandKind.PROPOSE_CORRECTION:
|
|
810
|
+
return _propose_correction_successor(db, profile_id, payload, connection=connection)
|
|
811
|
+
if kind in {
|
|
812
|
+
CommandKind.APPLY_CORRECTION,
|
|
813
|
+
CommandKind.REJECT_CORRECTION,
|
|
814
|
+
CommandKind.ROLLBACK_CORRECTION,
|
|
815
|
+
}:
|
|
816
|
+
return _transition_correction(db, kind, profile_id, payload, connection=connection)
|
|
735
817
|
fact_id = _payload_text(payload, "fact_id")
|
|
736
818
|
if kind is CommandKind.DELETE_FACT:
|
|
737
819
|
return _delete_fact(db, fact_id, profile_id)
|
|
@@ -750,6 +832,26 @@ def _delete_fact(db: DatabaseManager, fact_id: str, profile_id: str) -> dict[str
|
|
|
750
832
|
row = _fact_row(db, fact_id, profile_id)
|
|
751
833
|
if row is None:
|
|
752
834
|
return {"ok": False, "operation_id": f"delete:{fact_id}", "fact_id": fact_id}
|
|
835
|
+
# M042 deliberately retains immutable predecessor/successor history.
|
|
836
|
+
# SQLite's FK would reject a direct delete, but exposing that as a generic
|
|
837
|
+
# writer outage turns a real lifecycle conflict into a misleading 503.
|
|
838
|
+
# A dedicated erasure workflow owns ledger removal; ordinary forget never
|
|
839
|
+
# deletes a fact that is part of immutable correction history.
|
|
840
|
+
try:
|
|
841
|
+
protected = db.execute(
|
|
842
|
+
"SELECT 1 FROM correction_cases "
|
|
843
|
+
"WHERE profile_id=? AND (predecessor_fact_id=? OR successor_fact_id=?) LIMIT 1",
|
|
844
|
+
(profile_id, fact_id, fact_id),
|
|
845
|
+
)
|
|
846
|
+
except Exception as exc:
|
|
847
|
+
if "no such table" in str(exc).lower():
|
|
848
|
+
protected = []
|
|
849
|
+
else:
|
|
850
|
+
raise
|
|
851
|
+
if protected:
|
|
852
|
+
raise CanonicalMutationConflict(
|
|
853
|
+
"fact is protected by correction history; resolve the correction before forgetting it"
|
|
854
|
+
)
|
|
753
855
|
db.delete_fact(fact_id, profile_id=profile_id)
|
|
754
856
|
return {
|
|
755
857
|
"ok": True,
|
|
@@ -782,6 +884,173 @@ def _update_fact(
|
|
|
782
884
|
}
|
|
783
885
|
|
|
784
886
|
|
|
887
|
+
def _propose_correction_successor(
|
|
888
|
+
db: DatabaseManager,
|
|
889
|
+
profile_id: str,
|
|
890
|
+
payload: Mapping[str, Any],
|
|
891
|
+
*,
|
|
892
|
+
connection: Any,
|
|
893
|
+
) -> dict[str, Any]:
|
|
894
|
+
"""Create successor and M042 proposal in one canonical writer transaction.
|
|
895
|
+
|
|
896
|
+
System time is represented by the new fact's ``created_at`` and temporal
|
|
897
|
+
knowledge anchor. Event-time fields are copied exactly from the
|
|
898
|
+
predecessor because an edit payload has no trustworthy event-time claim.
|
|
899
|
+
"""
|
|
900
|
+
fact_id = _payload_text(payload, "fact_id")
|
|
901
|
+
source = _fact_row(db, fact_id, profile_id)
|
|
902
|
+
if source is None:
|
|
903
|
+
return {"ok": False, "operation_id": f"correction:{fact_id}", "fact_id": fact_id}
|
|
904
|
+
successor_id = _payload_text(payload, "successor_fact_id")
|
|
905
|
+
content = _payload_text(payload, "content").strip()
|
|
906
|
+
if not content:
|
|
907
|
+
raise ValueError("correction successor content cannot be empty")
|
|
908
|
+
if successor_id == fact_id:
|
|
909
|
+
raise ValueError("correction successor must differ from predecessor")
|
|
910
|
+
|
|
911
|
+
from superlocalmemory.storage.models import AtomicFact, FactType, MemoryLifecycle, SignalType
|
|
912
|
+
|
|
913
|
+
def _list_payload(key: str) -> list[float] | None:
|
|
914
|
+
value = payload.get(key)
|
|
915
|
+
if value is None:
|
|
916
|
+
return None
|
|
917
|
+
if not isinstance(value, (list, tuple)) or not all(
|
|
918
|
+
isinstance(v, (int, float)) for v in value
|
|
919
|
+
):
|
|
920
|
+
raise ValueError(f"correction successor {key} must be numeric or null")
|
|
921
|
+
return [float(v) for v in value]
|
|
922
|
+
|
|
923
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
924
|
+
successor = AtomicFact(
|
|
925
|
+
fact_id=successor_id,
|
|
926
|
+
memory_id=str(source.get("memory_id") or ""),
|
|
927
|
+
profile_id=profile_id,
|
|
928
|
+
scope=str(source.get("scope") or "personal"),
|
|
929
|
+
shared_with=json.loads(source["shared_with"]) if source.get("shared_with") else None,
|
|
930
|
+
content=content,
|
|
931
|
+
fact_type=FactType(str(source.get("fact_type") or "semantic")),
|
|
932
|
+
entities=json.loads(source["entities_json"]) if source.get("entities_json") else [],
|
|
933
|
+
canonical_entities=(
|
|
934
|
+
json.loads(source["canonical_entities_json"])
|
|
935
|
+
if source.get("canonical_entities_json") else []
|
|
936
|
+
),
|
|
937
|
+
observation_date=source.get("observation_date"),
|
|
938
|
+
referenced_date=source.get("referenced_date"),
|
|
939
|
+
interval_start=source.get("interval_start"),
|
|
940
|
+
interval_end=source.get("interval_end"),
|
|
941
|
+
confidence=float(source.get("confidence") or 0.0),
|
|
942
|
+
importance=float(source.get("importance") or 0.0),
|
|
943
|
+
evidence_count=1,
|
|
944
|
+
access_count=0,
|
|
945
|
+
source_turn_ids=(
|
|
946
|
+
json.loads(source["source_turn_ids_json"])
|
|
947
|
+
if source.get("source_turn_ids_json") else []
|
|
948
|
+
),
|
|
949
|
+
session_id=str(source.get("session_id") or ""),
|
|
950
|
+
embedding=_list_payload("embedding"),
|
|
951
|
+
fisher_mean=_list_payload("fisher_mean"),
|
|
952
|
+
fisher_variance=_list_payload("fisher_variance"),
|
|
953
|
+
lifecycle=MemoryLifecycle.ACTIVE,
|
|
954
|
+
langevin_position=None,
|
|
955
|
+
emotional_valence=float(source.get("emotional_valence") or 0.0),
|
|
956
|
+
emotional_arousal=float(source.get("emotional_arousal") or 0.0),
|
|
957
|
+
signal_type=SignalType(str(source.get("signal_type") or "factual")),
|
|
958
|
+
created_at=now,
|
|
959
|
+
)
|
|
960
|
+
persisted_id = db.insert_fact_immutable(successor)
|
|
961
|
+
from superlocalmemory.storage.correction_cases import (
|
|
962
|
+
CorrectionActor,
|
|
963
|
+
propose_on_connection,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
trusted_actor_id = _payload_text(payload, "trusted_actor_id")
|
|
967
|
+
actor = CorrectionActor(
|
|
968
|
+
actor_id=trusted_actor_id,
|
|
969
|
+
actor_kind="host_authenticated",
|
|
970
|
+
trust_tier="trusted",
|
|
971
|
+
)
|
|
972
|
+
case_id = uuid.uuid5(
|
|
973
|
+
uuid.NAMESPACE_URL,
|
|
974
|
+
f"slm-correction:{profile_id}:{fact_id}:{persisted_id}",
|
|
975
|
+
).hex
|
|
976
|
+
case = propose_on_connection(
|
|
977
|
+
connection,
|
|
978
|
+
case_id=case_id,
|
|
979
|
+
profile_id=profile_id,
|
|
980
|
+
scope=str(source.get("scope") or "personal"),
|
|
981
|
+
predecessor_fact_id=fact_id,
|
|
982
|
+
successor_fact_id=persisted_id,
|
|
983
|
+
reason_code="direct_content_correction",
|
|
984
|
+
actor=actor,
|
|
985
|
+
idempotency_key=_payload_text(payload, "idempotency_key"),
|
|
986
|
+
is_profile_active=lambda candidate: candidate == profile_id,
|
|
987
|
+
is_actor_trusted=lambda candidate: candidate == actor,
|
|
988
|
+
)
|
|
989
|
+
return {
|
|
990
|
+
"ok": True,
|
|
991
|
+
"operation_id": f"correction:{fact_id}:{persisted_id}",
|
|
992
|
+
"predecessor_fact_id": fact_id,
|
|
993
|
+
"successor_fact_id": persisted_id,
|
|
994
|
+
"case_id": case.case_id,
|
|
995
|
+
"status": case.status,
|
|
996
|
+
"version": case.version,
|
|
997
|
+
"created_at": now,
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _transition_correction(
|
|
1002
|
+
db: DatabaseManager,
|
|
1003
|
+
kind: CommandKind,
|
|
1004
|
+
profile_id: str,
|
|
1005
|
+
payload: Mapping[str, Any],
|
|
1006
|
+
*,
|
|
1007
|
+
connection: Any,
|
|
1008
|
+
) -> dict[str, Any]:
|
|
1009
|
+
"""Advance one correction case under the same receipt transaction."""
|
|
1010
|
+
from superlocalmemory.storage.correction_cases import (
|
|
1011
|
+
CorrectionActor,
|
|
1012
|
+
transition_on_connection,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
case_id = _payload_text(payload, "case_id")
|
|
1016
|
+
version = payload.get("expected_version")
|
|
1017
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 0:
|
|
1018
|
+
raise ValueError("correction expected_version must be a non-negative integer")
|
|
1019
|
+
actor = CorrectionActor(
|
|
1020
|
+
actor_id=_payload_text(payload, "trusted_actor_id"),
|
|
1021
|
+
actor_kind="host_authenticated",
|
|
1022
|
+
trust_tier="trusted",
|
|
1023
|
+
)
|
|
1024
|
+
transitions = {
|
|
1025
|
+
CommandKind.APPLY_CORRECTION: ("proposed", "applied", True),
|
|
1026
|
+
CommandKind.REJECT_CORRECTION: ("proposed", "rejected", False),
|
|
1027
|
+
CommandKind.ROLLBACK_CORRECTION: ("applied", "rolled_back", True),
|
|
1028
|
+
}
|
|
1029
|
+
from_status, to_status, mutate_temporal = transitions[kind]
|
|
1030
|
+
case = transition_on_connection(
|
|
1031
|
+
connection,
|
|
1032
|
+
case_id=case_id,
|
|
1033
|
+
expected_version=version,
|
|
1034
|
+
actor=actor,
|
|
1035
|
+
operation_id=_payload_text(payload, "idempotency_key"),
|
|
1036
|
+
from_status=from_status,
|
|
1037
|
+
to_status=to_status,
|
|
1038
|
+
mutate_temporal=mutate_temporal,
|
|
1039
|
+
event_valid_until=payload.get("event_valid_until"),
|
|
1040
|
+
is_profile_active=lambda candidate: candidate == profile_id,
|
|
1041
|
+
is_actor_trusted=lambda candidate: candidate == actor,
|
|
1042
|
+
)
|
|
1043
|
+
return {
|
|
1044
|
+
"ok": True,
|
|
1045
|
+
"operation_id": f"correction:{to_status}:{case.case_id}",
|
|
1046
|
+
"case_id": case.case_id,
|
|
1047
|
+
"predecessor_fact_id": case.predecessor_fact_id,
|
|
1048
|
+
"successor_fact_id": case.successor_fact_id,
|
|
1049
|
+
"status": case.status,
|
|
1050
|
+
"version": case.version,
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
|
|
785
1054
|
def _archive_fact(
|
|
786
1055
|
db: DatabaseManager, fact_id: str, profile_id: str, payload: Mapping[str, Any],
|
|
787
1056
|
) -> dict[str, Any]:
|