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.
Files changed (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  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 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -0,0 +1,728 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Separate FULL-synchronous journal for durable remember admission.
6
+
7
+ The journal is intentionally not a memory store. Its only mutable state is a
8
+ small encrypted replay command and the canonical receipt associated with it.
9
+ Canonical facts, FTS, graph, vectors, and model work stay outside this module.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import binascii
16
+ import hashlib
17
+ import json
18
+ import re
19
+ import sqlite3
20
+ import threading
21
+ import time
22
+ import uuid
23
+ from collections.abc import Callable, Mapping
24
+ from contextlib import contextmanager
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Generator, Protocol
28
+
29
+ from cryptography.exceptions import InvalidTag
30
+
31
+ _MAX_COMMAND_BYTES = 256 * 1024
32
+ _MAX_RECEIPT_BYTES = 16 * 1024
33
+ _MAX_METADATA_DEPTH = 8
34
+ _IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
35
+ _STATES = frozenset({"prepared", "dispatched", "committed", "rejected"})
36
+
37
+ _JOURNAL_DDL = """
38
+ CREATE TABLE IF NOT EXISTS admission_journal (
39
+ journal_id TEXT PRIMARY KEY,
40
+ idempotency_key TEXT NOT NULL,
41
+ request_hash TEXT NOT NULL,
42
+ profile_id TEXT NOT NULL,
43
+ command_json TEXT NOT NULL,
44
+ state TEXT NOT NULL CHECK (
45
+ state IN ('prepared','dispatched','committed','rejected')
46
+ ),
47
+ canonical_operation_id TEXT,
48
+ canonical_commit_sequence INTEGER,
49
+ error_code TEXT,
50
+ receipt_json TEXT,
51
+ created_at_ms INTEGER NOT NULL,
52
+ updated_at_ms INTEGER NOT NULL,
53
+ UNIQUE(profile_id, idempotency_key)
54
+ );
55
+ CREATE INDEX IF NOT EXISTS idx_admission_replay
56
+ ON admission_journal(state, updated_at_ms);
57
+ """
58
+
59
+ _JOURNAL_TABLE_DDL = _JOURNAL_DDL.split(";", 1)[0]
60
+ _JOURNAL_REPLAY_INDEX_DDL = (
61
+ "CREATE INDEX IF NOT EXISTS idx_admission_replay "
62
+ "ON admission_journal(state, updated_at_ms)"
63
+ )
64
+
65
+
66
+ class IdempotencyConflict(ValueError):
67
+ """The key belongs to a different immutable remember request."""
68
+
69
+
70
+ class AdmissionAuthorizationError(PermissionError):
71
+ """The actor is not entitled to submit the requested profile and scope."""
72
+
73
+
74
+ class AdmissionPayloadError(ValueError):
75
+ """The admission body is invalid, oversized, or cannot be encoded safely."""
76
+
77
+
78
+ class AdmissionJournalUnavailable(RuntimeError):
79
+ """The durable admission journal could not mutate within its caller budget."""
80
+
81
+
82
+ class TerminalAdmissionError(RuntimeError):
83
+ """A replayable command was deterministically rejected after journaling."""
84
+
85
+ def __init__(self, error_code: str = "COMMAND_REJECTED") -> None:
86
+ if not error_code or len(error_code) > 128:
87
+ raise ValueError("error_code is required and bounded")
88
+ super().__init__(error_code)
89
+ self.error_code = error_code
90
+
91
+
92
+ class CommandCodec(Protocol):
93
+ """Existing product encryption policy injected by the runtime.
94
+
95
+ The journal deliberately owns no key derivation or cryptographic primitive.
96
+ This prevents it from creating a second, incompatible encryption policy.
97
+ """
98
+
99
+ def encrypt(self, plaintext: bytes) -> bytes: ...
100
+
101
+ def decrypt(self, ciphertext: bytes) -> bytes: ...
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class Actor:
106
+ """Bounded authorization context supplied by the authenticated boundary."""
107
+
108
+ principal_id: str
109
+ allowed_profiles: frozenset[str]
110
+ allowed_scopes: frozenset[str]
111
+ trusted: bool = True
112
+
113
+ def permits(self, profile_id: str, scope: str) -> bool:
114
+ return self.trusted and profile_id in self.allowed_profiles and scope in self.allowed_scopes
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class RememberRequest:
119
+ """Immutable canonical input for a single remember admission."""
120
+
121
+ content: str
122
+ profile_id: str
123
+ source_type: str
124
+ idempotency_key: str
125
+ metadata: Mapping[str, Any] = field(default_factory=dict)
126
+ scope: str = "personal"
127
+ shared_with: tuple[str, ...] = ()
128
+ trusted_actor_id: str = ""
129
+ session_id: str = ""
130
+ session_date: str = ""
131
+ speaker: str = ""
132
+ role: str = "user"
133
+
134
+ def __post_init__(self) -> None:
135
+ if not isinstance(self.content, str) or not self.content.strip():
136
+ raise AdmissionPayloadError("content is required")
137
+ for name in ("profile_id", "source_type"):
138
+ if not isinstance(getattr(self, name), str) or not getattr(self, name).strip():
139
+ raise AdmissionPayloadError(f"{name} is required")
140
+ if not isinstance(self.idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch(
141
+ self.idempotency_key
142
+ ):
143
+ raise AdmissionPayloadError("idempotency_key must be 1-256 safe characters")
144
+ if self.scope not in {"personal", "project", "shared", "global"}:
145
+ raise AdmissionPayloadError(f"unsupported scope: {self.scope}")
146
+ if not isinstance(self.metadata, Mapping):
147
+ raise AdmissionPayloadError("metadata must be an object")
148
+ metadata = dict(self.metadata)
149
+ _validate_json(metadata, "metadata")
150
+ object.__setattr__(self, "metadata", metadata)
151
+ object.__setattr__(self, "shared_with", tuple(self.shared_with))
152
+
153
+ def canonical_payload(self) -> dict[str, Any]:
154
+ return {
155
+ "content": self.content,
156
+ "idempotency_key": self.idempotency_key,
157
+ "metadata": dict(self.metadata),
158
+ "profile_id": self.profile_id,
159
+ "role": self.role,
160
+ "scope": self.scope,
161
+ "session_date": self.session_date,
162
+ "session_id": self.session_id,
163
+ "shared_with": list(self.shared_with),
164
+ "source_type": self.source_type,
165
+ "speaker": self.speaker,
166
+ "trusted_actor_id": self.trusted_actor_id,
167
+ }
168
+
169
+ @classmethod
170
+ def from_payload(cls, payload: Mapping[str, Any]) -> RememberRequest:
171
+ return cls(
172
+ content=str(payload["content"]),
173
+ profile_id=str(payload["profile_id"]),
174
+ source_type=str(payload["source_type"]),
175
+ idempotency_key=str(payload["idempotency_key"]),
176
+ metadata=dict(payload.get("metadata") or {}),
177
+ scope=str(payload.get("scope") or "personal"),
178
+ shared_with=tuple(payload.get("shared_with") or ()),
179
+ trusted_actor_id=str(payload.get("trusted_actor_id") or ""),
180
+ session_id=str(payload.get("session_id") or ""),
181
+ session_date=str(payload.get("session_date") or ""),
182
+ speaker=str(payload.get("speaker") or ""),
183
+ role=str(payload.get("role") or "user"),
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class AdmissionEntry:
189
+ """Content-free journal metadata safe for status and recovery decisions."""
190
+
191
+ journal_id: str
192
+ idempotency_key: str
193
+ request_hash: str
194
+ profile_id: str
195
+ state: str
196
+ canonical_operation_id: str | None
197
+ canonical_commit_sequence: int | None
198
+ error_code: str | None
199
+ created_at_ms: int
200
+ updated_at_ms: int
201
+ original_receipt: dict[str, Any] | None = None
202
+
203
+
204
+ PreparedAdmission = AdmissionEntry
205
+
206
+
207
+ class AdmissionJournal:
208
+ """Synchronous idempotency journal stored independently from ``memory.db``."""
209
+
210
+ def __init__(self, path: str | Path, *, codec: CommandCodec) -> None:
211
+ self.path = Path(path)
212
+ self.path.parent.mkdir(parents=True, exist_ok=True)
213
+ self._codec = codec
214
+ # The daemon is the sole journal owner, but many HTTP/MCP request
215
+ # threads can prepare and transition entries concurrently. SQLite has
216
+ # one writer, so admit those tiny FULL-synchronous transactions through
217
+ # one process-local lock instead of letting BEGIN IMMEDIATE race and
218
+ # leak SQLITE_BUSY to remember callers.
219
+ self._write_lock = threading.RLock()
220
+ self._initialize()
221
+
222
+ def prepare(
223
+ self,
224
+ request: RememberRequest,
225
+ actor: Actor,
226
+ *,
227
+ deadline: float | None = None,
228
+ ) -> PreparedAdmission:
229
+ """Durably prepare one encrypted replay command before dispatch."""
230
+ if not actor.principal_id.strip() or not actor.permits(request.profile_id, request.scope):
231
+ raise AdmissionAuthorizationError(
232
+ "actor is not authorized for requested profile or scope"
233
+ )
234
+ if request.trusted_actor_id and request.trusted_actor_id != actor.principal_id:
235
+ raise AdmissionAuthorizationError(
236
+ "trusted actor does not match authenticated principal"
237
+ )
238
+
239
+ payload = request.canonical_payload()
240
+ plaintext = _canonical_bytes(payload)
241
+ if len(plaintext) > _MAX_COMMAND_BYTES:
242
+ raise AdmissionPayloadError("remember command exceeds journal payload limit")
243
+ encrypted = self._codec.encrypt(plaintext)
244
+ if not isinstance(encrypted, bytes) or not encrypted:
245
+ raise AdmissionPayloadError("configured command codec returned no ciphertext")
246
+ command_json = json.dumps({"ciphertext_b64": base64.b64encode(encrypted).decode("ascii")})
247
+ request_hash = hashlib.sha256(plaintext).hexdigest()
248
+ now = _now_ms()
249
+ journal_id = uuid.uuid4().hex
250
+
251
+ with self._write_transaction(deadline=deadline) as conn:
252
+ existing = conn.execute(
253
+ "SELECT * FROM admission_journal "
254
+ "WHERE profile_id=? AND idempotency_key=?",
255
+ (request.profile_id, request.idempotency_key),
256
+ ).fetchone()
257
+ if existing is not None:
258
+ entry = self._entry_from_row(existing)
259
+ if entry.request_hash != request_hash:
260
+ raise IdempotencyConflict(
261
+ "idempotency key belongs to a different immutable request"
262
+ )
263
+ return entry
264
+ conn.execute(
265
+ "INSERT INTO admission_journal "
266
+ "(journal_id, idempotency_key, request_hash, profile_id, command_json, state, "
267
+ "created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)",
268
+ (
269
+ journal_id,
270
+ request.idempotency_key,
271
+ request_hash,
272
+ request.profile_id,
273
+ command_json,
274
+ now,
275
+ now,
276
+ ),
277
+ )
278
+ row = conn.execute(
279
+ "SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
280
+ ).fetchone()
281
+ assert row is not None
282
+ return self._entry_from_row(row)
283
+
284
+ def request_for(
285
+ self,
286
+ entry: AdmissionEntry,
287
+ *,
288
+ deadline: float | None = None,
289
+ ) -> RememberRequest:
290
+ """Decrypt the minimal replay body only immediately before canonical work."""
291
+ with self._read_connection(deadline=deadline) as conn:
292
+ row = conn.execute(
293
+ "SELECT command_json FROM admission_journal WHERE journal_id=?", (entry.journal_id,)
294
+ ).fetchone()
295
+ if row is None:
296
+ raise KeyError(entry.journal_id)
297
+ try:
298
+ encoded = json.loads(str(row["command_json"]))["ciphertext_b64"]
299
+ plaintext = self._codec.decrypt(base64.b64decode(encoded, validate=True))
300
+ payload = json.loads(plaintext.decode("utf-8"))
301
+ except (
302
+ KeyError,
303
+ TypeError,
304
+ UnicodeDecodeError,
305
+ ValueError,
306
+ binascii.Error,
307
+ json.JSONDecodeError,
308
+ InvalidTag,
309
+ ) as exc:
310
+ raise AdmissionPayloadError(
311
+ "journal command cannot be decrypted by the configured policy"
312
+ ) from exc
313
+ request = RememberRequest.from_payload(payload)
314
+ _remaining_seconds(deadline)
315
+ return request
316
+
317
+ def mark_dispatched(
318
+ self,
319
+ journal_id: str,
320
+ *,
321
+ deadline: float | None = None,
322
+ ) -> AdmissionEntry:
323
+ # A concurrent retry may observe ``prepared`` and then lose the race to
324
+ # another caller that commits the same idempotent command. Treat that
325
+ # terminal state as a successful no-op so the retry can return the
326
+ # canonical receipt instead of surfacing a false transition failure.
327
+ return self._transition(
328
+ journal_id,
329
+ target="dispatched",
330
+ allowed={"prepared", "dispatched", "committed"},
331
+ deadline=deadline,
332
+ )
333
+
334
+ def mark_rejected(
335
+ self,
336
+ journal_id: str,
337
+ error_code: str,
338
+ *,
339
+ deadline: float | None = None,
340
+ ) -> AdmissionEntry:
341
+ if not error_code or len(error_code) > 128:
342
+ raise ValueError("error_code is required and bounded")
343
+ return self._transition(
344
+ journal_id,
345
+ target="rejected",
346
+ allowed={"prepared", "dispatched", "rejected"},
347
+ error_code=error_code,
348
+ deadline=deadline,
349
+ )
350
+
351
+ def mark_committed(
352
+ self,
353
+ journal_id: str,
354
+ receipt: Mapping[str, Any],
355
+ *,
356
+ deadline: float | None = None,
357
+ ) -> AdmissionEntry:
358
+ receipt_json = _receipt_json(receipt)
359
+ data = json.loads(receipt_json)
360
+ operation_id = data.get("operation_id")
361
+ commit_sequence = data.get("commit_sequence")
362
+ if operation_id is not None and not isinstance(operation_id, str):
363
+ raise ValueError("receipt operation_id must be a string")
364
+ if commit_sequence is not None and not isinstance(commit_sequence, int):
365
+ raise ValueError("receipt commit_sequence must be an integer")
366
+ return self._transition(
367
+ journal_id,
368
+ target="committed",
369
+ allowed={"prepared", "dispatched", "rejected", "committed"},
370
+ receipt_json=receipt_json,
371
+ operation_id=operation_id,
372
+ commit_sequence=commit_sequence,
373
+ deadline=deadline,
374
+ )
375
+
376
+ def get(self, journal_id: str) -> AdmissionEntry:
377
+ with self._read_connection() as conn:
378
+ row = conn.execute(
379
+ "SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
380
+ ).fetchone()
381
+ if row is None:
382
+ raise KeyError(journal_id)
383
+ return self._entry_from_row(row)
384
+
385
+ def get_by_idempotency_key(
386
+ self, profile_id: str, idempotency_key: str
387
+ ) -> AdmissionEntry | None:
388
+ """Return a profile-scoped retry record, never a cross-profile match."""
389
+ with self._read_connection() as conn:
390
+ row = conn.execute(
391
+ "SELECT * FROM admission_journal WHERE profile_id=? AND idempotency_key=?",
392
+ (profile_id, idempotency_key),
393
+ ).fetchone()
394
+ return self._entry_from_row(row) if row is not None else None
395
+
396
+ def count(self) -> int:
397
+ with self._read_connection() as conn:
398
+ return int(conn.execute("SELECT COUNT(*) FROM admission_journal").fetchone()[0])
399
+
400
+ def replay_pending(
401
+ self,
402
+ find_canonical_receipt: Callable[[AdmissionEntry], Mapping[str, Any] | None],
403
+ dispatch: Callable[[AdmissionEntry, RememberRequest], Mapping[str, Any]],
404
+ *,
405
+ profile_id: str | None = None,
406
+ ) -> int:
407
+ """Resolve crash-surviving entries without duplicate canonical writes.
408
+
409
+ A daemon runtime is bound to one active profile at a time. Pending
410
+ commands for other profiles remain durable until that profile is
411
+ rebound; dispatching them through the wrong profile writer would make
412
+ one abandoned command prevent the daemon from starting.
413
+ """
414
+ with self._read_connection() as conn:
415
+ if profile_id is None:
416
+ rows = conn.execute(
417
+ "SELECT * FROM admission_journal "
418
+ "WHERE state IN ('prepared', 'dispatched') "
419
+ "ORDER BY created_at_ms, journal_id"
420
+ ).fetchall()
421
+ else:
422
+ rows = conn.execute(
423
+ "SELECT * FROM admission_journal "
424
+ "WHERE state IN ('prepared', 'dispatched') AND profile_id=? "
425
+ "ORDER BY created_at_ms, journal_id",
426
+ (profile_id,),
427
+ ).fetchall()
428
+ recovered = 0
429
+ for row in rows:
430
+ entry = self._entry_from_row(row)
431
+ try:
432
+ canonical = find_canonical_receipt(entry)
433
+ if canonical is None:
434
+ canonical = dispatch(entry, self.request_for(entry))
435
+ except TerminalAdmissionError as exc:
436
+ self.mark_rejected(entry.journal_id, exc.error_code)
437
+ recovered += 1
438
+ continue
439
+ self.mark_committed(entry.journal_id, canonical)
440
+ recovered += 1
441
+ return recovered
442
+
443
+ def _transition(
444
+ self,
445
+ journal_id: str,
446
+ *,
447
+ target: str,
448
+ allowed: set[str],
449
+ error_code: str | None = None,
450
+ receipt_json: str | None = None,
451
+ operation_id: str | None = None,
452
+ commit_sequence: int | None = None,
453
+ deadline: float | None = None,
454
+ ) -> AdmissionEntry:
455
+ with self._write_transaction(deadline=deadline) as conn:
456
+ row = conn.execute(
457
+ "SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
458
+ ).fetchone()
459
+ if row is None:
460
+ raise KeyError(journal_id)
461
+ previous = self._entry_from_row(row)
462
+ if previous.state not in allowed:
463
+ if previous.state == "committed":
464
+ raise ValueError("journal entry is already committed")
465
+ raise ValueError(f"illegal admission transition {previous.state} -> {target}")
466
+ if previous.state == "committed":
467
+ return previous
468
+ conn.execute(
469
+ "UPDATE admission_journal SET "
470
+ "state=?, "
471
+ "canonical_operation_id=COALESCE(?, canonical_operation_id), "
472
+ "canonical_commit_sequence=COALESCE(?, canonical_commit_sequence), "
473
+ "error_code=?, "
474
+ "receipt_json=COALESCE(?, receipt_json), "
475
+ "updated_at_ms=? WHERE journal_id=? AND state=?",
476
+ (
477
+ target,
478
+ operation_id,
479
+ commit_sequence,
480
+ error_code,
481
+ receipt_json,
482
+ _now_ms(),
483
+ journal_id,
484
+ previous.state,
485
+ ),
486
+ )
487
+ updated = conn.execute(
488
+ "SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
489
+ ).fetchone()
490
+ assert updated is not None
491
+ return self._entry_from_row(updated)
492
+
493
+ def _initialize(self) -> None:
494
+ with self._connection() as conn:
495
+ conn.execute("PRAGMA journal_mode=WAL")
496
+ if self._has_legacy_global_idempotency_key(conn):
497
+ self._upgrade_legacy_schema(conn)
498
+ else:
499
+ _create_journal_schema(conn)
500
+
501
+ @contextmanager
502
+ def _write_transaction(
503
+ self,
504
+ *,
505
+ deadline: float | None = None,
506
+ ) -> Generator[sqlite3.Connection, None, None]:
507
+ """Serialize and atomically commit one deadline-bounded mutation."""
508
+ if deadline is None:
509
+ acquired = self._write_lock.acquire()
510
+ else:
511
+ acquired = self._write_lock.acquire(
512
+ timeout=max(0.0, deadline - time.monotonic())
513
+ )
514
+ if not acquired:
515
+ raise AdmissionJournalUnavailable(
516
+ "admission journal deadline expired waiting for its writer"
517
+ )
518
+ try:
519
+ remaining = _remaining_seconds(deadline)
520
+ with self._connection(timeout=remaining) as conn:
521
+ try:
522
+ conn.execute("BEGIN IMMEDIATE")
523
+ if deadline is not None and time.monotonic() >= deadline:
524
+ raise AdmissionJournalUnavailable(
525
+ "admission journal deadline expired before its mutation"
526
+ )
527
+ yield conn
528
+ if deadline is not None and time.monotonic() >= deadline:
529
+ raise AdmissionJournalUnavailable(
530
+ "admission journal deadline expired during its mutation"
531
+ )
532
+ conn.commit()
533
+ except sqlite3.OperationalError as exc:
534
+ conn.rollback()
535
+ if _is_sqlite_busy(exc):
536
+ raise AdmissionJournalUnavailable(
537
+ "admission journal is busy beyond its caller deadline"
538
+ ) from exc
539
+ raise
540
+ except BaseException:
541
+ conn.rollback()
542
+ raise
543
+ finally:
544
+ self._write_lock.release()
545
+
546
+ @contextmanager
547
+ def _read_connection(
548
+ self,
549
+ *,
550
+ deadline: float | None = None,
551
+ ) -> Generator[sqlite3.Connection, None, None]:
552
+ """Open a bounded journal reader without leaking SQLite lock errors."""
553
+ try:
554
+ with self._connection(timeout=_remaining_seconds(deadline)) as conn:
555
+ yield conn
556
+ except sqlite3.OperationalError as exc:
557
+ if _is_sqlite_busy(exc):
558
+ raise AdmissionJournalUnavailable(
559
+ "admission journal is busy beyond its caller deadline"
560
+ ) from exc
561
+ raise
562
+
563
+ @staticmethod
564
+ def _has_legacy_global_idempotency_key(conn: sqlite3.Connection) -> bool:
565
+ table = conn.execute(
566
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='admission_journal'"
567
+ ).fetchone()
568
+ if table is None:
569
+ return False
570
+ return _has_unique_index(conn, "admission_journal", ("idempotency_key",))
571
+
572
+ @staticmethod
573
+ def _upgrade_legacy_schema(conn: sqlite3.Connection) -> None:
574
+ """Replace only the provisional global-key table without losing journals."""
575
+ conn.execute("SAVEPOINT admission_journal_profile_key_upgrade")
576
+ try:
577
+ conn.execute("ALTER TABLE admission_journal RENAME TO admission_journal_legacy")
578
+ conn.execute("DROP INDEX IF EXISTS idx_admission_replay")
579
+ _create_journal_schema(conn)
580
+ conn.execute(
581
+ "INSERT INTO admission_journal("
582
+ "journal_id, idempotency_key, request_hash, profile_id, command_json, state, "
583
+ "canonical_operation_id, canonical_commit_sequence, error_code, receipt_json, "
584
+ "created_at_ms, updated_at_ms"
585
+ ") SELECT journal_id, idempotency_key, request_hash, profile_id, command_json, "
586
+ "state, canonical_operation_id, canonical_commit_sequence, error_code, "
587
+ "receipt_json, "
588
+ "created_at_ms, updated_at_ms FROM admission_journal_legacy"
589
+ )
590
+ conn.execute("DROP TABLE admission_journal_legacy")
591
+ except BaseException:
592
+ conn.execute("ROLLBACK TO admission_journal_profile_key_upgrade")
593
+ conn.execute("RELEASE admission_journal_profile_key_upgrade")
594
+ raise
595
+ conn.execute("RELEASE admission_journal_profile_key_upgrade")
596
+
597
+ @contextmanager
598
+ def _connection(
599
+ self,
600
+ *,
601
+ timeout: float = 1.0,
602
+ ) -> Generator[sqlite3.Connection, None, None]:
603
+ bounded_timeout = max(0.001, timeout)
604
+ busy_timeout_ms = max(1, int(bounded_timeout * 1_000))
605
+ conn = sqlite3.connect(str(self.path), timeout=bounded_timeout)
606
+ try:
607
+ conn.row_factory = sqlite3.Row
608
+ conn.execute("PRAGMA synchronous=FULL")
609
+ conn.execute("PRAGMA foreign_keys=ON")
610
+ conn.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
611
+ yield conn
612
+ finally:
613
+ conn.close()
614
+
615
+ @staticmethod
616
+ def _entry_from_row(row: sqlite3.Row) -> AdmissionEntry:
617
+ receipt_raw = row["receipt_json"]
618
+ receipt = json.loads(receipt_raw) if receipt_raw else None
619
+ return AdmissionEntry(
620
+ journal_id=str(row["journal_id"]),
621
+ idempotency_key=str(row["idempotency_key"]),
622
+ request_hash=str(row["request_hash"]),
623
+ profile_id=str(row["profile_id"]),
624
+ state=str(row["state"]),
625
+ canonical_operation_id=row["canonical_operation_id"],
626
+ canonical_commit_sequence=row["canonical_commit_sequence"],
627
+ error_code=row["error_code"],
628
+ created_at_ms=int(row["created_at_ms"]),
629
+ updated_at_ms=int(row["updated_at_ms"]),
630
+ original_receipt=receipt,
631
+ )
632
+
633
+
634
+ def _canonical_bytes(value: Mapping[str, Any]) -> bytes:
635
+ try:
636
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
637
+ "utf-8"
638
+ )
639
+ except (TypeError, ValueError) as exc:
640
+ raise AdmissionPayloadError("remember command must be JSON serializable") from exc
641
+
642
+
643
+ def _receipt_json(receipt: Mapping[str, Any]) -> str:
644
+ if not isinstance(receipt, Mapping):
645
+ raise ValueError("receipt must be an object")
646
+ _validate_json(dict(receipt), "receipt")
647
+ _reject_raw_content(receipt)
648
+ rendered = _canonical_bytes(dict(receipt))
649
+ if len(rendered) > _MAX_RECEIPT_BYTES:
650
+ raise ValueError("receipt exceeds journal receipt limit")
651
+ return rendered.decode("utf-8")
652
+
653
+
654
+ def _validate_json(value: Any, label: str, depth: int = 0) -> None:
655
+ if depth > _MAX_METADATA_DEPTH:
656
+ raise AdmissionPayloadError(f"{label} nesting exceeds limit")
657
+ if isinstance(value, Mapping):
658
+ for key, child in value.items():
659
+ if not isinstance(key, str):
660
+ raise AdmissionPayloadError(f"{label} keys must be strings")
661
+ _validate_json(child, label, depth + 1)
662
+ elif isinstance(value, (list, tuple)):
663
+ for child in value:
664
+ _validate_json(child, label, depth + 1)
665
+ elif not isinstance(value, (str, int, float, bool, type(None))):
666
+ raise AdmissionPayloadError(f"{label} must be JSON serializable")
667
+
668
+
669
+ def _reject_raw_content(value: Any) -> None:
670
+ if isinstance(value, Mapping):
671
+ for key, child in value.items():
672
+ if key.casefold() in {
673
+ "content",
674
+ "content_preview",
675
+ "raw_content",
676
+ "memory_content",
677
+ "source_content",
678
+ }:
679
+ raise ValueError("receipt must not include raw memory content")
680
+ _reject_raw_content(child)
681
+ elif isinstance(value, (list, tuple)):
682
+ for child in value:
683
+ _reject_raw_content(child)
684
+
685
+
686
+ def _now_ms() -> int:
687
+ return int(time.time() * 1000)
688
+
689
+
690
+ def _remaining_seconds(deadline: float | None) -> float:
691
+ if deadline is None:
692
+ return 1.0
693
+ remaining = deadline - time.monotonic()
694
+ if remaining <= 0:
695
+ raise AdmissionJournalUnavailable("admission journal deadline expired")
696
+ return remaining
697
+
698
+
699
+ def _is_sqlite_busy(error: sqlite3.OperationalError) -> bool:
700
+ """Recognize primary and extended SQLite BUSY/LOCKED result codes."""
701
+ code = getattr(error, "sqlite_errorcode", None)
702
+ if isinstance(code, int) and (code & 0xFF) in {
703
+ sqlite3.SQLITE_BUSY,
704
+ sqlite3.SQLITE_LOCKED,
705
+ }:
706
+ return True
707
+ message = str(error).casefold()
708
+ return "locked" in message or "busy" in message
709
+
710
+
711
+ def _has_unique_index(
712
+ conn: sqlite3.Connection, table: str, columns: tuple[str, ...]
713
+ ) -> bool:
714
+ """Return whether SQLite enforces exactly these columns as a unique key."""
715
+ for index in conn.execute(f"PRAGMA index_list({table})").fetchall():
716
+ if not index[2]:
717
+ continue
718
+ names = tuple(
719
+ row[2] for row in conn.execute(f"PRAGMA index_info({index[1]})").fetchall()
720
+ )
721
+ if names == columns:
722
+ return True
723
+ return False
724
+
725
+
726
+ def _create_journal_schema(conn: sqlite3.Connection) -> None:
727
+ conn.execute(_JOURNAL_TABLE_DDL)
728
+ conn.execute(_JOURNAL_REPLAY_INDEX_DDL)