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.
- package/CHANGELOG.md +57 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +139 -91
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +283 -39
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- 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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
"""
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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).
|
|
44
|
-
|
|
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
|