superlocalmemory 3.8.13 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -0,0 +1,542 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Operational Recovery & Admin Remediation helpers (Wave-3 resilience slice).
5
+
6
+ Provides two primary functions used by HTTP endpoints, MCP tools, and CLI:
7
+
8
+ list_failed_operations(db_path, profile_id=None) -> dict
9
+ - dead_letter: ingestion ops that exhausted automatic retries (M031)
10
+ - degraded_manifests: completion_manifests in DEGRADED state
11
+ - exhausted_obligations: projection_obligations FAILED with attempts >= MAX
12
+
13
+ resolve_operation(db_path, engine, operation_id, action) -> dict
14
+ action ∈ {"retry", "force_reconcile", "cancel"}
15
+ - retry: re-enqueue a dead-lettered ingestion op via IngestionCommand.retry()
16
+ - force_reconcile: immediate obligation redrive (bypasses 30s throttle)
17
+ - cancel: mark op terminally cancelled and remove from failure surfaces
18
+
19
+ Design constraints (NON-NEGOTIABLE):
20
+ - No schema migration — surface via existing tables only
21
+ - Pure reads in list_failed_operations (no mutation)
22
+ - Additive & backward-compatible — healthy-path unaffected
23
+ - Immutable return dicts; explicit error handling; no silent swallowing
24
+
25
+ Part of SuperLocalMemory V4 | Wave-3: Operational Recovery
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import sqlite3
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Maximum automatic materialization/apply attempts (mirrors service.py + ingestion_command.py)
39
+ _MAX_ATTEMPTS = 10
40
+
41
+ _VALID_ACTIONS = frozenset({"retry", "force_reconcile", "cancel"})
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Public API — list_failed_operations
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def list_failed_operations(
50
+ db_path: str | Path,
51
+ profile_id: str | None = None,
52
+ ) -> dict[str, Any]:
53
+ """Collect all failure-surface items from memory.db.
54
+
55
+ Returns an immutable-shaped dict:
56
+ {
57
+ "dead_letter": [{"category": "dead_letter", "operation_id": ..., ...}],
58
+ "degraded_manifests": [...],
59
+ "exhausted_obligations": [...],
60
+ "total": int,
61
+ }
62
+
63
+ Each entry carries at minimum: category, operation_id, profile_id, error/state,
64
+ attempts (where applicable), and when (unix epoch float).
65
+
66
+ Raises nothing — all errors are logged and returned as empty lists.
67
+ """
68
+ db_path = Path(db_path)
69
+ dead_letter: list[dict] = []
70
+ degraded_manifests: list[dict] = []
71
+ exhausted_obligations: list[dict] = []
72
+
73
+ try:
74
+ conn = sqlite3.connect(str(db_path))
75
+ conn.row_factory = sqlite3.Row
76
+ try:
77
+ dead_letter = _fetch_dead_letter(conn, profile_id)
78
+ degraded_manifests = _fetch_degraded_manifests(conn, profile_id)
79
+ exhausted_obligations = _fetch_exhausted_obligations(conn, profile_id)
80
+ finally:
81
+ conn.close()
82
+ except Exception as exc: # noqa: BLE001
83
+ logger.error("list_failed_operations: DB query failed: %s", exc)
84
+
85
+ total = len(dead_letter) + len(degraded_manifests) + len(exhausted_obligations)
86
+ return {
87
+ "dead_letter": dead_letter,
88
+ "degraded_manifests": degraded_manifests,
89
+ "exhausted_obligations": exhausted_obligations,
90
+ "total": total,
91
+ }
92
+
93
+
94
+ def _fetch_dead_letter(
95
+ conn: sqlite3.Connection,
96
+ profile_id: str | None,
97
+ ) -> list[dict]:
98
+ """Read dead_letter_operations rows (M031 table)."""
99
+ try:
100
+ if profile_id is not None:
101
+ rows = conn.execute(
102
+ "SELECT original_op_id, error, attempt_count, profile_id, "
103
+ "dead_lettered_at FROM dead_letter_operations WHERE profile_id = ? "
104
+ "ORDER BY dead_lettered_at DESC LIMIT 200",
105
+ (profile_id,),
106
+ ).fetchall()
107
+ else:
108
+ rows = conn.execute(
109
+ "SELECT original_op_id, error, attempt_count, profile_id, "
110
+ "dead_lettered_at FROM dead_letter_operations "
111
+ "ORDER BY dead_lettered_at DESC LIMIT 200",
112
+ ).fetchall()
113
+ except sqlite3.Error:
114
+ # Table may not exist on older DBs — fail gracefully
115
+ return []
116
+
117
+ result: list[dict] = []
118
+ for row in rows:
119
+ entry = {
120
+ "category": "dead_letter",
121
+ "operation_id": row["original_op_id"],
122
+ "error": row["error"] or "",
123
+ "attempts": row["attempt_count"] or 0,
124
+ "profile_id": row["profile_id"] or "",
125
+ "when": row["dead_lettered_at"],
126
+ "what_happened": "Ingestion failed after maximum retries — needs manual action.",
127
+ }
128
+ result.append(entry)
129
+ return result
130
+
131
+
132
+ def _fetch_degraded_manifests(
133
+ conn: sqlite3.Connection,
134
+ profile_id: str | None,
135
+ ) -> list[dict]:
136
+ """Read completion_manifests rows in DEGRADED state (M033 table)."""
137
+ try:
138
+ if profile_id is not None:
139
+ rows = conn.execute(
140
+ "SELECT operation_id, profile_id, state, updated_at "
141
+ "FROM completion_manifests WHERE state = 'DEGRADED' AND profile_id = ? "
142
+ "ORDER BY updated_at DESC LIMIT 200",
143
+ (profile_id,),
144
+ ).fetchall()
145
+ else:
146
+ rows = conn.execute(
147
+ "SELECT operation_id, profile_id, state, updated_at "
148
+ "FROM completion_manifests WHERE state = 'DEGRADED' "
149
+ "ORDER BY updated_at DESC LIMIT 200",
150
+ ).fetchall()
151
+ except sqlite3.Error:
152
+ return []
153
+
154
+ result: list[dict] = []
155
+ for row in rows:
156
+ entry = {
157
+ "category": "degraded_manifest",
158
+ "operation_id": row["operation_id"],
159
+ "state": row["state"],
160
+ "profile_id": row["profile_id"] or "",
161
+ "when": row["updated_at"],
162
+ "what_happened": "Operation completed partially — some projections did not apply.",
163
+ }
164
+ result.append(entry)
165
+ return result
166
+
167
+
168
+ def _fetch_exhausted_obligations(
169
+ conn: sqlite3.Connection,
170
+ profile_id: str | None,
171
+ ) -> list[dict]:
172
+ """Read projection_obligations rows that are FAILED and exhausted (M033 table).
173
+
174
+ Exhausted = state='failed' AND attempts >= _MAX_ATTEMPTS AND NOT admin-cancelled.
175
+ We exclude admin-cancelled obligations (detail contains 'admin_cancel') so they
176
+ don't resurface after cancellation.
177
+ """
178
+ try:
179
+ if profile_id is not None:
180
+ rows = conn.execute(
181
+ "SELECT DISTINCT operation_id, profile_id, "
182
+ "MAX(attempts) AS attempts, MAX(updated_at) AS updated_at, detail "
183
+ "FROM projection_obligations "
184
+ "WHERE state = 'failed' AND attempts >= ? AND profile_id = ? "
185
+ "GROUP BY operation_id "
186
+ "ORDER BY updated_at DESC LIMIT 200",
187
+ (_MAX_ATTEMPTS, profile_id),
188
+ ).fetchall()
189
+ else:
190
+ rows = conn.execute(
191
+ "SELECT DISTINCT operation_id, profile_id, "
192
+ "MAX(attempts) AS attempts, MAX(updated_at) AS updated_at, detail "
193
+ "FROM projection_obligations "
194
+ "WHERE state = 'failed' AND attempts >= ? "
195
+ "GROUP BY operation_id "
196
+ "ORDER BY updated_at DESC LIMIT 200",
197
+ (_MAX_ATTEMPTS,),
198
+ ).fetchall()
199
+ except sqlite3.Error:
200
+ return []
201
+
202
+ result: list[dict] = []
203
+ for row in rows:
204
+ # Skip admin-cancelled obligations
205
+ detail_raw = row["detail"] if row["detail"] is not None else ""
206
+ if '"admin_cancel"' in detail_raw or '"admin_cancelled"' in detail_raw:
207
+ continue
208
+ entry = {
209
+ "category": "exhausted_obligation",
210
+ "operation_id": row["operation_id"],
211
+ "attempts": row["attempts"] or 0,
212
+ "profile_id": row["profile_id"] or "",
213
+ "when": row["updated_at"],
214
+ "what_happened": (
215
+ "Background sync failed after maximum retries — "
216
+ "use Force Re-sync or Cancel."
217
+ ),
218
+ }
219
+ result.append(entry)
220
+ return result
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Public API — resolve_operation
225
+ # ---------------------------------------------------------------------------
226
+
227
+
228
+ def resolve_operation(
229
+ db_path: str | Path,
230
+ engine: Any,
231
+ operation_id: str,
232
+ action: str,
233
+ ) -> dict[str, Any]:
234
+ """Resolve a failed/stuck operation.
235
+
236
+ Parameters
237
+ ----------
238
+ db_path
239
+ Path to memory.db (used for DLQ + obligation queries).
240
+ engine
241
+ The running engine instance (may be None for cancel-only operations).
242
+ operation_id
243
+ The operation to act upon.
244
+ action
245
+ One of: "retry", "force_reconcile", "cancel".
246
+
247
+ Returns a dict: {"success": bool, "action": str, "operation_id": str, ...}
248
+ Raises ValueError for invalid action.
249
+ """
250
+ if action not in _VALID_ACTIONS:
251
+ raise ValueError(
252
+ f"invalid action {action!r}; must be one of {sorted(_VALID_ACTIONS)}"
253
+ )
254
+
255
+ db_path = Path(db_path)
256
+
257
+ if action == "cancel":
258
+ return _action_cancel(db_path, operation_id)
259
+ if action == "retry":
260
+ return _action_retry(db_path, engine, operation_id)
261
+ if action == "force_reconcile":
262
+ return _action_force_reconcile(db_path, engine, operation_id)
263
+
264
+ # Unreachable — guarded by _VALID_ACTIONS check above
265
+ raise ValueError(f"unhandled action {action!r}") # pragma: no cover
266
+
267
+
268
+ def _action_cancel(db_path: Path, operation_id: str) -> dict:
269
+ """Cancel an operation: remove from DLQ, mark obligations as admin-cancelled."""
270
+ found = False
271
+ try:
272
+ conn = sqlite3.connect(str(db_path))
273
+ try:
274
+ # Remove from dead_letter_operations
275
+ cursor = conn.execute(
276
+ "DELETE FROM dead_letter_operations WHERE original_op_id = ?",
277
+ (operation_id,),
278
+ )
279
+ if cursor.rowcount > 0:
280
+ found = True
281
+
282
+ # Mark exhausted obligations as cancelled (so they don't resurface)
283
+ import json
284
+ cancel_detail = json.dumps({"admin_cancel": True, "cancelled_at": time.time()})
285
+ cursor2 = conn.execute(
286
+ "UPDATE projection_obligations SET state = 'failed', "
287
+ "detail = ?, attempts = MAX(attempts, ?) "
288
+ "WHERE operation_id = ? AND state = 'failed'",
289
+ (cancel_detail, _MAX_ATTEMPTS, operation_id),
290
+ )
291
+ if cursor2.rowcount > 0:
292
+ found = True
293
+
294
+ # Mark degraded manifest if present
295
+ cursor3 = conn.execute(
296
+ "UPDATE completion_manifests SET state = 'FAILED' "
297
+ "WHERE operation_id = ? AND state = 'DEGRADED'",
298
+ (operation_id,),
299
+ )
300
+ if cursor3.rowcount > 0:
301
+ found = True
302
+
303
+ conn.commit()
304
+ finally:
305
+ conn.close()
306
+ except Exception as exc: # noqa: BLE001
307
+ logger.error("resolve_operation cancel %s: %s", operation_id, exc)
308
+ return {
309
+ "success": False,
310
+ "action": "cancel",
311
+ "operation_id": operation_id,
312
+ "reason": str(exc),
313
+ }
314
+
315
+ if not found:
316
+ return {
317
+ "success": False,
318
+ "action": "cancel",
319
+ "operation_id": operation_id,
320
+ "reason": "not_found — operation not in any failure surface",
321
+ }
322
+
323
+ return {
324
+ "success": True,
325
+ "action": "cancel",
326
+ "operation_id": operation_id,
327
+ "message": "Operation cancelled. It will no longer appear in the attention list.",
328
+ }
329
+
330
+
331
+ def _action_retry(db_path: Path, engine: Any, operation_id: str) -> dict:
332
+ """Retry a dead-lettered ingestion operation via IngestionCommand.retry()."""
333
+ # Check that the operation exists in DLQ
334
+ try:
335
+ conn = sqlite3.connect(str(db_path))
336
+ try:
337
+ row = conn.execute(
338
+ "SELECT id FROM dead_letter_operations WHERE original_op_id = ? LIMIT 1",
339
+ (operation_id,),
340
+ ).fetchone()
341
+ finally:
342
+ conn.close()
343
+ except Exception as exc:
344
+ logger.error("resolve_operation retry DLQ check %s: %s", operation_id, exc)
345
+ return {
346
+ "success": False,
347
+ "action": "retry",
348
+ "operation_id": operation_id,
349
+ "reason": str(exc),
350
+ }
351
+
352
+ if row is None:
353
+ return {
354
+ "success": False,
355
+ "action": "retry",
356
+ "operation_id": operation_id,
357
+ "reason": "not_found — operation not in dead-letter queue",
358
+ }
359
+
360
+ # Attempt retry via IngestionCommand
361
+ if engine is not None:
362
+ try:
363
+ # Build a fully-wired ingestion command from the live engine
364
+ # (db + queryable writer + materializer). Constructing
365
+ # IngestionCommand directly would miss the required
366
+ # write_queryable/materialize collaborators.
367
+ from superlocalmemory.core.engine_ingestion import (
368
+ build_engine_ingestion_command,
369
+ )
370
+ from superlocalmemory.core.ingestion_command import IngestionState
371
+ cmd = build_engine_ingestion_command(engine)
372
+ operation = cmd.retry(operation_id)
373
+ # G-10: only remove from DLQ if the retry actually moved the
374
+ # operation out of FAILED state. If it is still FAILED, leave
375
+ # the DLQ row so it surfaces again on the next /ops list call
376
+ # and the operator can investigate.
377
+ if operation.state is IngestionState.FAILED:
378
+ return {
379
+ "success": False,
380
+ "action": "retry",
381
+ "operation_id": operation_id,
382
+ "reason": (
383
+ "retry_still_failed — operation remains in FAILED state;"
384
+ " DLQ row preserved for further investigation"
385
+ ),
386
+ }
387
+ # Operation moved out of FAILED → remove from DLQ
388
+ conn = sqlite3.connect(str(db_path))
389
+ try:
390
+ conn.execute(
391
+ "DELETE FROM dead_letter_operations WHERE original_op_id = ?",
392
+ (operation_id,),
393
+ )
394
+ conn.commit()
395
+ finally:
396
+ conn.close()
397
+ return {
398
+ "success": True,
399
+ "action": "retry",
400
+ "operation_id": operation_id,
401
+ "message": f"Operation re-queued for retry (state: {operation.state.value}).",
402
+ }
403
+ except Exception as exc:
404
+ logger.error("resolve_operation retry %s: %s", operation_id, exc)
405
+ return {
406
+ "success": False,
407
+ "action": "retry",
408
+ "operation_id": operation_id,
409
+ "reason": str(exc),
410
+ }
411
+ else:
412
+ # No engine — best effort: just clear the DLQ row so it doesn't resurface
413
+ try:
414
+ conn = sqlite3.connect(str(db_path))
415
+ try:
416
+ conn.execute(
417
+ "DELETE FROM dead_letter_operations WHERE original_op_id = ?",
418
+ (operation_id,),
419
+ )
420
+ conn.commit()
421
+ finally:
422
+ conn.close()
423
+ except Exception as exc:
424
+ logger.warning("resolve_operation retry (no engine) %s: %s", operation_id, exc)
425
+ return {
426
+ "success": True,
427
+ "action": "retry",
428
+ "operation_id": operation_id,
429
+ "message": "DLQ entry cleared. Daemon will retry on next cycle.",
430
+ }
431
+
432
+
433
+ def _action_force_reconcile(db_path: Path, engine: Any, operation_id: str) -> dict:
434
+ """Force an immediate projection reconcile for this operation_id."""
435
+ if engine is None:
436
+ return {
437
+ "success": False,
438
+ "action": "force_reconcile",
439
+ "operation_id": operation_id,
440
+ "reason": "engine not available — daemon must be running for force_reconcile",
441
+ }
442
+
443
+ try:
444
+ from superlocalmemory.core.transactions.concrete_owners import (
445
+ build_transaction_service,
446
+ )
447
+ from superlocalmemory.server.unified_daemon import _context_for_operation
448
+
449
+ db_obj = getattr(engine, "_db", None)
450
+ if db_obj is None:
451
+ return {
452
+ "success": False,
453
+ "action": "force_reconcile",
454
+ "operation_id": operation_id,
455
+ "reason": "engine._db not available",
456
+ }
457
+
458
+ context = _context_for_operation(engine, operation_id)
459
+ if context is None:
460
+ return {
461
+ "success": False,
462
+ "action": "force_reconcile",
463
+ "operation_id": operation_id,
464
+ "reason": "not_found — no canonical record for this operation",
465
+ }
466
+
467
+ service = build_transaction_service(engine)
468
+ service.reconcile_operation(db_obj, context)
469
+ return {
470
+ "success": True,
471
+ "action": "force_reconcile",
472
+ "operation_id": operation_id,
473
+ "message": "Reconcile triggered immediately (30s throttle bypassed).",
474
+ }
475
+ except Exception as exc:
476
+ logger.error("resolve_operation force_reconcile %s: %s", operation_id, exc)
477
+ return {
478
+ "success": False,
479
+ "action": "force_reconcile",
480
+ "operation_id": operation_id,
481
+ "reason": str(exc),
482
+ }
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Counts helpers (for /health and /status surface)
487
+ # ---------------------------------------------------------------------------
488
+
489
+
490
+ def get_failure_counts(db_path: str | Path) -> dict[str, int]:
491
+ """Fast count query for /health and /status surface.
492
+
493
+ Returns {"dead_letter_count": int, "degraded_operations": int,
494
+ "exhausted_obligations": int}.
495
+ All counts default to 0 on error (never raises).
496
+ """
497
+ db_path = Path(db_path)
498
+ counts = {"dead_letter_count": 0, "degraded_operations": 0, "exhausted_obligations": 0}
499
+ try:
500
+ conn = sqlite3.connect(str(db_path))
501
+ conn.row_factory = sqlite3.Row
502
+ try:
503
+ try:
504
+ row = conn.execute(
505
+ "SELECT COUNT(*) AS c FROM dead_letter_operations"
506
+ ).fetchone()
507
+ counts["dead_letter_count"] = int(row["c"]) if row else 0
508
+ except sqlite3.Error:
509
+ pass
510
+
511
+ try:
512
+ row = conn.execute(
513
+ "SELECT COUNT(*) AS c FROM completion_manifests WHERE state = 'DEGRADED'"
514
+ ).fetchone()
515
+ counts["degraded_operations"] = int(row["c"]) if row else 0
516
+ except sqlite3.Error:
517
+ pass
518
+
519
+ try:
520
+ row = conn.execute(
521
+ "SELECT COUNT(DISTINCT operation_id) AS c "
522
+ "FROM projection_obligations "
523
+ "WHERE state = 'failed' AND attempts >= ? "
524
+ "AND (detail IS NULL OR detail NOT LIKE '%admin_cancel%')",
525
+ (_MAX_ATTEMPTS,),
526
+ ).fetchone()
527
+ counts["exhausted_obligations"] = int(row["c"]) if row else 0
528
+ except sqlite3.Error:
529
+ pass
530
+ finally:
531
+ conn.close()
532
+ except Exception as exc: # noqa: BLE001
533
+ logger.debug("get_failure_counts: %s", exc)
534
+
535
+ return counts
536
+
537
+
538
+ __all__ = [
539
+ "list_failed_operations",
540
+ "resolve_operation",
541
+ "get_failure_counts",
542
+ ]
@@ -786,6 +786,7 @@ def run_recall(
786
786
  include_global: bool = False,
787
787
  include_shared: bool = False,
788
788
  window: str | tuple[str, str] | None = None,
789
+ as_of: str | None = None,
789
790
  ) -> RecallResponse:
790
791
  """Recall relevant facts for a query.
791
792
 
@@ -799,6 +800,11 @@ def run_recall(
799
800
  to the client-driven-agentic default (see ``resolve_hot_path_fast``): the
800
801
  agent hot path skips the internal round and delegates refinement to the
801
802
  calling LLM. ``fast=False`` forces the internal agentic round.
803
+
804
+ ``as_of``: Optional ISO 8601 datetime string for point-in-time time-travel
805
+ recall. When set, the bi-temporal validity filter demotes facts that were
806
+ not yet valid or had already expired at that point. Default ``None`` leaves
807
+ all existing behaviour unchanged.
802
808
  """
803
809
  m = mode or config.mode
804
810
 
@@ -829,6 +835,7 @@ def run_recall(
829
835
  include_global=include_global,
830
836
  include_shared=include_shared,
831
837
  window=window,
838
+ as_of=as_of,
832
839
  )
833
840
  _mark("retrieval(chan+rerank)")
834
841