superlocalmemory 3.6.22 → 3.7.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 (303) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +275 -72
  3. package/bin/slm-npm +43 -89
  4. package/docs/pi-dev-integration.md +43 -0
  5. package/ide/configs/antigravity-mcp.json +2 -2
  6. package/ide/configs/chatgpt-desktop-mcp.json +1 -1
  7. package/ide/configs/claude-desktop-mcp.json +2 -2
  8. package/ide/configs/windsurf-mcp.json +2 -2
  9. package/ide/hooks/context-hook.js +6 -2
  10. package/ide/hooks/post-recall-hook.js +7 -3
  11. package/ide/hooks/tool-event-hook.sh +2 -1
  12. package/package.json +19 -10
  13. package/plugin/.claude-plugin/plugin.json +1 -1
  14. package/plugin/_GENERATED.md +1 -1
  15. package/plugin/agents/slm-memory-advisor.md +1 -1
  16. package/plugin/requirements.txt +1 -1
  17. package/plugin/skills/slm-session/SKILL.md +1 -1
  18. package/plugin-src/rules/AGENTS.md +1 -1
  19. package/pyproject.toml +40 -8
  20. package/scripts/postinstall-interactive.js +17 -94
  21. package/scripts/postinstall.js +185 -258
  22. package/scripts/preuninstall.js +9 -50
  23. package/src/superlocalmemory/__init__.py +2 -2
  24. package/src/superlocalmemory/attribution/mathematical_dna.py +1 -1
  25. package/src/superlocalmemory/attribution/signer.py +34 -19
  26. package/src/superlocalmemory/attribution/watermark.py +1 -1
  27. package/src/superlocalmemory/cli/_lazy_init.py +3 -5
  28. package/src/superlocalmemory/cli/commands.py +490 -195
  29. package/src/superlocalmemory/cli/context_commands.py +5 -4
  30. package/src/superlocalmemory/cli/daemon.py +282 -187
  31. package/src/superlocalmemory/cli/db_migrate.py +3 -1
  32. package/src/superlocalmemory/cli/diagnostics_cmd.py +28 -0
  33. package/src/superlocalmemory/cli/evidence_cmd.py +103 -0
  34. package/src/superlocalmemory/cli/ingest_cmd.py +7 -3
  35. package/src/superlocalmemory/cli/main.py +128 -31
  36. package/src/superlocalmemory/cli/pending_store.py +54 -38
  37. package/src/superlocalmemory/cli/scale_engine_cmd.py +37 -0
  38. package/src/superlocalmemory/cli/service_installer.py +57 -52
  39. package/src/superlocalmemory/cli/setup_wizard.py +142 -88
  40. package/src/superlocalmemory/cli/version_banner.py +2 -1
  41. package/src/superlocalmemory/code_graph/config.py +3 -1
  42. package/src/superlocalmemory/core/backend_orchestrator.py +81 -21
  43. package/src/superlocalmemory/core/config.py +65 -20
  44. package/src/superlocalmemory/core/consolidation_engine.py +9 -7
  45. package/src/superlocalmemory/core/context_cache.py +56 -8
  46. package/src/superlocalmemory/core/derivation_lineage.py +246 -0
  47. package/src/superlocalmemory/core/embedding_worker.py +32 -20
  48. package/src/superlocalmemory/core/embeddings.py +54 -18
  49. package/src/superlocalmemory/core/engine.py +150 -104
  50. package/src/superlocalmemory/core/engine_ingestion.py +513 -0
  51. package/src/superlocalmemory/core/engine_wiring.py +2 -0
  52. package/src/superlocalmemory/core/evidence_bundle.py +526 -0
  53. package/src/superlocalmemory/core/fact_consolidator.py +5 -11
  54. package/src/superlocalmemory/core/graph_analyzer.py +2 -2
  55. package/src/superlocalmemory/core/health_monitor.py +4 -2
  56. package/src/superlocalmemory/core/ingestion_command.py +636 -0
  57. package/src/superlocalmemory/core/injection.py +69 -18
  58. package/src/superlocalmemory/core/lifecycle_state.py +153 -0
  59. package/src/superlocalmemory/core/maintenance.py +23 -22
  60. package/src/superlocalmemory/core/maintenance_scheduler.py +51 -35
  61. package/src/superlocalmemory/core/mutations.py +143 -0
  62. package/src/superlocalmemory/core/platform_utils.py +7 -4
  63. package/src/superlocalmemory/core/ram_lock.py +16 -5
  64. package/src/superlocalmemory/core/rate_limit.py +1 -1
  65. package/src/superlocalmemory/core/recall_pipeline.py +60 -101
  66. package/src/superlocalmemory/core/recall_worker.py +76 -59
  67. package/src/superlocalmemory/core/registry.py +1 -1
  68. package/src/superlocalmemory/core/scale_engine.py +293 -0
  69. package/src/superlocalmemory/core/score_contract.py +62 -0
  70. package/src/superlocalmemory/core/security_primitives.py +3 -1
  71. package/src/superlocalmemory/core/slm_disabled.py +3 -5
  72. package/src/superlocalmemory/core/store_pipeline.py +172 -40
  73. package/src/superlocalmemory/core/tier_manager.py +32 -20
  74. package/src/superlocalmemory/core/worker_pool.py +13 -4
  75. package/src/superlocalmemory/dynamics/activation_guided_quantization.py +1 -1
  76. package/src/superlocalmemory/dynamics/eap_scheduler.py +10 -3
  77. package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +1 -1
  78. package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +1 -1
  79. package/src/superlocalmemory/encoding/auto_linker.py +1 -1
  80. package/src/superlocalmemory/encoding/cognitive_consolidator.py +7 -16
  81. package/src/superlocalmemory/encoding/consolidator.py +22 -5
  82. package/src/superlocalmemory/encoding/fact_extractor.py +1 -1
  83. package/src/superlocalmemory/encoding/foresight.py +2 -0
  84. package/src/superlocalmemory/encoding/graph_builder.py +1 -1
  85. package/src/superlocalmemory/encoding/temporal_parser.py +2 -0
  86. package/src/superlocalmemory/evaluation/__init__.py +13 -0
  87. package/src/superlocalmemory/evaluation/calibration.py +308 -0
  88. package/src/superlocalmemory/evolution/skill_evolver.py +2 -1
  89. package/src/superlocalmemory/graph/cozo_backend.py +256 -23
  90. package/src/superlocalmemory/hooks/_outcome_common.py +21 -11
  91. package/src/superlocalmemory/hooks/antigravity_adapter.py +10 -31
  92. package/src/superlocalmemory/hooks/auto_invoker.py +25 -27
  93. package/src/superlocalmemory/hooks/auto_recall.py +31 -6
  94. package/src/superlocalmemory/hooks/auto_recall_hook.py +13 -33
  95. package/src/superlocalmemory/hooks/before_web_hook.py +9 -7
  96. package/src/superlocalmemory/hooks/claude_code_hooks.py +126 -39
  97. package/src/superlocalmemory/hooks/codex_assets.py +59 -0
  98. package/src/superlocalmemory/hooks/codex_hooks.py +186 -0
  99. package/src/superlocalmemory/hooks/context_payload.py +1 -1
  100. package/src/superlocalmemory/hooks/copilot_adapter.py +9 -24
  101. package/src/superlocalmemory/hooks/cursor_adapter.py +10 -32
  102. package/src/superlocalmemory/hooks/hook_daemon.py +4 -2
  103. package/src/superlocalmemory/hooks/hook_handlers.py +241 -55
  104. package/src/superlocalmemory/hooks/memory_protocol.py +5 -3
  105. package/src/superlocalmemory/hooks/post_tool_async_hook.py +4 -2
  106. package/src/superlocalmemory/hooks/session_registry.py +15 -8
  107. package/src/superlocalmemory/hooks/stop_outcome_hook.py +10 -6
  108. package/src/superlocalmemory/hooks/topic_shift_hook.py +42 -12
  109. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -14
  110. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +19 -11
  111. package/src/superlocalmemory/infra/auth_middleware.py +38 -5
  112. package/src/superlocalmemory/infra/backup.py +7 -5
  113. package/src/superlocalmemory/infra/cloud_backup.py +18 -8
  114. package/src/superlocalmemory/infra/daemon_identity.py +248 -0
  115. package/src/superlocalmemory/infra/data_root.py +199 -0
  116. package/src/superlocalmemory/infra/event_bus.py +3 -1
  117. package/src/superlocalmemory/infra/local_diagnostics.py +327 -0
  118. package/src/superlocalmemory/infra/process_reaper.py +23 -0
  119. package/src/superlocalmemory/ingestion/adapter_manager.py +27 -9
  120. package/src/superlocalmemory/ingestion/base_adapter.py +25 -31
  121. package/src/superlocalmemory/ingestion/calendar_adapter.py +13 -4
  122. package/src/superlocalmemory/ingestion/credentials.py +14 -7
  123. package/src/superlocalmemory/ingestion/gmail_adapter.py +13 -4
  124. package/src/superlocalmemory/ingestion/transcript_adapter.py +7 -2
  125. package/src/superlocalmemory/learning/consolidation_quantization_worker.py +1 -1
  126. package/src/superlocalmemory/learning/ensemble.py +11 -0
  127. package/src/superlocalmemory/learning/entity_compiler.py +1 -1
  128. package/src/superlocalmemory/learning/feedback.py +1 -1
  129. package/src/superlocalmemory/learning/forgetting_scheduler.py +12 -7
  130. package/src/superlocalmemory/learning/quantization_scheduler.py +1 -1
  131. package/src/superlocalmemory/learning/ranker.py +4 -1
  132. package/src/superlocalmemory/learning/source_quality.py +1 -1
  133. package/src/superlocalmemory/learning/trigram_index.py +3 -2
  134. package/src/superlocalmemory/llm/backbone.py +13 -8
  135. package/src/superlocalmemory/math/ebbinghaus.py +1 -1
  136. package/src/superlocalmemory/math/fisher.py +1 -1
  137. package/src/superlocalmemory/math/fisher_quantized.py +1 -1
  138. package/src/superlocalmemory/math/hopfield.py +1 -1
  139. package/src/superlocalmemory/math/langevin.py +1 -1
  140. package/src/superlocalmemory/math/polar_quant.py +3 -4
  141. package/src/superlocalmemory/math/qjl.py +1 -1
  142. package/src/superlocalmemory/math/sheaf.py +1 -1
  143. package/src/superlocalmemory/math/turbo_quant.py +3 -2
  144. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -11
  145. package/src/superlocalmemory/mcp/_pool_adapter.py +27 -0
  146. package/src/superlocalmemory/mcp/http_transport.py +53 -0
  147. package/src/superlocalmemory/mcp/server.py +39 -13
  148. package/src/superlocalmemory/mcp/shared.py +69 -3
  149. package/src/superlocalmemory/mcp/tools_active.py +141 -31
  150. package/src/superlocalmemory/mcp/tools_core.py +128 -29
  151. package/src/superlocalmemory/mcp/tools_evolution.py +5 -7
  152. package/src/superlocalmemory/mcp/tools_learning.py +42 -2
  153. package/src/superlocalmemory/mcp/tools_mesh.py +7 -23
  154. package/src/superlocalmemory/mcp/tools_optimize.py +8 -1
  155. package/src/superlocalmemory/mcp/tools_v28.py +23 -2
  156. package/src/superlocalmemory/mcp/tools_v3.py +26 -1
  157. package/src/superlocalmemory/mcp/tools_v33.py +56 -17
  158. package/src/superlocalmemory/mesh/broker.py +2 -0
  159. package/src/superlocalmemory/mesh/remote_sync.py +50 -12
  160. package/src/superlocalmemory/optimize/cache/manager.py +77 -1
  161. package/src/superlocalmemory/optimize/cache/semantic.py +23 -3
  162. package/src/superlocalmemory/optimize/compress/ccr.py +4 -0
  163. package/src/superlocalmemory/optimize/compress/router.py +6 -1
  164. package/src/superlocalmemory/optimize/config/__init__.py +5 -0
  165. package/src/superlocalmemory/optimize/config/store.py +6 -4
  166. package/src/superlocalmemory/optimize/proxy/_helpers.py +15 -5
  167. package/src/superlocalmemory/optimize/proxy/capture.py +3 -2
  168. package/src/superlocalmemory/optimize/proxy/server.py +2 -2
  169. package/src/superlocalmemory/optimize/storage/db.py +14 -13
  170. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  171. package/src/superlocalmemory/retrieval/ann_index.py +1 -1
  172. package/src/superlocalmemory/retrieval/bm25_channel.py +35 -11
  173. package/src/superlocalmemory/retrieval/bridge_discovery.py +73 -8
  174. package/src/superlocalmemory/retrieval/engine.py +169 -79
  175. package/src/superlocalmemory/retrieval/entity_channel.py +289 -67
  176. package/src/superlocalmemory/retrieval/forgetting_filter.py +1 -1
  177. package/src/superlocalmemory/retrieval/fusion.py +1 -1
  178. package/src/superlocalmemory/retrieval/hopfield_channel.py +118 -30
  179. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  180. package/src/superlocalmemory/retrieval/quantization_aware_search.py +16 -10
  181. package/src/superlocalmemory/retrieval/reranker.py +56 -20
  182. package/src/superlocalmemory/retrieval/scope_policy.py +85 -0
  183. package/src/superlocalmemory/retrieval/semantic_channel.py +122 -14
  184. package/src/superlocalmemory/retrieval/spreading_activation.py +141 -25
  185. package/src/superlocalmemory/retrieval/strategy.py +1 -1
  186. package/src/superlocalmemory/retrieval/temporal_channel.py +30 -15
  187. package/src/superlocalmemory/retrieval/vector_store.py +1 -1
  188. package/src/superlocalmemory/server/api.py +10 -7
  189. package/src/superlocalmemory/server/bandit_loops.py +4 -2
  190. package/src/superlocalmemory/server/recall_serializer.py +24 -0
  191. package/src/superlocalmemory/server/route_mutations.py +84 -0
  192. package/src/superlocalmemory/server/routes/agents.py +8 -6
  193. package/src/superlocalmemory/server/routes/brain.py +14 -12
  194. package/src/superlocalmemory/server/routes/chat.py +29 -12
  195. package/src/superlocalmemory/server/routes/data_io.py +55 -24
  196. package/src/superlocalmemory/server/routes/helpers.py +29 -4
  197. package/src/superlocalmemory/server/routes/ingest.py +53 -36
  198. package/src/superlocalmemory/server/routes/memories.py +104 -43
  199. package/src/superlocalmemory/server/routes/mesh.py +31 -0
  200. package/src/superlocalmemory/server/routes/profiles.py +26 -4
  201. package/src/superlocalmemory/server/routes/tiers.py +43 -11
  202. package/src/superlocalmemory/server/routes/timeline.py +5 -1
  203. package/src/superlocalmemory/server/routes/v3_api.py +76 -21
  204. package/src/superlocalmemory/server/security_middleware.py +1 -1
  205. package/src/superlocalmemory/server/ui.py +6 -3
  206. package/src/superlocalmemory/server/unified_daemon.py +680 -293
  207. package/src/superlocalmemory/server/write_identity.py +147 -0
  208. package/src/superlocalmemory/storage/access_log.py +4 -3
  209. package/src/superlocalmemory/storage/database.py +118 -25
  210. package/src/superlocalmemory/storage/migration_runner.py +84 -1
  211. package/src/superlocalmemory/storage/migration_v33.py +1 -1
  212. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +6 -60
  213. package/src/superlocalmemory/storage/migrations/M018_ingestion_operations.py +120 -0
  214. package/src/superlocalmemory/storage/migrations/M019_derivation_lineage.py +54 -0
  215. package/src/superlocalmemory/storage/migrations/M020_model_state_integrity.py +52 -0
  216. package/src/superlocalmemory/storage/migrations/__init__.py +5 -0
  217. package/src/superlocalmemory/storage/models.py +16 -0
  218. package/src/superlocalmemory/storage/quantized_store.py +20 -3
  219. package/src/superlocalmemory/storage/v2_migrator.py +5 -3
  220. package/src/superlocalmemory/ui/favicon.svg +5 -0
  221. package/src/superlocalmemory/ui/index.html +1 -0
  222. package/src/superlocalmemory/ui/js/compliance.js +1 -1
  223. package/src/superlocalmemory/ui/js/core.js +49 -8
  224. package/src/superlocalmemory/ui/js/dashboard.js +23 -2
  225. package/src/superlocalmemory/ui/js/feedback.js +1 -1
  226. package/src/superlocalmemory/ui/js/graph-filters.js +1 -1
  227. package/src/superlocalmemory/ui/js/graph-ui.js +1 -1
  228. package/src/superlocalmemory/ui/js/lifecycle.js +1 -1
  229. package/src/superlocalmemory/ui/js/ng-mesh.js +15 -49
  230. package/src/superlocalmemory/ui/js/settings.js +4 -2
  231. package/src/superlocalmemory/vector/lancedb_backend.py +57 -9
  232. package/bin/slm +0 -59
  233. package/bin/slm.bat +0 -77
  234. package/bin/slm.cmd +0 -5
  235. package/ide/integrations/langchain/README.md +0 -106
  236. package/ide/integrations/langchain/langchain_superlocalmemory/__init__.py +0 -9
  237. package/ide/integrations/langchain/langchain_superlocalmemory/chat_message_history.py +0 -201
  238. package/ide/integrations/langchain/pyproject.toml +0 -38
  239. package/ide/integrations/langchain/tests/__init__.py +0 -3
  240. package/ide/integrations/langchain/tests/test_chat_message_history.py +0 -215
  241. package/ide/integrations/langchain/tests/test_security.py +0 -117
  242. package/ide/integrations/llamaindex/README.md +0 -81
  243. package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/__init__.py +0 -9
  244. package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/base.py +0 -316
  245. package/ide/integrations/llamaindex/pyproject.toml +0 -43
  246. package/ide/integrations/llamaindex/tests/__init__.py +0 -3
  247. package/ide/integrations/llamaindex/tests/test_chat_store.py +0 -294
  248. package/ide/integrations/llamaindex/tests/test_security.py +0 -241
  249. package/plugin-src/.mcp.json +0 -12
  250. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  251. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  252. package/plugin-src/hooks/.gitkeep +0 -0
  253. package/plugin-src/hooks/hooks.json +0 -23
  254. package/plugin-src/manifest.json +0 -25
  255. package/plugin-src/requirements.txt +0 -1
  256. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  257. package/plugin-src/scripts/ensure-venv.bat +0 -122
  258. package/plugin-src/scripts/ensure-venv.sh +0 -105
  259. package/plugin-src/scripts/slm-launch +0 -15
  260. package/plugin-src/scripts/slm-launch.bat +0 -17
  261. package/plugin-src/settings.json +0 -16
  262. package/plugin-src/skills/slm-cache/SKILL.md +0 -140
  263. package/plugin-src/skills/slm-compress/SKILL.md +0 -143
  264. package/plugin-src/skills/slm-graph/SKILL.md +0 -300
  265. package/plugin-src/skills/slm-recall/SKILL.md +0 -204
  266. package/plugin-src/skills/slm-remember/SKILL.md +0 -194
  267. package/plugin-src/skills/slm-session/SKILL.md +0 -207
  268. package/plugin-src/skills/slm-status/SKILL.md +0 -149
  269. package/scripts/__tests__/build-plugin.test.mjs +0 -613
  270. package/scripts/_savings_math.py +0 -270
  271. package/scripts/build-dmg.sh +0 -417
  272. package/scripts/build-plugin.js +0 -742
  273. package/scripts/build-slm-hook.ps1 +0 -40
  274. package/scripts/build-slm-hook.sh +0 -45
  275. package/scripts/build_entry.py +0 -452
  276. package/scripts/ci/stage5b_gate.sh +0 -50
  277. package/scripts/dogfood_savings.py +0 -490
  278. package/scripts/generate-thumbnails.py +0 -218
  279. package/scripts/install-skills.ps1 +0 -4
  280. package/scripts/install-skills.sh +0 -5
  281. package/scripts/install.ps1 +0 -701
  282. package/scripts/install.sh +0 -1015
  283. package/scripts/postinstall_binary.js +0 -287
  284. package/scripts/prepack.js +0 -33
  285. package/scripts/release_manifest.py +0 -273
  286. package/scripts/slm-hook.spec +0 -56
  287. package/scripts/start-dashboard.ps1 +0 -52
  288. package/scripts/start-dashboard.sh +0 -41
  289. package/scripts/sync-wiki.ps1 +0 -127
  290. package/scripts/sync-wiki.sh +0 -82
  291. package/scripts/test-dmg.sh +0 -161
  292. package/scripts/test-npm-package.ps1 +0 -252
  293. package/scripts/test-npm-package.sh +0 -207
  294. package/scripts/verify-install.ps1 +0 -294
  295. package/scripts/verify-install.sh +0 -266
  296. package/scripts/verify-v27.ps1 +0 -301
  297. package/scripts/verify-v27.sh +0 -233
  298. package/src/superlocalmemory.egg-info/PKG-INFO +0 -513
  299. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -529
  300. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  301. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  302. package/src/superlocalmemory.egg-info/requires.txt +0 -71
  303. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,636 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3
4
+
5
+ """Canonical durable-ingestion command and operation state machine.
6
+
7
+ The command deliberately depends on injected queryable/materialization
8
+ functions. This keeps the durable contract testable while legacy write paths
9
+ are migrated through an expand-migrate-contract rollout.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import sqlite3
17
+ import threading
18
+ import time
19
+ import uuid
20
+ from dataclasses import dataclass, field
21
+ from enum import Enum
22
+ from typing import Any, Callable
23
+
24
+ from superlocalmemory.storage.database import DatabaseManager
25
+
26
+ _MATERIALIZATION_LOCKS = tuple(threading.RLock() for _ in range(64))
27
+
28
+
29
+ def _materialization_lock(operation_id: str) -> threading.RLock:
30
+ bucket = int(hashlib.sha256(operation_id.encode("utf-8")).hexdigest()[:8], 16)
31
+ return _MATERIALIZATION_LOCKS[bucket % len(_MATERIALIZATION_LOCKS)]
32
+
33
+
34
+ class IngestionState(str, Enum):
35
+ RAW = "raw"
36
+ QUERYABLE = "queryable"
37
+ ENRICHING = "enriching"
38
+ COMPLETE = "complete"
39
+ FAILED = "failed"
40
+
41
+
42
+ class IdempotencyConflict(ValueError):
43
+ """The same idempotency key was reused for different immutable evidence."""
44
+
45
+
46
+ class InvalidStateTransition(RuntimeError):
47
+ """An ingestion operation attempted an illegal or stale transition."""
48
+
49
+
50
+ class OperationInProgress(RuntimeError):
51
+ """Another live lease owner is materializing this operation."""
52
+
53
+
54
+ def _canonical_json(value: Any) -> str:
55
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class IngestionRequest:
60
+ content: str
61
+ profile_id: str
62
+ source_type: str
63
+ idempotency_key: str
64
+ metadata: dict[str, Any] = field(default_factory=dict)
65
+ scope: str = "personal"
66
+ shared_with: tuple[str, ...] = ()
67
+ trusted_actor_id: str = ""
68
+ session_id: str = ""
69
+ session_date: str = ""
70
+ speaker: str = ""
71
+ role: str = "user"
72
+
73
+ def __post_init__(self) -> None:
74
+ if not self.content or not self.content.strip():
75
+ raise ValueError("content is required")
76
+ for name in ("profile_id", "source_type", "idempotency_key"):
77
+ if not str(getattr(self, name)).strip():
78
+ raise ValueError(f"{name} is required")
79
+ if self.scope not in {"personal", "project", "shared", "global"}:
80
+ raise ValueError(f"unsupported scope: {self.scope}")
81
+ object.__setattr__(self, "metadata", dict(self.metadata))
82
+ object.__setattr__(self, "shared_with", tuple(self.shared_with))
83
+
84
+ @property
85
+ def source_hash(self) -> str:
86
+ return hashlib.sha256(self.content.encode("utf-8")).hexdigest()
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class IngestionOperation:
91
+ operation_id: str
92
+ profile_id: str
93
+ source_type: str
94
+ idempotency_key: str
95
+ source_hash: str
96
+ raw_content: str
97
+ metadata: dict[str, Any]
98
+ scope: str
99
+ shared_with: tuple[str, ...]
100
+ trusted_actor_id: str
101
+ session_id: str
102
+ session_date: str
103
+ speaker: str
104
+ role: str
105
+ state: IngestionState
106
+ queryable_fact_ids: tuple[str, ...]
107
+ final_fact_ids: tuple[str, ...]
108
+ derivation_version: str
109
+ derivation_state: dict[str, bool]
110
+ lease_owner: str
111
+ lease_expires_at: float
112
+ next_retry_at: float
113
+ attempt_count: int
114
+ last_error: str
115
+ created_at: str
116
+ updated_at: str
117
+
118
+ @property
119
+ def fact_ids(self) -> tuple[str, ...]:
120
+ return self.final_fact_ids or self.queryable_fact_ids
121
+
122
+
123
+ class IngestionOperationRepository:
124
+ """Persistence and compare-and-swap transitions for M018 operations."""
125
+
126
+ _ALLOWED: dict[IngestionState, frozenset[IngestionState]] = {
127
+ IngestionState.RAW: frozenset(
128
+ {IngestionState.QUERYABLE, IngestionState.FAILED}
129
+ ),
130
+ IngestionState.QUERYABLE: frozenset(
131
+ {IngestionState.ENRICHING, IngestionState.FAILED}
132
+ ),
133
+ IngestionState.ENRICHING: frozenset(
134
+ {IngestionState.COMPLETE, IngestionState.FAILED}
135
+ ),
136
+ IngestionState.FAILED: frozenset({IngestionState.ENRICHING}),
137
+ IngestionState.COMPLETE: frozenset(),
138
+ }
139
+
140
+ def __init__(self, db: DatabaseManager) -> None:
141
+ self.db = db
142
+
143
+ @staticmethod
144
+ def _from_row(row: Any) -> IngestionOperation:
145
+ data = dict(row)
146
+ return IngestionOperation(
147
+ operation_id=data["operation_id"],
148
+ profile_id=data["profile_id"],
149
+ source_type=data["source_type"],
150
+ idempotency_key=data["idempotency_key"],
151
+ source_hash=data["source_hash"],
152
+ raw_content=data["raw_content"],
153
+ metadata=json.loads(data["raw_metadata_json"] or "{}"),
154
+ scope=data["scope"],
155
+ shared_with=tuple(json.loads(data["shared_with_json"] or "[]")),
156
+ trusted_actor_id=data["trusted_actor_id"],
157
+ session_id=data["session_id"],
158
+ session_date=data["session_date"],
159
+ speaker=data["speaker"],
160
+ role=data["role"],
161
+ state=IngestionState(data["state"]),
162
+ queryable_fact_ids=tuple(
163
+ json.loads(data["queryable_fact_ids_json"] or "[]")
164
+ ),
165
+ final_fact_ids=tuple(json.loads(data["final_fact_ids_json"] or "[]")),
166
+ derivation_version=data["derivation_version"],
167
+ derivation_state=json.loads(data.get("derivation_state_json") or "{}"),
168
+ lease_owner=data.get("lease_owner") or "",
169
+ lease_expires_at=float(data.get("lease_expires_at") or 0),
170
+ next_retry_at=float(data.get("next_retry_at") or 0),
171
+ attempt_count=int(data["attempt_count"]),
172
+ last_error=data["last_error"],
173
+ created_at=data["created_at"],
174
+ updated_at=data["updated_at"],
175
+ )
176
+
177
+ def _find_request(self, request: IngestionRequest) -> IngestionOperation | None:
178
+ rows = self.db.execute(
179
+ "SELECT * FROM ingestion_operations "
180
+ "WHERE profile_id=? AND source_type=? AND idempotency_key=?",
181
+ (request.profile_id, request.source_type, request.idempotency_key),
182
+ )
183
+ return self._from_row(rows[0]) if rows else None
184
+
185
+ @staticmethod
186
+ def _assert_same_request(
187
+ existing: IngestionOperation, request: IngestionRequest
188
+ ) -> None:
189
+ comparable = (
190
+ existing.source_hash == request.source_hash,
191
+ existing.raw_content == request.content,
192
+ existing.metadata == request.metadata,
193
+ existing.scope == request.scope,
194
+ existing.shared_with == request.shared_with,
195
+ existing.trusted_actor_id == request.trusted_actor_id,
196
+ existing.session_id == request.session_id,
197
+ existing.session_date == request.session_date,
198
+ existing.speaker == request.speaker,
199
+ existing.role == request.role,
200
+ )
201
+ if not all(comparable):
202
+ raise IdempotencyConflict(
203
+ "idempotency key already belongs to different immutable evidence"
204
+ )
205
+
206
+ def create(self, request: IngestionRequest) -> IngestionOperation:
207
+ operation, _created = self.create_with_status(request)
208
+ return operation
209
+
210
+ def create_with_status(
211
+ self, request: IngestionRequest,
212
+ ) -> tuple[IngestionOperation, bool]:
213
+ existing = self._find_request(request)
214
+ if existing is not None:
215
+ self._assert_same_request(existing, request)
216
+ return existing, False
217
+
218
+ operation_id = uuid.uuid4().hex
219
+ try:
220
+ self.db.execute(
221
+ "INSERT INTO ingestion_operations "
222
+ "(operation_id, profile_id, source_type, idempotency_key, "
223
+ "source_hash, raw_content, raw_metadata_json, scope, "
224
+ "shared_with_json, trusted_actor_id, session_id, session_date, "
225
+ "speaker, role) "
226
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
227
+ (
228
+ operation_id,
229
+ request.profile_id,
230
+ request.source_type,
231
+ request.idempotency_key,
232
+ request.source_hash,
233
+ request.content,
234
+ _canonical_json(request.metadata),
235
+ request.scope,
236
+ _canonical_json(list(request.shared_with)),
237
+ request.trusted_actor_id,
238
+ request.session_id,
239
+ request.session_date,
240
+ request.speaker,
241
+ request.role,
242
+ ),
243
+ )
244
+ except sqlite3.IntegrityError:
245
+ concurrent = self._find_request(request)
246
+ if concurrent is None:
247
+ raise
248
+ self._assert_same_request(concurrent, request)
249
+ return concurrent, False
250
+ return self.get(operation_id), True
251
+
252
+ def get(self, operation_id: str) -> IngestionOperation:
253
+ rows = self.db.execute(
254
+ "SELECT * FROM ingestion_operations WHERE operation_id=?",
255
+ (operation_id,),
256
+ )
257
+ if not rows:
258
+ raise KeyError(operation_id)
259
+ return self._from_row(rows[0])
260
+
261
+ def list_operations(self) -> list[IngestionOperation]:
262
+ return [
263
+ self._from_row(row)
264
+ for row in self.db.execute(
265
+ "SELECT * FROM ingestion_operations ORDER BY created_at, operation_id"
266
+ )
267
+ ]
268
+
269
+ def list_materializable(
270
+ self,
271
+ *,
272
+ limit: int = 50,
273
+ min_queryable_age_seconds: float = 0.0,
274
+ ) -> list[IngestionOperation]:
275
+ """Return durable work in FIFO order for the background materializer.
276
+
277
+ A short age gate can protect a freshly admitted receipt from racing
278
+ the user's immediate recall on single-queue local model runtimes such
279
+ as Ollama. Failed retries and expired leases remain immediately due.
280
+ """
281
+ now = time.time()
282
+ grace_modifier = f"-{max(0.0, float(min_queryable_age_seconds))} seconds"
283
+ return [
284
+ self._from_row(row)
285
+ for row in self.db.execute(
286
+ "SELECT * FROM ingestion_operations "
287
+ "WHERE (state='queryable' AND created_at <= "
288
+ "strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) "
289
+ "OR (state='failed' AND next_retry_at <= ?) "
290
+ "OR (state='enriching' AND lease_expires_at <= ?) "
291
+ "ORDER BY created_at, rowid LIMIT ?",
292
+ (grace_modifier, now, now, max(1, int(limit))),
293
+ )
294
+ ]
295
+
296
+ def claim_enriching(
297
+ self,
298
+ operation_id: str,
299
+ *,
300
+ owner: str,
301
+ lease_seconds: float,
302
+ ) -> IngestionOperation:
303
+ """Claim queryable/failed work or reclaim an expired enrichment."""
304
+ now = time.time()
305
+ rows = self.db.execute(
306
+ "UPDATE ingestion_operations SET state='enriching', "
307
+ "lease_owner=?, lease_expires_at=?, "
308
+ "next_retry_at=0, attempt_count=attempt_count + "
309
+ "CASE WHEN state='enriching' AND lease_owner=? THEN 0 ELSE 1 END, "
310
+ "last_error='', "
311
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
312
+ "WHERE operation_id=? AND ("
313
+ "state IN ('queryable', 'failed') OR "
314
+ "(state='enriching' AND (lease_owner=? OR lease_expires_at <= ?))"
315
+ ") RETURNING *",
316
+ (owner, now + lease_seconds, owner, operation_id, owner, now),
317
+ )
318
+ if rows:
319
+ return self._from_row(rows[0])
320
+ current = self.get(operation_id)
321
+ if current.state is IngestionState.COMPLETE:
322
+ return current
323
+ raise OperationInProgress(
324
+ f"operation {operation_id} is leased by another worker"
325
+ )
326
+
327
+ def transition(
328
+ self,
329
+ operation_id: str,
330
+ *,
331
+ expected: IngestionState,
332
+ target: IngestionState,
333
+ queryable_fact_ids: tuple[str, ...] | None = None,
334
+ final_fact_ids: tuple[str, ...] | None = None,
335
+ derivation_version: str | None = None,
336
+ derivation_state: dict[str, bool] | None = None,
337
+ last_error: str | None = None,
338
+ ) -> IngestionOperation:
339
+ if target not in self._ALLOWED[expected]:
340
+ raise InvalidStateTransition(f"{expected.value} -> {target.value}")
341
+ rows = self.db.execute(
342
+ "UPDATE ingestion_operations SET state=?, "
343
+ "queryable_fact_ids_json=COALESCE(?, queryable_fact_ids_json), "
344
+ "final_fact_ids_json=COALESCE(?, final_fact_ids_json), "
345
+ "derivation_version=COALESCE(?, derivation_version), "
346
+ "derivation_state_json=COALESCE(?, derivation_state_json), "
347
+ "attempt_count=attempt_count + ?, "
348
+ "last_error=?, "
349
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
350
+ "WHERE operation_id=? AND state=? RETURNING *",
351
+ (
352
+ target.value,
353
+ _canonical_json(list(queryable_fact_ids))
354
+ if queryable_fact_ids is not None
355
+ else None,
356
+ _canonical_json(list(final_fact_ids))
357
+ if final_fact_ids is not None
358
+ else None,
359
+ derivation_version,
360
+ _canonical_json(derivation_state)
361
+ if derivation_state is not None
362
+ else None,
363
+ 1 if target is IngestionState.ENRICHING else 0,
364
+ last_error or "",
365
+ operation_id,
366
+ expected.value,
367
+ ),
368
+ )
369
+ if not rows:
370
+ actual = self.get(operation_id).state
371
+ raise InvalidStateTransition(
372
+ f"expected {expected.value}, found {actual.value}"
373
+ )
374
+ return self._from_row(rows[0])
375
+
376
+ def checkpoint_enriching(
377
+ self,
378
+ operation_id: str,
379
+ *,
380
+ final_fact_ids: tuple[str, ...],
381
+ derivation_version: str,
382
+ derivation_state: dict[str, bool],
383
+ lease_owner: str,
384
+ lease_seconds: float,
385
+ ) -> IngestionOperation:
386
+ """Durably checkpoint relational derivation before external indexes."""
387
+ rows = self.db.execute(
388
+ "UPDATE ingestion_operations SET "
389
+ "final_fact_ids_json=?, derivation_version=?, "
390
+ "derivation_state_json=?, lease_expires_at=?, last_error='', "
391
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
392
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
393
+ "RETURNING *",
394
+ (
395
+ _canonical_json(list(final_fact_ids)),
396
+ derivation_version,
397
+ _canonical_json(derivation_state),
398
+ time.time() + max(1.0, float(lease_seconds)),
399
+ operation_id,
400
+ lease_owner,
401
+ ),
402
+ )
403
+ if not rows:
404
+ raise InvalidStateTransition("enriching checkpoint lost ownership")
405
+ operation = self._from_row(rows[0])
406
+ from superlocalmemory.core.derivation_lineage import capture_operation_lineage
407
+
408
+ capture_operation_lineage(
409
+ self.db,
410
+ operation_id=operation.operation_id,
411
+ profile_id=operation.profile_id,
412
+ raw_content=operation.raw_content,
413
+ fact_ids=operation.final_fact_ids,
414
+ derivation_version=operation.derivation_version,
415
+ )
416
+ return operation
417
+
418
+ def finish_enriching(
419
+ self,
420
+ operation_id: str,
421
+ *,
422
+ owner: str,
423
+ target: IngestionState,
424
+ final_fact_ids: tuple[str, ...] | None = None,
425
+ derivation_version: str | None = None,
426
+ derivation_state: dict[str, bool] | None = None,
427
+ last_error: str = "",
428
+ ) -> IngestionOperation:
429
+ """Finish only work owned by the caller's durable lease."""
430
+ if target not in {IngestionState.COMPLETE, IngestionState.FAILED}:
431
+ raise InvalidStateTransition(f"enriching -> {target.value}")
432
+ current = self.get(operation_id)
433
+ retry_at = 0.0
434
+ if target is IngestionState.FAILED:
435
+ delay = min(2 ** min(max(current.attempt_count, 1), 10), 300)
436
+ retry_at = time.time() + delay
437
+ rows = self.db.execute(
438
+ "UPDATE ingestion_operations SET state=?, "
439
+ "final_fact_ids_json=COALESCE(?, final_fact_ids_json), "
440
+ "derivation_version=COALESCE(?, derivation_version), "
441
+ "derivation_state_json=COALESCE(?, derivation_state_json), "
442
+ "lease_owner='', lease_expires_at=0, next_retry_at=?, last_error=?, "
443
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
444
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
445
+ "RETURNING *",
446
+ (
447
+ target.value,
448
+ _canonical_json(list(final_fact_ids))
449
+ if final_fact_ids is not None
450
+ else None,
451
+ derivation_version,
452
+ _canonical_json(derivation_state)
453
+ if derivation_state is not None
454
+ else None,
455
+ retry_at,
456
+ last_error,
457
+ operation_id,
458
+ owner,
459
+ ),
460
+ )
461
+ if not rows:
462
+ raise InvalidStateTransition("enriching lease ownership was lost")
463
+ return self._from_row(rows[0])
464
+
465
+
466
+ @dataclass(frozen=True, slots=True)
467
+ class MaterializationResult:
468
+ """Final fact IDs plus the declared derivation stages actually completed."""
469
+
470
+ fact_ids: tuple[str, ...]
471
+ derivation_state: dict[str, bool]
472
+
473
+
474
+ QueryableWriter = Callable[[IngestionRequest, str], list[str]]
475
+ Materializer = Callable[
476
+ [IngestionOperation],
477
+ list[str] | tuple[str, ...] | MaterializationResult,
478
+ ]
479
+ Projector = Callable[[IngestionOperation], dict[str, bool]]
480
+
481
+
482
+ class IngestionCommand:
483
+ """Coordinates durable submission and idempotent enrichment."""
484
+
485
+ def __init__(
486
+ self,
487
+ repository: IngestionOperationRepository,
488
+ *,
489
+ write_queryable: QueryableWriter,
490
+ materialize: Materializer,
491
+ project: Projector | None = None,
492
+ derivation_version: str = "v3.7-ingestion-1",
493
+ lease_seconds: float = 900.0,
494
+ ) -> None:
495
+ self.repository = repository
496
+ self._write_queryable = write_queryable
497
+ self._materializer = materialize
498
+ self._projector = project
499
+ self._derivation_version = derivation_version
500
+ self._lease_seconds = max(1.0, float(lease_seconds))
501
+ self._owner = f"ingestion-worker:{uuid.uuid4().hex}"
502
+
503
+ def submit(self, request: IngestionRequest) -> IngestionOperation:
504
+ operation, _created = self.submit_with_status(request)
505
+ return operation
506
+
507
+ def submit_with_status(
508
+ self, request: IngestionRequest,
509
+ ) -> tuple[IngestionOperation, bool]:
510
+ """Submit once and report whether this call created the operation."""
511
+ with self.repository.db.transaction():
512
+ operation, created = self.repository.create_with_status(request)
513
+ if operation.state is not IngestionState.RAW:
514
+ return operation, created
515
+ fact_ids = tuple(self._write_queryable(request, operation.operation_id))
516
+ if not fact_ids:
517
+ raise RuntimeError("ingestion produced no queryable facts")
518
+ receipt = self.repository.transition(
519
+ operation.operation_id,
520
+ expected=IngestionState.RAW,
521
+ target=IngestionState.QUERYABLE,
522
+ queryable_fact_ids=fact_ids,
523
+ )
524
+ return receipt, created
525
+
526
+ def materialize(self, operation_id: str) -> IngestionOperation:
527
+ # Coalesce in-process HTTP/background/worker races for the same
528
+ # operation. Database compare-and-swap remains the cross-process gate.
529
+ with _materialization_lock(operation_id):
530
+ return self._materialize_locked(operation_id)
531
+
532
+ def _materialize_locked(self, operation_id: str) -> IngestionOperation:
533
+ operation = self.repository.get(operation_id)
534
+ if operation.state is IngestionState.COMPLETE:
535
+ return operation
536
+ if operation.state not in {
537
+ IngestionState.QUERYABLE,
538
+ IngestionState.ENRICHING,
539
+ IngestionState.FAILED,
540
+ }:
541
+ raise InvalidStateTransition(
542
+ f"cannot materialize operation in {operation.state.value}"
543
+ )
544
+ enriching = self.repository.claim_enriching(
545
+ operation_id,
546
+ owner=self._owner,
547
+ lease_seconds=self._lease_seconds,
548
+ )
549
+ if enriching.state is IngestionState.COMPLETE:
550
+ return enriching
551
+ if enriching.final_fact_ids:
552
+ return self._project_and_complete(enriching)
553
+ try:
554
+ with self.repository.db.transaction():
555
+ materialized = self._materializer(enriching)
556
+ if isinstance(materialized, MaterializationResult):
557
+ fact_ids = tuple(materialized.fact_ids)
558
+ derivation_state = dict(materialized.derivation_state)
559
+ else:
560
+ fact_ids = tuple(materialized)
561
+ derivation_state = {"materializer": True}
562
+ if not fact_ids:
563
+ raise RuntimeError("materialization produced no final facts")
564
+ incomplete = sorted(
565
+ name for name, complete in derivation_state.items()
566
+ if not complete
567
+ )
568
+ if incomplete:
569
+ raise RuntimeError(
570
+ "incomplete derivation stages: " + ", ".join(incomplete)
571
+ )
572
+ checkpointed = self.repository.checkpoint_enriching(
573
+ operation_id,
574
+ final_fact_ids=fact_ids,
575
+ derivation_version=self._derivation_version,
576
+ derivation_state=derivation_state,
577
+ lease_owner=self._owner,
578
+ lease_seconds=self._lease_seconds,
579
+ )
580
+ except Exception as exc:
581
+ return self.repository.finish_enriching(
582
+ operation_id,
583
+ owner=self._owner,
584
+ target=IngestionState.FAILED,
585
+ last_error=str(exc),
586
+ )
587
+ return self._project_and_complete(checkpointed)
588
+
589
+ def _project_and_complete(
590
+ self, operation: IngestionOperation,
591
+ ) -> IngestionOperation:
592
+ try:
593
+ # The relational checkpoint extends ownership before optional ANN
594
+ # and vector projectors, whose cold-start latency can be material.
595
+ # Re-claiming with the same owner renews the lease atomically.
596
+ operation = self.repository.claim_enriching(
597
+ operation.operation_id,
598
+ owner=self._owner,
599
+ lease_seconds=self._lease_seconds,
600
+ )
601
+ projection_state = (
602
+ dict(self._projector(operation))
603
+ if self._projector is not None
604
+ else {}
605
+ )
606
+ combined = {**operation.derivation_state, **projection_state}
607
+ incomplete = sorted(
608
+ name for name, complete in combined.items() if not complete
609
+ )
610
+ if incomplete:
611
+ raise RuntimeError(
612
+ "incomplete derivation stages: " + ", ".join(incomplete)
613
+ )
614
+ return self.repository.finish_enriching(
615
+ operation.operation_id,
616
+ owner=self._owner,
617
+ target=IngestionState.COMPLETE,
618
+ final_fact_ids=operation.final_fact_ids,
619
+ derivation_version=self._derivation_version,
620
+ derivation_state=combined,
621
+ )
622
+ except Exception as exc:
623
+ return self.repository.finish_enriching(
624
+ operation.operation_id,
625
+ owner=self._owner,
626
+ target=IngestionState.FAILED,
627
+ last_error=str(exc),
628
+ )
629
+
630
+ def retry(self, operation_id: str) -> IngestionOperation:
631
+ operation = self.repository.get(operation_id)
632
+ if operation.state is not IngestionState.FAILED:
633
+ raise InvalidStateTransition(
634
+ f"cannot retry operation in {operation.state.value}"
635
+ )
636
+ return self.materialize(operation_id)