superlocalmemory 3.8.5 → 3.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +139 -404
  34. package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
  35. package/src/superlocalmemory/core/component_registry.py +4 -2
  36. package/src/superlocalmemory/core/embeddings.py +33 -6
  37. package/src/superlocalmemory/core/engine.py +94 -49
  38. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  39. package/src/superlocalmemory/core/ingestion_command.py +133 -21
  40. package/src/superlocalmemory/core/mutations.py +32 -10
  41. package/src/superlocalmemory/core/recall_pipeline.py +111 -77
  42. package/src/superlocalmemory/core/remember_admission.py +152 -0
  43. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  44. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  45. package/src/superlocalmemory/learning/bandit.py +50 -1
  46. package/src/superlocalmemory/learning/source_quality.py +38 -35
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  48. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  49. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  50. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  51. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  52. package/src/superlocalmemory/retrieval/engine.py +8 -3
  53. package/src/superlocalmemory/retrieval/reranker.py +35 -10
  54. package/src/superlocalmemory/server/loopback.py +7 -13
  55. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  56. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  57. package/src/superlocalmemory/server/routes/agents.py +3 -5
  58. package/src/superlocalmemory/server/routes/behavioral.py +5 -13
  59. package/src/superlocalmemory/server/routes/brain.py +6 -9
  60. package/src/superlocalmemory/server/routes/entity.py +3 -7
  61. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  62. package/src/superlocalmemory/server/routes/helpers.py +44 -23
  63. package/src/superlocalmemory/server/routes/insights.py +2 -4
  64. package/src/superlocalmemory/server/routes/learning.py +2 -5
  65. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  66. package/src/superlocalmemory/server/routes/memories.py +122 -100
  67. package/src/superlocalmemory/server/routes/tiers.py +3 -22
  68. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  69. package/src/superlocalmemory/server/routes/v3_api.py +18 -16
  70. package/src/superlocalmemory/server/unified_daemon.py +200 -109
  71. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  72. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  73. package/src/superlocalmemory/storage/database.py +59 -0
  74. package/src/superlocalmemory/storage/deferred_writes.py +67 -11
  75. package/src/superlocalmemory/storage/memory_write.py +8 -12
  76. package/src/superlocalmemory/storage/migration_runner.py +37 -0
  77. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  78. package/src/superlocalmemory/storage/read_connection.py +115 -0
  79. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  80. package/src/superlocalmemory/ui/index.html +1 -1
  81. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  82. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -0,0 +1,712 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Daemon-owned bridge from journaled remember requests to one SQLite writer.
5
+
6
+ The public HTTP boundary authenticates and runs trust policy before it calls
7
+ this module. This runtime then uses a separate FULL-synchronous journal to
8
+ make the request replayable, and submits a typed command to the sole daemon
9
+ writer. The command transaction creates only the M018 operation plus its
10
+ immediately-queryable projection; model and enrichment work remain with the
11
+ existing background materializer.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import re
19
+ import threading
20
+ import uuid
21
+ from collections.abc import Callable, Mapping
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from superlocalmemory.core.ingestion_command import (
27
+ IngestionCommand,
28
+ IngestionOperation,
29
+ IngestionOperationRepository,
30
+ IngestionRejectedError,
31
+ IngestionRequest,
32
+ MaterializationResult,
33
+ )
34
+ from superlocalmemory.core.remember_admission import (
35
+ RememberAdmissionCommand,
36
+ RememberReceipt,
37
+ RememberService,
38
+ )
39
+ from superlocalmemory.storage.admission_codec import MachineKeyCommandCodec
40
+ from superlocalmemory.storage.admission_journal import (
41
+ Actor,
42
+ AdmissionEntry,
43
+ AdmissionJournal,
44
+ AdmissionJournalUnavailable,
45
+ AdmissionPayloadError,
46
+ RememberRequest,
47
+ TerminalAdmissionError,
48
+ )
49
+ from superlocalmemory.storage.database import DatabaseManager
50
+ from superlocalmemory.storage.write_coordinator import (
51
+ CommandConflictError,
52
+ CommandKind,
53
+ CommandRejectedError,
54
+ OwnershipRequiredError,
55
+ WriteCommand,
56
+ WriteCoordinator,
57
+ WriteCoordinatorError,
58
+ WriteResult,
59
+ )
60
+
61
+ QueryableWriter = Callable[[IngestionRequest, str], list[str]]
62
+ Materializer = Callable[
63
+ [IngestionOperation],
64
+ list[str] | tuple[str, ...] | MaterializationResult,
65
+ ]
66
+
67
+
68
+ class CanonicalRememberUnavailable(RuntimeError):
69
+ """The daemon cannot accept a bounded canonical remember request."""
70
+
71
+
72
+ class CanonicalMutationConflict(ValueError):
73
+ """A mutation retry key was reused for different immutable input."""
74
+
75
+
76
+ _MUTATION_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
77
+
78
+
79
+ def validate_deterministic_admission(
80
+ content: str,
81
+ *,
82
+ max_verbatim_chars: int = 24_000,
83
+ max_ingest_bytes: int = 1_048_576,
84
+ ) -> None:
85
+ """Reject deterministic non-evidence before it can enter the journal.
86
+
87
+ The immediate writer checks these gates as a defence in depth measure. At
88
+ the daemon boundary they must run first: a rejected payload has no
89
+ queryable receipt, so recording it as dispatched would create replay work
90
+ that can never commit.
91
+ """
92
+ from superlocalmemory.core.engine_ingestion import content_passes_admission
93
+ from superlocalmemory.core.ingest_gate import apply_ingest_gate
94
+
95
+ if not content_passes_admission(content):
96
+ raise AdmissionPayloadError("content rejected by deterministic admission policy")
97
+ if apply_ingest_gate(
98
+ content,
99
+ max_verbatim_chars=max_verbatim_chars,
100
+ max_ingest_bytes=max_ingest_bytes,
101
+ ).rejected:
102
+ raise AdmissionPayloadError("content rejected by deterministic ingest policy")
103
+
104
+
105
+ class _CoordinatorAdapter:
106
+ """Translate the journal service's narrow protocol into typed commands."""
107
+
108
+ def __init__(self, coordinator: WriteCoordinator) -> None:
109
+ self._coordinator = coordinator
110
+
111
+ def submit(
112
+ self, command: RememberAdmissionCommand, *, wait_ms: int,
113
+ ) -> Mapping[str, Any]:
114
+ payload = {
115
+ "journal_id": command.journal_id,
116
+ "request_hash": command.request_hash,
117
+ "profile_id": command.profile_id,
118
+ "idempotency_key": command.idempotency_key,
119
+ "request": command.request.canonical_payload(),
120
+ }
121
+ try:
122
+ result = self._coordinator.submit(
123
+ WriteCommand.create(
124
+ CommandKind.ADMISSION,
125
+ payload,
126
+ command_id=command.journal_id,
127
+ ),
128
+ timeout=max(0.001, wait_ms / 1000),
129
+ )
130
+ except CommandRejectedError as exc:
131
+ raise TerminalAdmissionError(exc.error_code) from exc
132
+ return {"state": "committed", "receipt": dict(result.receipt)}
133
+
134
+
135
+ class CanonicalRememberRuntime:
136
+ """One daemon lifetime of journal-first, coordinator-owned admission."""
137
+
138
+ def __init__(
139
+ self,
140
+ *,
141
+ db: DatabaseManager,
142
+ profile_id: str,
143
+ writer: QueryableWriter,
144
+ journal_path: str | Path,
145
+ materialize: Materializer | None = None,
146
+ owner_id: str | None = None,
147
+ ) -> None:
148
+ if not profile_id:
149
+ raise ValueError("profile_id is required")
150
+ self._db = db
151
+ self._profile_id = profile_id
152
+ self._writer = writer
153
+ self._materialize = materialize or _materialization_is_not_available
154
+ self._binding_lock = threading.RLock()
155
+ self.coordinator = WriteCoordinator(db.db_path, owner_id=owner_id)
156
+ self.journal = AdmissionJournal(
157
+ journal_path,
158
+ codec=MachineKeyCommandCodec(Path(journal_path).with_name("admission-key.bin")),
159
+ )
160
+ self._service = RememberService(self.journal, _CoordinatorAdapter(self.coordinator))
161
+ self._started = False
162
+
163
+ @classmethod
164
+ def for_engine(cls, engine: Any) -> "CanonicalRememberRuntime":
165
+ """Create the daemon boundary from an initialized engine only."""
166
+ from superlocalmemory.core.engine_ingestion import build_immediate_admission_handler
167
+ from superlocalmemory.infra.data_root import state_path
168
+
169
+ db = engine._db
170
+ store_config = getattr(engine._config, "store", None)
171
+ return cls(
172
+ db=db,
173
+ profile_id=engine._profile_id,
174
+ writer=build_immediate_admission_handler(
175
+ db,
176
+ profile_id=engine._profile_id,
177
+ max_verbatim_chars=getattr(store_config, "max_verbatim_chars", 24_000),
178
+ max_ingest_bytes=getattr(store_config, "max_ingest_bytes", 1_048_576),
179
+ ),
180
+ journal_path=state_path("admission_journal.db"),
181
+ )
182
+
183
+ def start(self) -> None:
184
+ """Claim writer ownership, install the handler, then recover journal work."""
185
+ if self._started:
186
+ return
187
+ if not self.coordinator.claim_ownership():
188
+ raise CanonicalRememberUnavailable(
189
+ "another daemon owns the canonical memory writer"
190
+ )
191
+ try:
192
+ self.coordinator.register_handler(CommandKind.ADMISSION, self._handle_admission)
193
+ self.coordinator.register_handler(CommandKind.DELETE_FACT, self._handle_mutation)
194
+ self.coordinator.register_handler(CommandKind.UPDATE_FACT, self._handle_mutation)
195
+ self.coordinator.register_handler(CommandKind.ARCHIVE_FACT, self._handle_mutation)
196
+ self.coordinator.register_handler(CommandKind.MERGE_FACT, self._handle_mutation)
197
+ self.coordinator.register_handler(CommandKind.SET_FACT_SCOPE, self._handle_mutation)
198
+ self.coordinator.start()
199
+ self.replay_pending()
200
+ except BaseException:
201
+ self.coordinator.release_ownership()
202
+ raise
203
+ self._started = True
204
+
205
+ @property
206
+ def ready(self) -> bool:
207
+ worker = self.coordinator._worker
208
+ return bool(
209
+ self._started
210
+ and self.coordinator._ownership_context is not None
211
+ and worker is not None
212
+ and worker.is_alive()
213
+ )
214
+
215
+ def stop(self) -> None:
216
+ """Release the daemon writer lease after callers and workers have stopped."""
217
+ self._started = False
218
+ self.coordinator.release_ownership()
219
+
220
+ def rebind_engine(self, engine: Any) -> None:
221
+ """Atomically follow a drained daemon mode/profile transition."""
222
+ from superlocalmemory.core.engine_ingestion import build_immediate_admission_handler
223
+
224
+ db = engine._db
225
+ if db.db_path.expanduser().resolve() != self.coordinator.db_path:
226
+ raise CanonicalRememberUnavailable(
227
+ "reconfigured engine targets a different canonical database"
228
+ )
229
+ profile_id = str(engine._profile_id)
230
+ if not profile_id:
231
+ raise CanonicalRememberUnavailable("reconfigured engine has no profile")
232
+ store_config = getattr(getattr(engine, "_config", None), "store", None)
233
+ writer = build_immediate_admission_handler(
234
+ db,
235
+ profile_id=profile_id,
236
+ max_verbatim_chars=getattr(store_config, "max_verbatim_chars", 24_000),
237
+ max_ingest_bytes=getattr(store_config, "max_ingest_bytes", 1_048_576),
238
+ )
239
+ with self._binding_lock:
240
+ previous = (self._db, self._profile_id, self._writer)
241
+ self._db = db
242
+ self._profile_id = profile_id
243
+ self._writer = writer
244
+ try:
245
+ self.replay_pending()
246
+ except BaseException:
247
+ with self._binding_lock:
248
+ self._db, self._profile_id, self._writer = previous
249
+ raise
250
+
251
+ def remember(
252
+ self, request: RememberRequest, actor: Actor, *, deadline_ms: int = 2_000,
253
+ ) -> RememberReceipt:
254
+ """Journal then commit one bounded queryable admission receipt."""
255
+ if not self._started:
256
+ raise CanonicalRememberUnavailable("canonical remember writer is not ready")
257
+ if deadline_ms < 1 or deadline_ms > 2_000:
258
+ raise ValueError("deadline_ms must be between 1 and 2000")
259
+ try:
260
+ return self._service.remember(request, actor, deadline_ms=deadline_ms)
261
+ except (
262
+ AdmissionJournalUnavailable,
263
+ OwnershipRequiredError,
264
+ WriteCoordinatorError,
265
+ ) as exc:
266
+ raise CanonicalRememberUnavailable(
267
+ "canonical remember is temporarily unavailable"
268
+ ) from exc
269
+
270
+ def replay_pending(self) -> int:
271
+ """Finish prepared/dispatched journal entries before publishing readiness."""
272
+ if self.coordinator._ownership_context is None:
273
+ raise CanonicalRememberUnavailable("canonical writer ownership is required")
274
+
275
+ def find(entry: AdmissionEntry) -> Mapping[str, Any] | None:
276
+ rows = self.coordinator.execute(
277
+ "SELECT request_hash, receipt_json FROM write_commits "
278
+ "WHERE profile_id=? AND idempotency_key=?",
279
+ (entry.profile_id, entry.idempotency_key),
280
+ timeout=1.0,
281
+ )
282
+ if not rows:
283
+ return None
284
+ if rows[0]["request_hash"] != entry.request_hash:
285
+ raise TerminalAdmissionError("IDEMPOTENCY_CONFLICT")
286
+ receipt = json.loads(rows[0]["receipt_json"])
287
+ return receipt if isinstance(receipt, dict) else None
288
+
289
+ def dispatch(entry, request: RememberRequest) -> Mapping[str, Any]:
290
+ command = RememberAdmissionCommand(
291
+ journal_id=entry.journal_id,
292
+ request_hash=entry.request_hash,
293
+ request=request,
294
+ profile_id=entry.profile_id,
295
+ idempotency_key=entry.idempotency_key,
296
+ )
297
+ return _CoordinatorAdapter(self.coordinator).submit(command, wait_ms=2_000)["receipt"]
298
+
299
+ try:
300
+ return self.journal.replay_pending(
301
+ find,
302
+ dispatch,
303
+ profile_id=self._profile_id,
304
+ )
305
+ except (WriteCoordinatorError, ValueError, json.JSONDecodeError) as exc:
306
+ raise CanonicalRememberUnavailable("pending remember recovery failed") from exc
307
+
308
+ def delete_fact(
309
+ self, profile_id: str, fact_id: str, *, idempotency_key: str | None = None,
310
+ ) -> Mapping[str, Any]:
311
+ """Hard-delete one profile-owned fact through the sole writer."""
312
+ return self._submit_mutation(
313
+ CommandKind.DELETE_FACT,
314
+ profile_id,
315
+ {"fact_id": fact_id},
316
+ idempotency_key=idempotency_key,
317
+ )
318
+
319
+ def update_fact(
320
+ self,
321
+ profile_id: str,
322
+ fact_id: str,
323
+ updates: Mapping[str, Any],
324
+ *,
325
+ idempotency_key: str | None = None,
326
+ ) -> Mapping[str, Any]:
327
+ """Apply deterministic fact fields after policy/model work completes."""
328
+ return self._submit_mutation(
329
+ CommandKind.UPDATE_FACT,
330
+ profile_id,
331
+ {"fact_id": fact_id, "updates": _json_roundtrip(updates)},
332
+ idempotency_key=idempotency_key,
333
+ )
334
+
335
+ def archive_fact(
336
+ self, profile_id: str, fact_id: str, *, idempotency_key: str | None = None,
337
+ ) -> Mapping[str, Any]:
338
+ """Archive a fact and its restore payload in one bounded transaction."""
339
+ return self._submit_mutation(
340
+ CommandKind.ARCHIVE_FACT,
341
+ profile_id,
342
+ {"fact_id": fact_id},
343
+ idempotency_key=idempotency_key,
344
+ )
345
+
346
+ def merge_fact(
347
+ self,
348
+ profile_id: str,
349
+ fact_id: str,
350
+ kept_fact_id: str,
351
+ *,
352
+ idempotency_key: str | None = None,
353
+ ) -> Mapping[str, Any]:
354
+ """Record and apply a same-profile merge through the sole writer."""
355
+ return self._submit_mutation(
356
+ CommandKind.MERGE_FACT,
357
+ profile_id,
358
+ {
359
+ "fact_id": fact_id,
360
+ "kept_fact_id": kept_fact_id,
361
+ },
362
+ idempotency_key=idempotency_key,
363
+ )
364
+
365
+ def set_fact_scope(
366
+ self,
367
+ profile_id: str,
368
+ fact_id: str,
369
+ scope: str,
370
+ shared_with: list[str],
371
+ *,
372
+ idempotency_key: str | None = None,
373
+ ) -> Mapping[str, Any]:
374
+ """Set a validated scope without bypassing profile isolation."""
375
+ return self._submit_mutation(
376
+ CommandKind.SET_FACT_SCOPE,
377
+ profile_id,
378
+ {"fact_id": fact_id, "scope": scope, "shared_with": shared_with},
379
+ idempotency_key=idempotency_key,
380
+ )
381
+
382
+ def _submit_mutation(
383
+ self,
384
+ kind: CommandKind,
385
+ profile_id: str,
386
+ payload: Mapping[str, Any],
387
+ *,
388
+ idempotency_key: str | None,
389
+ ) -> Mapping[str, Any]:
390
+ if not self._started:
391
+ raise CanonicalRememberUnavailable("canonical mutation writer is not ready")
392
+ if not profile_id:
393
+ raise ValueError("profile_id is required")
394
+ key = idempotency_key or str(uuid.uuid4())
395
+ if not _MUTATION_IDEMPOTENCY_KEY.fullmatch(key):
396
+ raise ValueError("idempotency key must be 1-256 safe characters")
397
+ canonical = _json_roundtrip(payload)
398
+ request_hash = hashlib.sha256(
399
+ json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
400
+ ).hexdigest()
401
+ scoped_key = (
402
+ f"mutation:{kind.value}:"
403
+ + hashlib.sha256(
404
+ f"{profile_id}\0{key}".encode("utf-8")
405
+ ).hexdigest()
406
+ )
407
+ command = WriteCommand(
408
+ command_id=scoped_key,
409
+ kind=kind,
410
+ payload={
411
+ **canonical,
412
+ "journal_id": scoped_key,
413
+ "request_hash": request_hash,
414
+ "profile_id": profile_id,
415
+ "idempotency_key": scoped_key,
416
+ },
417
+ )
418
+ try:
419
+ return dict(self.coordinator.submit(command, timeout=2.0).receipt)
420
+ except CommandConflictError as exc:
421
+ raise CanonicalMutationConflict(
422
+ "idempotency key belongs to a different mutation request"
423
+ ) from exc
424
+ except (OwnershipRequiredError, WriteCoordinatorError) as exc:
425
+ raise CanonicalRememberUnavailable(
426
+ "canonical mutation is temporarily unavailable"
427
+ ) from exc
428
+
429
+ def _handle_admission(
430
+ self,
431
+ conn,
432
+ capability,
433
+ command: WriteCommand,
434
+ ) -> WriteResult:
435
+ """Project a journal command under the coordinator's sole transaction."""
436
+ payload = command.payload
437
+ raw_request = payload.get("request")
438
+ if not isinstance(raw_request, Mapping):
439
+ raise ValueError("admission command request is missing")
440
+ request = RememberRequest.from_payload(raw_request)
441
+ with self._binding_lock:
442
+ db = self._db
443
+ if request.profile_id != self._profile_id:
444
+ raise ValueError("admission command targets a different profile")
445
+ ingestion_request = IngestionRequest(
446
+ content=request.content,
447
+ profile_id=request.profile_id,
448
+ source_type=request.source_type,
449
+ idempotency_key=request.idempotency_key,
450
+ metadata=dict(request.metadata),
451
+ scope=request.scope,
452
+ shared_with=request.shared_with,
453
+ trusted_actor_id=request.trusted_actor_id,
454
+ session_id=request.session_id,
455
+ session_date=request.session_date,
456
+ speaker=request.speaker,
457
+ role=request.role,
458
+ )
459
+ # There is deliberately no validate_admission argument here. The
460
+ # HTTP trust hook ran before journal.prepare, and no hook/model or
461
+ # projection code can enter the coordinator transaction.
462
+ with db._bind_coordinator_connection(conn, capability):
463
+ command_impl = IngestionCommand(
464
+ IngestionOperationRepository(db),
465
+ write_queryable=self._writer,
466
+ materialize=self._materialize,
467
+ )
468
+ try:
469
+ receipt = command_impl.submit(ingestion_request)
470
+ except IngestionRejectedError as exc:
471
+ raise CommandRejectedError() from exc
472
+ return WriteResult.from_receipt(
473
+ command,
474
+ {
475
+ "operation_id": receipt.operation_id,
476
+ "pending_id": receipt.operation_id,
477
+ "fact_ids": list(receipt.fact_ids),
478
+ "count": len(receipt.fact_ids),
479
+ "status": "queryable",
480
+ "materialization_state": receipt.state.value,
481
+ },
482
+ )
483
+
484
+ def _handle_mutation(self, conn, capability, command: WriteCommand) -> WriteResult:
485
+ """Run only deterministic SQLite mutation statements under the writer."""
486
+ payload = command.payload
487
+ profile_id = _payload_text(payload, "profile_id")
488
+ with self._binding_lock:
489
+ if profile_id != self._profile_id:
490
+ raise ValueError("mutation command targets a different profile")
491
+ with self._db._bind_coordinator_connection(conn, capability):
492
+ receipt = _execute_mutation(self._db, command.kind, profile_id, payload)
493
+ receipt["operation_id"] = f"mutation:{command.kind.value}:{command.command_id}"
494
+ return WriteResult.from_receipt(command, receipt)
495
+
496
+
497
+ def _materialization_is_not_available(_operation: IngestionOperation) -> list[str]:
498
+ """Guard against accidental inline enrichment on the canonical path."""
499
+ raise RuntimeError("canonical remember materialization belongs to the background worker")
500
+
501
+
502
+ _FACT_UPDATE_COLUMNS = frozenset({"content", "embedding", "fisher_mean", "fisher_variance"})
503
+
504
+
505
+ def _json_roundtrip(value: Mapping[str, Any]) -> dict[str, Any]:
506
+ """Reject non-command data before it reaches the writer thread."""
507
+ try:
508
+ decoded = json.loads(json.dumps(value, sort_keys=True, ensure_ascii=False))
509
+ except (TypeError, ValueError) as exc:
510
+ raise ValueError("mutation payload must be JSON-compatible") from exc
511
+ if not isinstance(decoded, dict): # pragma: no cover - Mapping input is enforced
512
+ raise ValueError("mutation payload must be an object")
513
+ return decoded
514
+
515
+
516
+ def _payload_text(payload: Mapping[str, Any], key: str) -> str:
517
+ value = payload.get(key)
518
+ if not isinstance(value, str) or not value:
519
+ raise ValueError(f"mutation command is missing {key}")
520
+ return value
521
+
522
+
523
+ def _fact_row(db: DatabaseManager, fact_id: str, profile_id: str) -> Mapping[str, Any] | None:
524
+ rows = db.execute(
525
+ "SELECT * FROM atomic_facts WHERE fact_id = ? AND profile_id = ? LIMIT 1",
526
+ (fact_id, profile_id),
527
+ )
528
+ return dict(rows[0]) if rows else None
529
+
530
+
531
+ def _execute_mutation(
532
+ db: DatabaseManager,
533
+ kind: CommandKind,
534
+ profile_id: str,
535
+ payload: Mapping[str, Any],
536
+ ) -> dict[str, Any]:
537
+ """Dispatch the finite mutation set; no policy, hooks, models, or I/O."""
538
+ fact_id = _payload_text(payload, "fact_id")
539
+ if kind is CommandKind.DELETE_FACT:
540
+ return _delete_fact(db, fact_id, profile_id)
541
+ if kind is CommandKind.UPDATE_FACT:
542
+ return _update_fact(db, fact_id, profile_id, payload)
543
+ if kind is CommandKind.ARCHIVE_FACT:
544
+ return _archive_fact(db, fact_id, profile_id, payload)
545
+ if kind is CommandKind.MERGE_FACT:
546
+ return _merge_fact(db, fact_id, profile_id, payload)
547
+ if kind is CommandKind.SET_FACT_SCOPE:
548
+ return _set_fact_scope(db, fact_id, profile_id, payload)
549
+ raise ValueError(f"unsupported mutation command {kind.value}")
550
+
551
+
552
+ def _delete_fact(db: DatabaseManager, fact_id: str, profile_id: str) -> dict[str, Any]:
553
+ row = _fact_row(db, fact_id, profile_id)
554
+ if row is None:
555
+ return {"ok": False, "operation_id": f"delete:{fact_id}", "fact_id": fact_id}
556
+ db.delete_fact(fact_id, profile_id=profile_id)
557
+ return {
558
+ "ok": True,
559
+ "operation_id": f"delete:{fact_id}",
560
+ "deleted": fact_id,
561
+ }
562
+
563
+
564
+ def _update_fact(
565
+ db: DatabaseManager, fact_id: str, profile_id: str, payload: Mapping[str, Any],
566
+ ) -> dict[str, Any]:
567
+ updates = payload.get("updates")
568
+ if not isinstance(updates, Mapping) or not isinstance(updates.get("content"), str):
569
+ raise ValueError("update command requires content")
570
+ row = _fact_row(db, fact_id, profile_id)
571
+ if row is None:
572
+ return {"ok": False, "operation_id": f"update:{fact_id}", "fact_id": fact_id}
573
+ safe = {
574
+ key: _thaw_command_value(value)
575
+ for key, value in updates.items()
576
+ if key in _FACT_UPDATE_COLUMNS
577
+ }
578
+ if set(safe) != set(updates):
579
+ raise ValueError("update command contains unsupported fields")
580
+ db.update_fact(fact_id, safe, profile_id=profile_id)
581
+ return {
582
+ "ok": True,
583
+ "operation_id": f"update:{fact_id}",
584
+ "fact_id": fact_id,
585
+ }
586
+
587
+
588
+ def _archive_fact(
589
+ db: DatabaseManager, fact_id: str, profile_id: str, payload: Mapping[str, Any],
590
+ ) -> dict[str, Any]:
591
+ row = _fact_row(db, fact_id, profile_id)
592
+ if row is None:
593
+ return {"ok": False, "operation_id": f"archive:{fact_id}", "fact_id": fact_id}
594
+ command_key = _payload_text(payload, "idempotency_key")
595
+ archive_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{command_key}:archive"))
596
+ archived_at = datetime.now(timezone.utc).isoformat()
597
+ archive_payload = {
598
+ key: row.get(key)
599
+ for key in (
600
+ "fact_id",
601
+ "content",
602
+ "canonical_entities_json",
603
+ "importance",
604
+ "confidence",
605
+ "created_at",
606
+ )
607
+ }
608
+ db.execute(
609
+ "INSERT INTO memory_archive "
610
+ "(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
611
+ "VALUES (?, ?, ?, ?, ?, ?)",
612
+ (
613
+ archive_id,
614
+ fact_id,
615
+ profile_id,
616
+ json.dumps(archive_payload),
617
+ archived_at,
618
+ "user_forget_dashboard",
619
+ ),
620
+ )
621
+ db.execute(
622
+ "UPDATE atomic_facts SET archive_status = 'archived' "
623
+ "WHERE fact_id = ? AND profile_id = ?",
624
+ (fact_id, profile_id),
625
+ )
626
+ return {
627
+ "ok": True,
628
+ "operation_id": f"archive:{fact_id}",
629
+ "fact_id": fact_id,
630
+ "archived_at": archived_at,
631
+ }
632
+
633
+
634
+ def _merged_fact_ids(
635
+ db: DatabaseManager, fact_id: str, kept: str, profile_id: str,
636
+ ) -> set[str]:
637
+ rows = db.execute(
638
+ "SELECT fact_id FROM atomic_facts "
639
+ "WHERE fact_id IN (?, ?) AND profile_id = ?",
640
+ (fact_id, kept, profile_id),
641
+ )
642
+ return {row["fact_id"] for row in rows}
643
+
644
+
645
+ def _merge_fact(
646
+ db: DatabaseManager, fact_id: str, profile_id: str, payload: Mapping[str, Any],
647
+ ) -> dict[str, Any]:
648
+ kept = _payload_text(payload, "kept_fact_id")
649
+ if kept == fact_id:
650
+ raise ValueError("cannot merge a fact into itself")
651
+ found = _merged_fact_ids(db, fact_id, kept, profile_id)
652
+ if fact_id not in found or kept not in found:
653
+ return {"ok": False, "operation_id": f"merge:{fact_id}:{kept}", "fact_id": fact_id}
654
+ command_key = _payload_text(payload, "idempotency_key")
655
+ merge_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{command_key}:merge"))
656
+ merged_at = datetime.now(timezone.utc).isoformat()
657
+ db.execute(
658
+ "INSERT INTO memory_merge_log "
659
+ "(merge_id, profile_id, canonical_fact_id, merged_fact_id, "
660
+ "cosine_sim, entity_jaccard, merged_at, reversible) "
661
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
662
+ (merge_id, profile_id, kept, fact_id, None, None, merged_at, 1),
663
+ )
664
+ db.execute(
665
+ "UPDATE atomic_facts SET merged_into = ?, archive_status = 'merged', "
666
+ "archive_reason = 'user_merge_dashboard' "
667
+ "WHERE fact_id = ? AND profile_id = ?",
668
+ (kept, fact_id, profile_id),
669
+ )
670
+ return {
671
+ "ok": True,
672
+ "operation_id": f"merge:{fact_id}:{kept}",
673
+ "merged": fact_id,
674
+ "into": kept,
675
+ "merged_at": merged_at,
676
+ }
677
+
678
+
679
+ def _set_fact_scope(
680
+ db: DatabaseManager, fact_id: str, profile_id: str, payload: Mapping[str, Any],
681
+ ) -> dict[str, Any]:
682
+ scope = _payload_text(payload, "scope")
683
+ shared_with = payload.get("shared_with")
684
+ if scope not in {"personal", "shared", "global"} or not isinstance(shared_with, tuple):
685
+ raise ValueError("invalid scope command")
686
+ if _fact_row(db, fact_id, profile_id) is None:
687
+ return {"ok": False, "operation_id": f"scope:{fact_id}", "fact_id": fact_id}
688
+ values = [str(item) for item in shared_with]
689
+ db.update_fact(fact_id, {"scope": scope, "shared_with": values}, profile_id=profile_id)
690
+ return {
691
+ "ok": True,
692
+ "operation_id": f"scope:{fact_id}",
693
+ "fact_id": fact_id,
694
+ "scope": scope,
695
+ "shared_with": values,
696
+ }
697
+
698
+
699
+ def _thaw_command_value(value: Any) -> Any:
700
+ """Restore coordinator-frozen JSON lists before DatabaseManager serializes them."""
701
+ if isinstance(value, Mapping):
702
+ return {key: _thaw_command_value(item) for key, item in value.items()}
703
+ if isinstance(value, tuple):
704
+ return [_thaw_command_value(item) for item in value]
705
+ return value
706
+
707
+
708
+ __all__ = [
709
+ "CanonicalRememberRuntime",
710
+ "CanonicalRememberUnavailable",
711
+ "validate_deterministic_admission",
712
+ ]