superlocalmemory 3.7.8 → 3.8.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 (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +384 -56
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -62,6 +62,7 @@ def _get_engine():
62
62
  def _handle_recall(
63
63
  query: str, limit: int, session_id: str = "", fast: bool = False,
64
64
  include_global: bool | None = None, include_shared: bool | None = None,
65
+ window: str | None = None,
65
66
  ) -> dict:
66
67
  engine = _get_engine()
67
68
  # v3.6.15 multi-scope: None flags let engine.recall resolve the configured
@@ -70,11 +71,19 @@ def _handle_recall(
70
71
  response = engine.recall(
71
72
  query, limit=limit, session_id=session_id or None, fast=bool(fast),
72
73
  include_global=include_global, include_shared=include_shared,
74
+ window=window or None,
73
75
  )
74
76
 
75
- # Batch-fetch original memory text for all results
77
+ # Batch-fetch original memory text for all results. Retrieval already
78
+ # enforced scope, so resolve content for everything it returned (own +
79
+ # global + shared-with-me); another tenant's PRIVATE content still can't
80
+ # resolve. Using the raw (possibly-None) recall flags here would drop
81
+ # content for legitimately-recalled global/shared memories.
76
82
  memory_ids = list({r.fact.memory_id for r in response.results[:limit] if r.fact.memory_id})
77
- memory_map = engine._db.get_memory_content_batch(memory_ids) if memory_ids else {}
83
+ memory_map = engine._db.get_memory_content_batch(
84
+ memory_ids, engine.profile_id,
85
+ include_global=True, include_shared=True,
86
+ ) if memory_ids else {}
78
87
 
79
88
  # v3.6.6: same shared chokepoint as the daemon HTTP route + CLI fallback,
80
89
  # so the MCP WorkerPool subprocess path returns identical budgeted output.
@@ -172,11 +181,15 @@ def _handle_store(content: str, metadata: dict) -> dict:
172
181
  def _handle_get_memory_facts(memory_id: str) -> dict:
173
182
  engine = _get_engine()
174
183
  pid = engine.profile_id
175
- # Get original memory content
176
- mem_map = engine._db.get_memory_content_batch([memory_id])
184
+ # Get original memory content (C4: tenant-scoped; global/shared resolvable)
185
+ mem_map = engine._db.get_memory_content_batch(
186
+ [memory_id], pid, include_global=True, include_shared=True,
187
+ )
177
188
  original = mem_map.get(memory_id, "")
178
- # Get child facts
179
- facts = engine._db.get_facts_by_memory_id(memory_id, pid)
189
+ # Get child facts — same scope as the content fetch so a shared/global
190
+ # memory's facts are not silently empty.
191
+ facts = engine._db.get_facts_by_memory_id(
192
+ memory_id, pid, include_global=True, include_shared=True)
180
193
  fact_list = []
181
194
  for f in facts:
182
195
  fact_list.append({
@@ -308,6 +321,7 @@ def _worker_main() -> None:
308
321
  req.get("session_id", ""), bool(req.get("fast", False)),
309
322
  include_global=req.get("include_global"),
310
323
  include_shared=req.get("include_shared"),
324
+ window=req.get("window"),
311
325
  )
312
326
  _respond(result)
313
327
  elif cmd == "store":
@@ -116,6 +116,13 @@ class ScaleEngineManager:
116
116
  # This command reads persisted state, not the live daemon. Never
117
117
  # turn a last-known backend row into a present-tense routing claim.
118
118
  "active": {"cozo": False, "lance": False},
119
+ # LOW-2 (3.7.9): after promote the daemon must restart before the
120
+ # backends actually serve; surface that explicitly so `status` isn't
121
+ # mistaken for "promotion failed".
122
+ "daemon_must_restart_to_activate": (
123
+ state == "promoted"
124
+ and not any(v == "active" for v in runtime.values())
125
+ ),
119
126
  "last_daemon_observation": runtime,
120
127
  "paths_present": paths_present,
121
128
  "retrieval_routing": (
@@ -319,6 +326,8 @@ class ScaleEngineManager:
319
326
  raise ScaleEngineError(
320
327
  f"projection parity failed: canonical={canonical}, observed={observed}"
321
328
  )
329
+ with self._readonly_connection() as conn:
330
+ self._verify_content_sample(conn, cozo, canonical)
322
331
  manifest.update({"state": "verified", "verified_at": _utc_now(), "observed": observed})
323
332
  self._write_manifest(stage_dir, manifest)
324
333
  self.config.scale_engine_state = "verified"
@@ -510,7 +519,31 @@ class ScaleEngineManager:
510
519
  ).fetchone()[0]
511
520
  edges = count_logical_edges(conn, self.profile_id)
512
521
  vectors = count_canonical_vectors(conn, self.profile_id)
513
- return {"entities": int(nodes), "edges": int(edges), "vectors": int(vectors)}
522
+ fact_entity = self._count_fact_entity_links(conn)
523
+ return {
524
+ "entities": int(nodes),
525
+ "edges": int(edges),
526
+ "vectors": int(vectors),
527
+ "fact_entity": int(fact_entity),
528
+ }
529
+
530
+ def _count_fact_entity_links(self, conn: sqlite3.Connection) -> int:
531
+ """Count fact->entity bridge rows exactly as ``bulk_import_from_sqlite``
532
+ projects them: dedup entity IDs per fact, drop empties. This bridge is
533
+ what lets Cozo map a query seed into the fact graph; if its import
534
+ silently fails, count parity on entities/edges/vectors still passes but
535
+ entity recall returns empty — so it must be verified explicitly."""
536
+ total = 0
537
+ for (raw,) in conn.execute(
538
+ "SELECT canonical_entities_json FROM atomic_facts WHERE profile_id=?",
539
+ (self.profile_id,),
540
+ ):
541
+ try:
542
+ entity_ids = json.loads(raw or "[]")
543
+ except (TypeError, ValueError, json.JSONDecodeError):
544
+ continue
545
+ total += sum(1 for eid in dict.fromkeys(entity_ids) if eid)
546
+ return total
514
547
 
515
548
  def _observed_counts(self, cozo: Any, lance: Any) -> dict[str, int]:
516
549
  graph = cozo.health_check()
@@ -521,8 +554,34 @@ class ScaleEngineManager:
521
554
  "entities": int(graph["entities"]),
522
555
  "edges": int(graph["edges"]),
523
556
  "vectors": int(vector["vectors"]),
557
+ "fact_entity": int(graph.get("fact_entity", 0)),
524
558
  }
525
559
 
560
+ def _verify_content_sample(
561
+ self, conn: sqlite3.Connection, cozo: Any, canonical: dict[str, int]
562
+ ) -> None:
563
+ """Content parity: count equality does not prove the import reproduced
564
+ the correct rows. Compare the projected entity-ID set against canonical
565
+ SQLite (bounded), catching a projection that has the right entity count
566
+ but the wrong identities."""
567
+ getter = getattr(cozo, "entity_ids", None)
568
+ n = int(canonical.get("entities", 0))
569
+ if not callable(getter) or n == 0:
570
+ return # backend cannot sample, or nothing to compare
571
+ observed_ids = set(getter(limit=n))
572
+ rows = conn.execute(
573
+ "SELECT entity_id FROM canonical_entities WHERE profile_id=?",
574
+ (self.profile_id,),
575
+ ).fetchall()
576
+ expected_ids = {r[0] for r in rows}
577
+ if expected_ids != observed_ids:
578
+ missing = sorted(expected_ids - observed_ids)[:5]
579
+ extra = sorted(observed_ids - expected_ids)[:5]
580
+ raise ScaleEngineError(
581
+ "projection content parity failed: entity IDs diverged "
582
+ f"(missing sample={missing}, unexpected sample={extra})"
583
+ )
584
+
526
585
  def _projection_fingerprint(
527
586
  self, conn: sqlite3.Connection, counts: dict[str, int]
528
587
  ) -> str:
@@ -399,6 +399,35 @@ def redact_secrets(text: str, *, entropy_threshold: float = 4.5,
399
399
  # ---------------------------------------------------------------------------
400
400
 
401
401
 
402
+ def harden_db_perms(db_path: str | Path) -> None:
403
+ """Restrict a database file (and its WAL/SHM sidecars) to owner-only 0600
404
+ and its parent directory to 0700 (C4 encryption-at-rest defense-in-depth).
405
+
406
+ Best-effort and POSIX-only: on Windows or if the file does not exist yet
407
+ this is a silent no-op. The daemon's data dir is already 0700, but the DB
408
+ files themselves shipped 0644 (world-readable); on a shared host that is a
409
+ real exposure even with full-disk encryption at rest.
410
+ """
411
+ if _is_windows():
412
+ return
413
+ try:
414
+ p = Path(db_path)
415
+ parent = p.parent
416
+ try:
417
+ os.chmod(parent, 0o700)
418
+ except OSError:
419
+ pass
420
+ for suffix in ("", "-wal", "-shm"):
421
+ f = Path(str(p) + suffix)
422
+ if f.exists():
423
+ try:
424
+ os.chmod(f, 0o600)
425
+ except OSError:
426
+ pass
427
+ except Exception: # pragma: no cover — never block DB open on a chmod
428
+ pass
429
+
430
+
402
431
  def _install_token_path() -> Path: # pragma: no cover — monkeypatched in tests
403
432
  """Default install-token location — override in tests via monkeypatch."""
404
433
  from superlocalmemory.infra.data_root import state_path
@@ -598,8 +627,17 @@ def run_subprocess_safe(
598
627
  - Restricted environment by default — only a minimal set of safe keys.
599
628
  - Callers may pass an explicit ``env`` to add specific variables.
600
629
 
601
- This is the ONE place in the codebase allowed to call ``subprocess.run``.
602
- Grep guard in CI enforces this (LLD-07 §7 SEC-HR-06).
630
+ This is the PREFERRED wrapper for any subprocess whose ``argv`` includes
631
+ dynamic or externally-influenced values routing them through here keeps
632
+ ``shell=False``, a mandatory timeout, and a restricted environment.
633
+
634
+ It is not the *only* ``subprocess.run`` call site: a small set of vetted
635
+ callers invoke ``subprocess.run`` directly where they need inherited stdio
636
+ for live progress, a long-lived managed process, or process-group control
637
+ (e.g. model downloads in ``cli/setup_wizard.py``, ``infra/process_reaper``,
638
+ ``cli/service_installer``). Those pass only fixed/argv-quoted values — never
639
+ a shell string. Do not read this wrapper as a guarantee that no other
640
+ subprocess call exists; audit new call sites individually. (L-01, 3.7.9)
603
641
  """
604
642
  if not isinstance(argv, list):
605
643
  raise TypeError("argv must be list[str], shell=False only")
@@ -208,21 +208,33 @@ def run_store(
208
208
  if not pre_authorized:
209
209
  hooks.run_pre("store", hook_ctx)
210
210
 
211
- if entropy_gate and not entropy_gate.should_pass(content):
211
+ # Admission gates apply to FRESH submissions only. A materialization pass
212
+ # re-runs this pipeline for content whose queryable projection was already
213
+ # committed at submit (``queryable_fact_ids`` is set). Re-applying admission
214
+ # here — the entropy near-duplicate gate especially — would discard that
215
+ # already-committed fact (return []) and wedge the operation in an endless
216
+ # materialize retry loop ("materialization produced no final facts"). The
217
+ # near-duplicate verdict is expected at materialize: the submitted
218
+ # projection itself is in the gate's window. Admission was already decided
219
+ # at submit (store_fast enforces the same gates), so skip it here.
220
+ is_materialization = bool(queryable_fact_ids)
221
+
222
+ if entropy_gate and not is_materialization and not entropy_gate.should_pass(content):
212
223
  return []
213
224
 
214
225
  # v3.5.0: store-side quality gate (H3). Reject prompt-template leakage,
215
226
  # empty placeholders, and other non-memory content BEFORE it enters the
216
227
  # DB. Uses the shared is_low_quality from core/injection so both store
217
228
  # AND injection filter by identical rules. Saves DB IO + recall pollution.
218
- try:
219
- from superlocalmemory.core.injection import is_low_quality
220
- if is_low_quality(content):
221
- logger.debug("Store rejected (low-quality content): %s...",
222
- content[:80].replace("\n", " "))
223
- return []
224
- except Exception:
225
- pass # Best-effort gate; store succeeds if import fails
229
+ if not is_materialization:
230
+ try:
231
+ from superlocalmemory.core.injection import is_low_quality
232
+ if is_low_quality(content):
233
+ logger.debug("Store rejected (low-quality content): %s...",
234
+ content[:80].replace("\n", " "))
235
+ return []
236
+ except Exception:
237
+ pass # Best-effort gate; store succeeds if import fails
226
238
 
227
239
  from superlocalmemory.encoding.temporal_parser import TemporalParser
228
240
  parser = temporal_parser or TemporalParser()
@@ -583,14 +595,26 @@ def run_store(
583
595
  fact.fact_id, exc,
584
596
  )
585
597
 
598
+ # Phase 4b: fact-augmented key expansion (T3b). Index entity aliases /
599
+ # canonical names as BM25 alt-keys so paraphrased queries match. Mode A
600
+ # (entity graph) is zero-LLM and safe on the hot store path; LLM
601
+ # paraphrase enrichment (Mode B/C) is left to background consolidation.
602
+ try:
603
+ from superlocalmemory.core.key_expander import KeyExpander
604
+ _alt_keys = KeyExpander(db).expand(fact, profile_id, mode="a")
605
+ if _alt_keys:
606
+ db.upsert_fact_expansion(fact.fact_id, _alt_keys)
607
+ except Exception as exc:
608
+ logger.debug("Key expansion skipped for %s: %s", fact.fact_id, exc)
609
+
586
610
  if observation_builder:
587
611
  for eid in fact.canonical_entities:
588
612
  observation_builder.update_profile(eid, fact, profile_id)
589
613
 
590
- # Increment fact_count for each linked canonical entity
614
+ # Increment fact_count for each linked canonical entity (scoped to profile).
591
615
  for eid in fact.canonical_entities:
592
616
  try:
593
- db.increment_entity_fact_count(eid)
617
+ db.increment_entity_fact_count(eid, profile_id)
594
618
  except Exception:
595
619
  pass # Non-critical — entity may have been deleted
596
620
  if scene_builder:
@@ -70,6 +70,7 @@ class WorkerPool:
70
70
  fast: bool = False,
71
71
  include_global: bool | None = None,
72
72
  include_shared: bool | None = None,
73
+ window: str | None = None,
73
74
  ) -> dict:
74
75
  """Run recall in worker subprocess. Returns result dict.
75
76
 
@@ -91,6 +92,8 @@ class WorkerPool:
91
92
  msg["include_global"] = bool(include_global)
92
93
  if include_shared is not None:
93
94
  msg["include_shared"] = bool(include_shared)
95
+ if window:
96
+ msg["window"] = window
94
97
  return self._send(msg)
95
98
 
96
99
  def store(self, content: str, metadata: dict | None = None) -> dict:
@@ -291,15 +294,27 @@ class WorkerPool:
291
294
  self._idle_timer.cancel()
292
295
  self._idle_timer = None
293
296
  if self._proc is not None:
294
- pid = self._proc.pid
297
+ proc = self._proc
298
+ pid = proc.pid
295
299
  try:
296
- self._proc.stdin.write('{"cmd":"quit"}\n')
297
- self._proc.stdin.flush()
298
- self._proc.wait(timeout=3)
300
+ proc.stdin.write('{"cmd":"quit"}\n')
301
+ proc.stdin.flush()
302
+ proc.wait(timeout=3)
299
303
  except Exception:
300
304
  try:
301
- self._proc.kill()
302
- self._proc.wait(timeout=2)
305
+ proc.kill()
306
+ proc.wait(timeout=2)
307
+ except Exception:
308
+ pass
309
+ # L-CONC-1: deterministically close the pipe fds rather than leaving
310
+ # them to GC. This releases the OS handles immediately and unblocks
311
+ # any orphaned _readline_with_timeout reader thread (its readline
312
+ # returns/raises on the closed pipe), so repeated request timeouts
313
+ # cannot accumulate reader threads or file descriptors. Cross-platform.
314
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
315
+ try:
316
+ if stream is not None:
317
+ stream.close()
303
318
  except Exception:
304
319
  pass
305
320
  self._proc = None
@@ -0,0 +1,200 @@
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 | https://varunpratap.com
4
+
5
+ """Reflexion self-review over LLM-extracted entities (Wave Q1).
6
+
7
+ After primary LLM extraction (Mode B/C only), one bounded self-review pass
8
+ audits the extracted entities against the source text and:
9
+
10
+ - drops hallucinated entities (not supported by the source), and
11
+ - adds clearly-missed entities (whose exact text appears in the source).
12
+
13
+ This closes the two distinct extraction failure modes the market leaders
14
+ separate: coverage (missed) and grounding (hallucinated). Coverage is the
15
+ "gleaning" idea (re-present what was extracted, ask for misses); grounding is
16
+ the "reflexion" idea (verify each extracted entity is real).
17
+
18
+ Design guarantees:
19
+ - Mode A never reaches here — the caller gates on Mode B/C + LLM available.
20
+ - Fail-open: any error, unavailable LLM, or unparseable output returns the
21
+ input facts unchanged. This module NEVER raises.
22
+ - Immutable: corrected facts are new objects; inputs are not mutated.
23
+ - Bounded: one LLM call, capped facts reviewed, capped entities per fact.
24
+
25
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
26
+ License: AGPL-3.0-or-later
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import re
34
+ from dataclasses import replace
35
+ from typing import Any
36
+
37
+ from superlocalmemory.storage.models import AtomicFact
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ _MAX_ENTITIES_PER_FACT = 12
42
+ _MIN_ENTITY_LEN = 2
43
+
44
+ _SYSTEM = (
45
+ "You are a precise information-extraction auditor. You verify that "
46
+ "entities extracted from text are actually supported by that text, and "
47
+ "you catch clearly-named entities that were missed. You never invent "
48
+ "entities. You reply with JSON only."
49
+ )
50
+
51
+
52
+ class EntityReflexion:
53
+ """Bounded, fail-open reflexion pass over extracted entities."""
54
+
55
+ def __init__(
56
+ self,
57
+ llm: Any,
58
+ max_facts: int = 8,
59
+ max_tokens: int = 512,
60
+ ) -> None:
61
+ self._llm = llm
62
+ self._max_facts = max(1, int(max_facts))
63
+ self._max_tokens = max(64, int(max_tokens))
64
+
65
+ # ------------------------------------------------------------------
66
+ # Public API
67
+ # ------------------------------------------------------------------
68
+
69
+ def refine(
70
+ self, source_text: str, facts: list[AtomicFact],
71
+ ) -> list[AtomicFact]:
72
+ """Return facts with entity lists corrected, or unchanged on failure."""
73
+ if not self._llm or not facts or not source_text or not source_text.strip():
74
+ return facts
75
+ try:
76
+ is_avail = getattr(self._llm, "is_available", None)
77
+ if callable(is_avail) and not is_avail():
78
+ return facts
79
+ except Exception:
80
+ return facts
81
+
82
+ subject = facts[: self._max_facts]
83
+ try:
84
+ raw = self._invoke(source_text, subject)
85
+ except Exception as exc:
86
+ logger.debug("EntityReflexion LLM call failed: %s", exc)
87
+ return facts
88
+
89
+ corrections = self._parse(raw)
90
+ if not corrections:
91
+ return facts
92
+ try:
93
+ return self._apply(facts, corrections, source_text)
94
+ except Exception as exc:
95
+ logger.debug("EntityReflexion apply failed: %s", exc)
96
+ return facts
97
+
98
+ # ------------------------------------------------------------------
99
+ # Internal
100
+ # ------------------------------------------------------------------
101
+
102
+ def _invoke(self, source_text: str, subject: list[AtomicFact]) -> str:
103
+ lines = []
104
+ for i, f in enumerate(subject):
105
+ lines.append(f'{i}: "{f.content}" entities={list(f.entities)}')
106
+ facts_block = "\n".join(lines)
107
+ prompt = (
108
+ "Audit the extracted entities for each fact against the SOURCE "
109
+ "text below.\n"
110
+ "For each fact, report:\n"
111
+ "- drop: entities in the fact's list that are NOT supported by the "
112
+ "source (hallucinated).\n"
113
+ "- add: named entities clearly present in the source but missing "
114
+ "from the fact's list. Only add entities whose exact text appears "
115
+ "in the source.\n\n"
116
+ f"--- SOURCE ---\n{source_text}\n--- END ---\n\n"
117
+ f"--- FACTS ---\n{facts_block}\n--- END ---\n\n"
118
+ 'Respond with ONLY a JSON array: '
119
+ '[{"index": <int>, "drop": [..], "add": [..]}]. '
120
+ "Use [] for an empty list. Omit facts that need no change."
121
+ )
122
+ out = self._llm.generate(
123
+ prompt=prompt,
124
+ system=_SYSTEM,
125
+ temperature=0.0,
126
+ max_tokens=self._max_tokens,
127
+ )
128
+ return out if isinstance(out, str) else str(out or "")
129
+
130
+ @staticmethod
131
+ def _parse(raw: str) -> list[dict]:
132
+ if not raw or not raw.strip():
133
+ return []
134
+ match = re.search(r"\[.*\]", raw, re.DOTALL)
135
+ if not match:
136
+ return []
137
+ try:
138
+ data = json.loads(match.group())
139
+ except (json.JSONDecodeError, ValueError):
140
+ return []
141
+ if not isinstance(data, list):
142
+ return []
143
+ return [d for d in data if isinstance(d, dict)]
144
+
145
+ def _apply(
146
+ self,
147
+ facts: list[AtomicFact],
148
+ corrections: list[dict],
149
+ source_text: str,
150
+ ) -> list[AtomicFact]:
151
+ by_index: dict[int, dict] = {}
152
+ for c in corrections:
153
+ idx = c.get("index")
154
+ if isinstance(idx, int) and 0 <= idx < self._max_facts:
155
+ by_index[idx] = c
156
+
157
+ src_low = source_text.lower()
158
+ out: list[AtomicFact] = []
159
+ for i, fact in enumerate(facts):
160
+ c = by_index.get(i)
161
+ if c is None:
162
+ out.append(fact)
163
+ continue
164
+
165
+ ents = list(fact.entities)
166
+ drop = {
167
+ str(d).strip().lower()
168
+ for d in _as_list(c.get("drop"))
169
+ if str(d).strip()
170
+ }
171
+ if drop:
172
+ ents = [e for e in ents if e.strip().lower() not in drop]
173
+
174
+ seen = {e.strip().lower() for e in ents}
175
+ for a in _as_list(c.get("add")):
176
+ cand = str(a).strip()
177
+ low = cand.lower()
178
+ if len(cand) < _MIN_ENTITY_LEN or low in seen:
179
+ continue
180
+ # Grounding guard: only add entities literally present in source.
181
+ if low not in src_low:
182
+ continue
183
+ ents.append(cand)
184
+ seen.add(low)
185
+ if len(ents) >= _MAX_ENTITIES_PER_FACT:
186
+ break
187
+
188
+ if ents != list(fact.entities):
189
+ out.append(replace(fact, entities=ents))
190
+ else:
191
+ out.append(fact)
192
+ return out
193
+
194
+
195
+ def _as_list(value: Any) -> list:
196
+ if isinstance(value, list):
197
+ return value
198
+ if value is None:
199
+ return []
200
+ return [value]
@@ -283,22 +283,22 @@ class EntityResolver:
283
283
  entity = self._db.get_entity_by_name(name, profile_id)
284
284
  if entity is not None:
285
285
  resolution[raw] = entity.entity_id
286
- self._touch_last_seen(entity.entity_id)
286
+ self._touch_last_seen(entity.entity_id, profile_id)
287
287
  continue
288
288
 
289
289
  # Tier b: alias match (case-insensitive, indexed)
290
290
  entity_id = self._alias_lookup(name, profile_id)
291
291
  if entity_id is not None:
292
292
  resolution[raw] = entity_id
293
- self._touch_last_seen(entity_id)
293
+ self._touch_last_seen(entity_id, profile_id)
294
294
  continue
295
295
 
296
296
  # Tier c: fuzzy match via Jaro-Winkler
297
297
  match_id, score = self._fuzzy_match(name, profile_id)
298
298
  if match_id is not None and score >= JARO_WINKLER_AUTO_MERGE:
299
299
  resolution[raw] = match_id
300
- self._persist_alias(match_id, name, score, "jaro_winkler")
301
- self._touch_last_seen(match_id)
300
+ self._persist_alias(match_id, name, score, "jaro_winkler", profile_id)
301
+ self._touch_last_seen(match_id, profile_id)
302
302
  continue
303
303
 
304
304
  # Candidate zone (0.70–0.85): queue for LLM in Mode B/C
@@ -384,7 +384,7 @@ class EntityResolver:
384
384
  and removes the merged entity record.
385
385
  """
386
386
  # Move aliases from merge -> keep
387
- aliases = self._db.get_aliases_for_entity(entity_id_merge)
387
+ aliases = self._db.get_aliases_for_entity(entity_id_merge, profile_id)
388
388
  for alias in aliases:
389
389
  new_alias = EntityAlias(
390
390
  alias_id=_new_id(),
@@ -393,7 +393,7 @@ class EntityResolver:
393
393
  confidence=alias.confidence,
394
394
  source=f"merge_from:{entity_id_merge}",
395
395
  )
396
- self._db.store_alias(new_alias)
396
+ self._db.store_alias(new_alias, profile_id)
397
397
 
398
398
  # Also add the merged entity's canonical name as an alias of keep
399
399
  merged = self._db.get_entity_by_name("", "") # placeholder
@@ -403,7 +403,7 @@ class EntityResolver:
403
403
  )
404
404
  if rows:
405
405
  merged_name = str(dict(rows[0])["canonical_name"])
406
- self._persist_alias(entity_id_keep, merged_name, 1.0, "merge")
406
+ self._persist_alias(entity_id_keep, merged_name, 1.0, "merge", profile_id)
407
407
 
408
408
  # Update atomic_facts: replace entity_id_merge with entity_id_keep
409
409
  # in canonical_entities_json column
@@ -435,14 +435,14 @@ class EntityResolver:
435
435
  except (json.JSONDecodeError, TypeError):
436
436
  continue
437
437
 
438
- # Delete the merged entity
438
+ # Delete the merged entity (tenant-scoped)
439
439
  self._db.execute(
440
- "DELETE FROM entity_aliases WHERE entity_id = ?",
441
- (entity_id_merge,),
440
+ "DELETE FROM entity_aliases WHERE entity_id = ? AND profile_id = ?",
441
+ (entity_id_merge, profile_id),
442
442
  )
443
443
  self._db.execute(
444
- "DELETE FROM canonical_entities WHERE entity_id = ?",
445
- (entity_id_merge,),
444
+ "DELETE FROM canonical_entities WHERE entity_id = ? AND profile_id = ?",
445
+ (entity_id_merge, profile_id),
446
446
  )
447
447
  logger.info(
448
448
  "Merged entity %s into %s (profile=%s)",
@@ -526,7 +526,7 @@ class EntityResolver:
526
526
  self._db.store_entity(entity)
527
527
 
528
528
  # Store name as its own alias for uniform lookup
529
- self._persist_alias(entity.entity_id, name, 1.0, "canonical")
529
+ self._persist_alias(entity.entity_id, name, 1.0, "canonical", profile_id)
530
530
 
531
531
  logger.debug(
532
532
  "Created entity '%s' [%s] (type=%s, profile=%s)",
@@ -540,13 +540,16 @@ class EntityResolver:
540
540
  alias_text: str,
541
541
  confidence: float,
542
542
  source: str,
543
+ profile_id: str,
543
544
  ) -> None:
544
- """Store an alias, skipping duplicates."""
545
- # Check if alias already exists for this entity
545
+ """Store an alias under a profile, skipping duplicates within it."""
546
+ # Check if alias already exists for this entity IN THIS PROFILE. The
547
+ # dedup must be profile-scoped or one profile's alias suppresses
548
+ # another profile's identical alias for a same-id entity.
546
549
  existing = self._db.execute(
547
550
  "SELECT alias_id FROM entity_aliases "
548
- "WHERE entity_id = ? AND LOWER(alias) = LOWER(?)",
549
- (entity_id, alias_text),
551
+ "WHERE entity_id = ? AND profile_id = ? AND LOWER(alias) = LOWER(?)",
552
+ (entity_id, profile_id, alias_text),
550
553
  )
551
554
  if existing:
552
555
  return
@@ -557,13 +560,20 @@ class EntityResolver:
557
560
  confidence=confidence,
558
561
  source=source,
559
562
  )
560
- self._db.store_alias(alias)
563
+ self._db.store_alias(alias, profile_id)
561
564
 
562
- def _touch_last_seen(self, entity_id: str) -> None:
563
- """Update last_seen timestamp on a canonical entity."""
565
+ def _touch_last_seen(self, entity_id: str, profile_id: str = "default") -> None:
566
+ """Update last_seen timestamp on a canonical entity scoped to profile.
567
+
568
+ L-01 fix: the original query had no profile_id guard. Since entity_ids are
569
+ UUIDs today the blast radius is theoretical, but the guard is required for
570
+ defense-in-depth against future import/sharing features that could introduce
571
+ UUID collisions across profiles.
572
+ """
564
573
  self._db.execute(
565
- "UPDATE canonical_entities SET last_seen = ? WHERE entity_id = ?",
566
- (_now(), entity_id),
574
+ "UPDATE canonical_entities SET last_seen = ? "
575
+ "WHERE entity_id = ? AND profile_id = ?",
576
+ (_now(), entity_id, profile_id),
567
577
  )
568
578
 
569
579
  # -- Internal: LLM disambiguation (Mode B/C) ---------------------------
@@ -621,9 +631,9 @@ class EntityResolver:
621
631
  entity_id = known[name_str]
622
632
  resolved[mention_str] = entity_id
623
633
  self._persist_alias(
624
- entity_id, mention_str, 0.9, "llm",
634
+ entity_id, mention_str, 0.9, "llm", profile_id,
625
635
  )
626
- self._touch_last_seen(entity_id)
636
+ self._touch_last_seen(entity_id, profile_id)
627
637
  # If LLM says it's itself, leave for caller to create
628
638
  return resolved
629
639