superlocalmemory 3.8.2 → 3.8.5

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 (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -463,40 +463,141 @@ class IngestionOperationRepository:
463
463
  derivation_state: dict[str, bool] | None = None,
464
464
  last_error: str = "",
465
465
  ) -> IngestionOperation:
466
- """Finish only work owned by the caller's durable lease."""
466
+ """Finish only work owned by the caller's durable lease.
467
+
468
+ Fix E: when transitioning to FAILED and the operation has exhausted
469
+ ``_MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS``, atomically INSERT a row
470
+ into ``dead_letter_operations`` and set ``next_retry_at=0`` (no further
471
+ retries scheduled). The original row in ``ingestion_operations`` is
472
+ retained — the dead-letter row is a supplemental audit record, not a
473
+ replacement. Both writes happen inside a single ``db.transaction()``.
474
+
475
+ NOTE (Flaw 1 from CRIT): M031 may not exist if the live database has
476
+ not run migration_runner against it yet. The INSERT is therefore
477
+ wrapped in a try/except so that an absent table degrades gracefully to
478
+ the pre-3.8.4 silent-FAILED behaviour rather than breaking ingestion.
479
+ Operators who have run ``slm migrate`` will get the DLQ row.
480
+ """
467
481
  if target not in {IngestionState.COMPLETE, IngestionState.FAILED}:
468
482
  raise InvalidStateTransition(f"enriching -> {target.value}")
469
483
  current = self.get(operation_id)
470
- retry_at = 0.0
471
- if target is IngestionState.FAILED:
484
+ # Attempt count AFTER this transition = current + 1 (the UPDATE adds
485
+ # the delta via attempt_count+delta but here we compare the pre-update
486
+ # value + 1 to the cap).
487
+ next_attempt_count = current.attempt_count + 1
488
+ is_exhausted = (
489
+ target is IngestionState.FAILED
490
+ and next_attempt_count >= _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
491
+ )
492
+ # Fix E: exhausted → far-future retry_at so list_materializable's
493
+ # `next_retry_at <= now` clause never matches, excluding the
494
+ # dead-lettered op from the work queue permanently.
495
+ # 9_999_999_999 ≈ year 2286 — well beyond any reasonable operation window.
496
+ _NEVER_RETRY: float = 9_999_999_999.0
497
+ retry_at: float = _NEVER_RETRY if is_exhausted else 0.0
498
+ if target is IngestionState.FAILED and not is_exhausted:
472
499
  delay = min(2 ** min(max(current.attempt_count, 1), 10), 300)
473
500
  retry_at = time.time() + delay
474
- rows = self.db.execute(
475
- "UPDATE ingestion_operations SET state=?, "
476
- "final_fact_ids_json=COALESCE(?, final_fact_ids_json), "
477
- "derivation_version=COALESCE(?, derivation_version), "
478
- "derivation_state_json=COALESCE(?, derivation_state_json), "
479
- "lease_owner='', lease_expires_at=0, next_retry_at=?, last_error=?, "
480
- "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
481
- "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
482
- "RETURNING *",
483
- (
484
- target.value,
485
- _canonical_json(list(final_fact_ids))
486
- if final_fact_ids is not None
487
- else None,
488
- derivation_version,
489
- _canonical_json(derivation_state)
490
- if derivation_state is not None
491
- else None,
492
- retry_at,
493
- last_error,
494
- operation_id,
495
- owner,
496
- ),
497
- )
498
- if not rows:
499
- raise InvalidStateTransition("enriching lease ownership was lost")
501
+
502
+ try:
503
+ with self.db.transaction():
504
+ rows = self.db.execute(
505
+ "UPDATE ingestion_operations SET state=?, "
506
+ "final_fact_ids_json=COALESCE(?, final_fact_ids_json), "
507
+ "derivation_version=COALESCE(?, derivation_version), "
508
+ "derivation_state_json=COALESCE(?, derivation_state_json), "
509
+ "lease_owner='', lease_expires_at=0, next_retry_at=?, last_error=?, "
510
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
511
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
512
+ "RETURNING *",
513
+ (
514
+ target.value,
515
+ _canonical_json(list(final_fact_ids))
516
+ if final_fact_ids is not None
517
+ else None,
518
+ derivation_version,
519
+ _canonical_json(derivation_state)
520
+ if derivation_state is not None
521
+ else None,
522
+ retry_at,
523
+ last_error,
524
+ operation_id,
525
+ owner,
526
+ ),
527
+ )
528
+ if not rows:
529
+ raise InvalidStateTransition(
530
+ "enriching lease ownership was lost"
531
+ )
532
+ if is_exhausted:
533
+ # Fix E: INSERT dead-letter row inside the same transaction.
534
+ # Failure to write (e.g. M031 not yet migrated) must NOT
535
+ # abort the state-machine UPDATE — catch at the outer level.
536
+ self.db.execute(
537
+ "INSERT INTO dead_letter_operations "
538
+ "(original_op_id, operation_type, content, "
539
+ " metadata_json, error, attempt_count, "
540
+ " first_attempt_at, profile_id) "
541
+ "VALUES (?, 'M018', ?, ?, ?, ?, "
542
+ " (SELECT unixepoch(created_at) FROM ingestion_operations "
543
+ " WHERE operation_id=?), ?)",
544
+ (
545
+ operation_id,
546
+ current.raw_content,
547
+ json.dumps(current.metadata, separators=(",", ":"))
548
+ if current.metadata else None,
549
+ last_error or current.last_error,
550
+ next_attempt_count,
551
+ operation_id,
552
+ current.profile_id,
553
+ ),
554
+ )
555
+ logger.warning(
556
+ "Operation %s exhausted %d attempts — moved to dead-letter. "
557
+ "Last error: %s",
558
+ operation_id,
559
+ next_attempt_count,
560
+ last_error or current.last_error,
561
+ )
562
+ except InvalidStateTransition:
563
+ raise
564
+ except Exception as exc:
565
+ # Fix E graceful-degrade: if dead-letter INSERT fails (M031 absent),
566
+ # fall back to the pre-3.8.4 silent-FAILED state so ingestion
567
+ # continues unblocked.
568
+ logger.error(
569
+ "finish_enriching failed (dead-letter path): %s — retrying "
570
+ "without dead-letter INSERT",
571
+ exc,
572
+ )
573
+ rows = self.db.execute(
574
+ "UPDATE ingestion_operations SET state=?, "
575
+ "final_fact_ids_json=COALESCE(?, final_fact_ids_json), "
576
+ "derivation_version=COALESCE(?, derivation_version), "
577
+ "derivation_state_json=COALESCE(?, derivation_state_json), "
578
+ "lease_owner='', lease_expires_at=0, next_retry_at=?, last_error=?, "
579
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
580
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
581
+ "RETURNING *",
582
+ (
583
+ target.value,
584
+ _canonical_json(list(final_fact_ids))
585
+ if final_fact_ids is not None
586
+ else None,
587
+ derivation_version,
588
+ _canonical_json(derivation_state)
589
+ if derivation_state is not None
590
+ else None,
591
+ retry_at,
592
+ last_error,
593
+ operation_id,
594
+ owner,
595
+ ),
596
+ )
597
+ if not rows:
598
+ raise InvalidStateTransition(
599
+ "enriching lease ownership was lost"
600
+ ) from exc
500
601
  return self._from_row(rows[0])
501
602
 
502
603
 
@@ -624,10 +725,32 @@ class IngestionCommand:
624
725
  with _materialization_lock(operation_id):
625
726
  return self._materialize_locked(operation_id)
626
727
 
627
- def _materialize_locked(self, operation_id: str) -> IngestionOperation:
728
+ def _materialize_locked(
729
+ self,
730
+ operation_id: str,
731
+ *,
732
+ force: bool = False,
733
+ ) -> IngestionOperation:
628
734
  operation = self.repository.get(operation_id)
629
735
  if operation.state is IngestionState.COMPLETE:
630
736
  return operation
737
+ # Fix E: guard against re-attempting exhausted (dead-lettered) operations.
738
+ # attempt_count is incremented by claim_enriching BEFORE this method sees
739
+ # the new value, so the cap check uses the CURRENT (pre-claim) count.
740
+ # After the (cap-1)th attempt is claimed, attempt_count reaches cap-1;
741
+ # finish_enriching sees current.attempt_count=cap-1, next_attempt_count=cap
742
+ # and inserts the dead-letter row. Any subsequent materialize() call
743
+ # (attempt_count already at cap-1 in FAILED state) would re-claim and
744
+ # insert a second dead-letter row — this guard prevents that.
745
+ # ``force=True`` is the operator escape hatch used by retry() — it bypasses
746
+ # this guard so an admin can manually re-enqueue a dead-lettered operation.
747
+ if (
748
+ not force
749
+ and operation.state is IngestionState.FAILED
750
+ and operation.attempt_count >= _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS - 1
751
+ ):
752
+ # Already dead-lettered — return FAILED without re-claiming.
753
+ return operation
631
754
  if operation.state not in {
632
755
  IngestionState.QUERYABLE,
633
756
  IngestionState.ENRICHING,
@@ -757,9 +880,15 @@ class IngestionCommand:
757
880
  )
758
881
 
759
882
  def retry(self, operation_id: str) -> IngestionOperation:
883
+ """Operator escape hatch: force-retry a FAILED operation regardless of
884
+ attempt_count. Bypasses the Fix E dead-letter guard so an admin can
885
+ manually re-enqueue an exhausted operation after configuration repair.
886
+ """
760
887
  operation = self.repository.get(operation_id)
761
888
  if operation.state is not IngestionState.FAILED:
762
889
  raise InvalidStateTransition(
763
890
  f"cannot retry operation in {operation.state.value}"
764
891
  )
765
- return self.materialize(operation_id)
892
+ # force=True bypasses the _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS guard.
893
+ with _materialization_lock(operation_id):
894
+ return self._materialize_locked(operation_id, force=True)
@@ -60,17 +60,42 @@ class MaintenanceScheduler:
60
60
  return
61
61
  self._running = True
62
62
  self._schedule_next()
63
+ # v3.8.5: one-shot activation-cache GC ~90s after boot so an upgrade
64
+ # backlog (observed 83k expired rows) clears promptly instead of waiting
65
+ # a full interval — delayed past boot warmup so it never competes for
66
+ # the write lock during the startup window.
67
+ self._initial_gc_timer = threading.Timer(90.0, self._initial_cache_gc)
68
+ self._initial_gc_timer.daemon = True
69
+ self._initial_gc_timer.start()
63
70
  logger.info(
64
71
  "Maintenance scheduler started (interval=%dm)",
65
72
  self._config.forgetting.scheduler_interval_minutes,
66
73
  )
67
74
 
75
+ def _initial_cache_gc(self) -> None:
76
+ """Best-effort one-shot activation-cache GC shortly after boot."""
77
+ if not self._running:
78
+ return
79
+ try:
80
+ deleted = self._db.cleanup_activation_cache()
81
+ if deleted > 0:
82
+ logger.info(
83
+ "Activation-cache GC (startup): %d expired rows removed",
84
+ deleted,
85
+ )
86
+ except Exception as exc:
87
+ logger.debug("Startup activation-cache GC skipped: %s", exc)
88
+
68
89
  def stop(self) -> None:
69
90
  """Stop the scheduler. Idempotent."""
70
91
  self._running = False
71
92
  if self._timer is not None:
72
93
  self._timer.cancel()
73
94
  self._timer = None
95
+ _gc_timer = getattr(self, "_initial_gc_timer", None)
96
+ if _gc_timer is not None:
97
+ _gc_timer.cancel()
98
+ self._initial_gc_timer = None
74
99
  logger.info("Maintenance scheduler stopped")
75
100
 
76
101
  def _schedule_next(self) -> None:
@@ -125,9 +150,33 @@ class MaintenanceScheduler:
125
150
  )
126
151
 
127
152
  # V3.4.11: Graph pruning (remove orphan edges)
153
+ # v3.8.4-G: thread GraphPruningConfig params so dashboard changes
154
+ # persist and take effect without a daemon restart.
128
155
  try:
129
156
  from superlocalmemory.core.graph_pruner import prune_graph
130
- prune_stats = prune_graph(self._db.db_path, profile_id)
157
+ gp = self._config.graph_pruning
158
+ if not gp.enabled:
159
+ logger.debug(
160
+ "Graph pruning disabled by config for %s — skipping",
161
+ profile_id,
162
+ )
163
+ else:
164
+ # Fix A: pass DatabaseManager directly → writes serialised through _lock
165
+ prune_stats = prune_graph(
166
+ self._db,
167
+ profile_id,
168
+ max_degree=gp.max_degree_per_node,
169
+ min_edge_weight=gp.min_edge_weight,
170
+ )
171
+ removed = prune_stats["total_before"] - prune_stats["total_after"]
172
+ if removed > 0:
173
+ logger.info(
174
+ "Graph pruning for %s: %d edges removed", profile_id, removed
175
+ )
176
+ except AttributeError:
177
+ # Older SLMConfig without graph_pruning field (upgrade safety)
178
+ from superlocalmemory.core.graph_pruner import prune_graph
179
+ prune_stats = prune_graph(self._db, profile_id)
131
180
  removed = prune_stats["total_before"] - prune_stats["total_after"]
132
181
  if removed > 0:
133
182
  logger.info("Graph pruning for %s: %d edges removed", profile_id, removed)
@@ -171,6 +220,17 @@ class MaintenanceScheduler:
171
220
  except Exception as exc:
172
221
  logger.debug("Pending cleanup skipped: %s", exc)
173
222
 
223
+ # v3.8.5: GC the spreading-activation result cache. Neither cleanup path
224
+ # was ever wired in, so activation_cache grew without bound (observed
225
+ # 83k expired rows, oldest ~3.5 months). Batched + DB-wide (expiry is
226
+ # not profile-scoped), so it runs once per cycle rather than per profile.
227
+ try:
228
+ deleted = self._db.cleanup_activation_cache()
229
+ if deleted > 0:
230
+ logger.info("Activation-cache GC: %d expired rows removed", deleted)
231
+ except Exception as exc:
232
+ logger.debug("Activation-cache GC skipped: %s", exc)
233
+
174
234
  self._schedule_next()
175
235
 
176
236
  def _profile_ids(self) -> tuple[str, ...]:
@@ -776,6 +776,9 @@ def run_recall(
776
776
  if access_log and response.results:
777
777
  try:
778
778
  fact_ids = [r.fact.fact_id for r in response.results]
779
+ # Recall exposure logging is a durable analytics contract (tested),
780
+ # so it stays synchronous — it is a single batched write, cheap
781
+ # relative to the deferred spreading-activation cache / last_seen.
779
782
  access_log.store_access_batch(
780
783
  fact_ids=fact_ids,
781
784
  profile_id=profile_id,
@@ -110,7 +110,11 @@ class AgentRegistry:
110
110
  try:
111
111
  data = json.loads(self._path.read_text(encoding="utf-8"))
112
112
  self._agents = data.get("agents", {})
113
- self._write_locks = data.get("write_locks", {})
113
+ # D-01: write_locks from a prior process are meaningless after a
114
+ # process boundary — the agents that held them are dead. Starting
115
+ # with no inherited locks prevents mcp_client (and any other agent)
116
+ # from being permanently blocked on every daemon restart.
117
+ self._write_locks = {}
114
118
  except (json.JSONDecodeError, KeyError):
115
119
  logger.warning("Corrupt registry file at %s — starting fresh.", self._path)
116
120
  self._agents = {}
@@ -182,7 +182,9 @@ def is_rate_limit_exempt(client_host: str) -> bool:
182
182
  same rapid reads, so it is exempt too — otherwise normal dashboard polling
183
183
  trips the limiter (issue #40 Issue 3).
184
184
  """
185
- if client_host in ("127.0.0.1", "::1", "localhost"):
185
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
186
+
187
+ if _is_loopback_host(client_host):
186
188
  return True
187
189
  return is_lan_client_allowed(client_host)
188
190
 
@@ -349,7 +349,24 @@ class ScaleEngineManager:
349
349
  self._release_lifecycle_lock(lock_path)
350
350
 
351
351
  def _promote(self, stage_id: str) -> dict[str, Any]:
352
- """Promote while the caller owns the lifecycle lock."""
352
+ """Promote while the caller owns the lifecycle lock.
353
+
354
+ Concurrency fix (v3.8.4): the original code held a SQLite
355
+ BEGIN IMMEDIATE lock across multiple fsync/rename filesystem operations
356
+ (mkdir_durable, replace_durable, write_promotion_journal) which could
357
+ starve other memory.db writers for 30+ seconds.
358
+
359
+ Fix: the fingerprint check runs inside a short memory_read() block
360
+ (consistent WAL snapshot, no write lock). All filesystem operations
361
+ happen AFTER that block with no SQLite lock held. The lifecycle lock
362
+ (file-based, acquired by the caller) already serialises concurrent
363
+ promote/rollback calls.
364
+
365
+ Lock-ordering invariant preserved: get_write_lock is OUTERMOST; no
366
+ SQLite write lock is held here at all (only a read snapshot).
367
+ """
368
+ from superlocalmemory.storage.memory_write import memory_read
369
+
353
370
  stage_dir, manifest = self._load_stage(stage_id)
354
371
  self._validate_manifest(manifest, state="verified")
355
372
  staged = (stage_dir / "cozo", stage_dir / "lance")
@@ -357,17 +374,30 @@ class ScaleEngineManager:
357
374
  raise ScaleEngineError("verified stage is incomplete; prepare a new stage")
358
375
  backup_dir = self.backup_root / f"{stage_id}-{uuid.uuid4().hex[:6]}"
359
376
  active = self.active_paths
360
- gate = sqlite3.connect(self.db_path, timeout=30)
377
+
378
+ # ── Phase 1: fingerprint check (short read snapshot, no write lock) ──
379
+ # memory_read() gives a consistent WAL snapshot and closes the
380
+ # connection immediately on exit — no lock held after this block.
381
+ try:
382
+ with memory_read(self.db_path) as gate:
383
+ canonical = self._canonical_counts(gate)
384
+ if manifest["source_fingerprint"] != self._projection_fingerprint(
385
+ gate, canonical
386
+ ):
387
+ raise ScaleEngineError(
388
+ "canonical SQLite changed after verification; prepare a new stage"
389
+ )
390
+ except ScaleEngineError:
391
+ raise
392
+ except Exception as exc:
393
+ raise ScaleEngineError(
394
+ f"fingerprint check failed: {exc}"
395
+ ) from exc
396
+
397
+ # ── Phase 2: filesystem operations — NO SQLite lock held ─────────────
398
+ # The lifecycle lock (file-based, held by the caller) serialises
399
+ # concurrent promotions; no SQLite fence is required for the swaps.
361
400
  try:
362
- # The stage was built from a point-in-time SQLite snapshot. Hold a
363
- # short writer fence for the final fingerprint check and directory
364
- # swap so no successful promotion can trail a canonical write.
365
- gate.execute("BEGIN IMMEDIATE")
366
- canonical = self._canonical_counts(gate)
367
- if manifest["source_fingerprint"] != self._projection_fingerprint(gate, canonical):
368
- raise ScaleEngineError(
369
- "canonical SQLite changed after verification; prepare a new stage"
370
- )
371
401
  self._mkdir_durable(self.backup_root)
372
402
  journal = {
373
403
  "schema_version": self.SCHEMA_VERSION,
@@ -409,13 +439,8 @@ class ScaleEngineManager:
409
439
  journal["state"] = "committed"
410
440
  self._write_promotion_journal(journal)
411
441
  self.promotion_journal_path.unlink(missing_ok=True)
412
- gate.rollback()
413
442
  return manifest
414
443
  except Exception as exc:
415
- try:
416
- gate.rollback()
417
- except sqlite3.Error:
418
- pass
419
444
  try:
420
445
  recovery = self._recover_interrupted_promotion()
421
446
  except Exception as recovery_error:
@@ -426,8 +451,6 @@ class ScaleEngineManager:
426
451
  _, recovered_manifest = self._load_stage(stage_id)
427
452
  return recovered_manifest
428
453
  raise ScaleEngineError(f"promotion rolled back: {exc}") from exc
429
- finally:
430
- gate.close()
431
454
 
432
455
  def rollback(self, backup_id: str) -> dict[str, Any]:
433
456
  """Restore an explicitly named pre-promotion backup."""
@@ -166,10 +166,24 @@ def enrich_fact(
166
166
  from superlocalmemory.encoding.emotional import emotional_importance_boost, tag_emotion
167
167
  from superlocalmemory.encoding.signal_inference import infer_signal
168
168
 
169
- embedding = embedder.embed(fact.content) if embedder else None
170
- fisher_mean, fisher_variance = (None, None)
171
- if embedder and embedding:
172
- fisher_mean, fisher_variance = embedder.compute_fisher_params(embedding)
169
+ # v3.8.4 D: if the fact already carries a sync-embedded vector (warm-guard
170
+ # path in store_fast), reuse it — avoids a redundant embed call in the
171
+ # materializer and keeps the vector consistent with the one indexed in the
172
+ # vector store at write time.
173
+ if fact.embedding is not None:
174
+ embedding = fact.embedding
175
+ fisher_mean = fact.fisher_mean
176
+ fisher_variance = fact.fisher_variance
177
+ # fisher_params are computed in the warm-guard path too, but guard
178
+ # against the edge case where they weren't (e.g. compute_fisher_params
179
+ # raised after embed succeeded).
180
+ if (fisher_mean is None or fisher_variance is None) and embedder and embedding:
181
+ fisher_mean, fisher_variance = embedder.compute_fisher_params(embedding)
182
+ else:
183
+ embedding = embedder.embed(fact.content) if embedder else None
184
+ fisher_mean, fisher_variance = (None, None)
185
+ if embedder and embedding:
186
+ fisher_mean, fisher_variance = embedder.compute_fisher_params(embedding)
173
187
 
174
188
  canonical = {}
175
189
  if entity_resolver and fact.entities:
@@ -563,18 +563,25 @@ class EntityResolver:
563
563
  self._db.store_alias(alias, profile_id)
564
564
 
565
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.
566
+ """Record a last_seen touch (DEFERRED, non-blocking).
567
+
568
+ last_seen is dashboard-only bookkeeping (entities/graph "last seen"
569
+ columns); it never feeds recall ranking. Writing it INLINE made recall
570
+ a WRITER that waited on the global write lock — the root of the
571
+ "recall is 8 s" regression. We now record it in memory and flush from a
572
+ single background thread in coalesced batches, so recall stays
573
+ READ-ONLY on its hot path. The dashboard stays correct within the flush
574
+ interval (~2 s). The profile_id guard (L-01) is preserved in the
575
+ deferred UPDATE (see storage/deferred_writes.py).
572
576
  """
573
- self._db.execute(
574
- "UPDATE canonical_entities SET last_seen = ? "
575
- "WHERE entity_id = ? AND profile_id = ?",
576
- (_now(), entity_id, profile_id),
577
- )
577
+ try:
578
+ from superlocalmemory.storage.deferred_writes import (
579
+ get_deferred_last_seen,
580
+ )
581
+ get_deferred_last_seen(self._db).touch(entity_id, profile_id, _now())
582
+ except Exception:
583
+ # Bookkeeping must never break entity resolution.
584
+ pass
578
585
 
579
586
  # -- Internal: LLM disambiguation (Mode B/C) ---------------------------
580
587
 
@@ -40,8 +40,15 @@ from typing import IO, Optional
40
40
  # Budget constants
41
41
  # ---------------------------------------------------------------------------
42
42
 
43
- #: Hot-path SQLite busy timeout (ms). Fail fast rather than block a host tool.
44
- BUSY_TIMEOUT_MS: int = 50
43
+ #: Hot-path SQLite busy timeout (ms).
44
+ #
45
+ # Raised to 10 000 ms to match the daemon's SLM_DB_BUSY_TIMEOUT_MS default.
46
+ # Hooks run as a SEPARATE OS process — the daemon's threading.RLock write-lock
47
+ # cannot help cross-process — so PRAGMA busy_timeout is the ONLY lever that
48
+ # prevents SQLITE_BUSY when the daemon or CLI holds the WAL write lock.
49
+ # 10 s is long enough to outlast a typical daemon write cycle while staying
50
+ # safely below Claude Code's hook-kill timeout.
51
+ BUSY_TIMEOUT_MS: int = 10_000
45
52
 
46
53
  #: Cap on tool_response bytes scanned — bounds substring work to O(100 KB).
47
54
  SCAN_BYTES_CAP: int = 100_000