superlocalmemory 4.0.8 → 4.0.10

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 (147) hide show
  1. package/CHANGELOG.md +221 -1
  2. package/README.md +6 -6
  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/scripts/ensure-venv.sh +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +3 -3
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-status/SKILL.md +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/postinstall.js +4 -0
  28. package/src/superlocalmemory/__init__.py +1 -1
  29. package/src/superlocalmemory/cli/_lazy_init.py +1 -1
  30. package/src/superlocalmemory/cli/commands.py +164 -11
  31. package/src/superlocalmemory/cli/db_migrate.py +0 -2
  32. package/src/superlocalmemory/cli/gdpr_io.py +1 -1
  33. package/src/superlocalmemory/cli/main.py +7 -7
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  37. package/src/superlocalmemory/core/config.py +41 -7
  38. package/src/superlocalmemory/core/context_cache.py +0 -2
  39. package/src/superlocalmemory/core/engine.py +371 -63
  40. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  41. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  42. package/src/superlocalmemory/core/install_detector.py +131 -0
  43. package/src/superlocalmemory/core/maintenance.py +44 -6
  44. package/src/superlocalmemory/core/memory_health.py +266 -0
  45. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  46. package/src/superlocalmemory/core/operation_request.py +1 -1
  47. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  48. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  49. package/src/superlocalmemory/core/security_primitives.py +3 -6
  50. package/src/superlocalmemory/core/store_pipeline.py +172 -29
  51. package/src/superlocalmemory/core/topic_signature.py +0 -2
  52. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  53. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  54. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  55. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  56. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  57. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  58. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  59. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  60. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  61. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  63. package/src/superlocalmemory/infra/backup.py +44 -8
  64. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  65. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  66. package/src/superlocalmemory/learning/bandit.py +0 -2
  67. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  68. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  69. package/src/superlocalmemory/learning/ensemble.py +0 -2
  70. package/src/superlocalmemory/learning/labeler.py +0 -2
  71. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  72. package/src/superlocalmemory/learning/model_cache.py +0 -2
  73. package/src/superlocalmemory/learning/ranker.py +0 -2
  74. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  75. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  76. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  77. package/src/superlocalmemory/math/fisher.py +1 -1
  78. package/src/superlocalmemory/math/hopfield.py +4 -1
  79. package/src/superlocalmemory/math/langevin.py +1 -1
  80. package/src/superlocalmemory/math/sheaf.py +7 -3
  81. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  82. package/src/superlocalmemory/mcp/profiles.py +1 -1
  83. package/src/superlocalmemory/mcp/server.py +2 -2
  84. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  85. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  86. package/src/superlocalmemory/mcp/tools_core.py +41 -40
  87. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  88. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  89. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  90. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  92. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  93. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  94. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  95. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  96. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  97. package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
  98. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  99. package/src/superlocalmemory/retrieval/engine.py +272 -43
  100. package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
  101. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  102. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  103. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  104. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  105. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  106. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  107. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  108. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  109. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  110. package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
  111. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  112. package/src/superlocalmemory/retrieval/vector_store.py +151 -10
  113. package/src/superlocalmemory/server/api.py +6 -1
  114. package/src/superlocalmemory/server/asset_versions.py +171 -0
  115. package/src/superlocalmemory/server/recall_serializer.py +10 -0
  116. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  117. package/src/superlocalmemory/server/routes/brain.py +2 -2
  118. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  119. package/src/superlocalmemory/server/routes/entity.py +13 -1
  120. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  121. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  122. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  123. package/src/superlocalmemory/server/ui.py +8 -1
  124. package/src/superlocalmemory/server/unified_daemon.py +570 -16
  125. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  126. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  127. package/src/superlocalmemory/storage/backup.py +531 -0
  128. package/src/superlocalmemory/storage/database.py +139 -34
  129. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  130. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  131. package/src/superlocalmemory/storage/migration_runner.py +153 -2
  132. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  133. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  134. package/src/superlocalmemory/storage/migrations.py +15 -1
  135. package/src/superlocalmemory/storage/models.py +7 -0
  136. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  137. package/src/superlocalmemory/storage/schema.py +98 -0
  138. package/src/superlocalmemory/summaries/base.py +1 -1
  139. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  140. package/src/superlocalmemory/summaries/session_summary.py +1 -1
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  142. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  143. package/src/superlocalmemory/ui/index.html +1 -1
  144. package/src/superlocalmemory/ui/js/core.js +1 -1
  145. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  146. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  147. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -398,9 +398,15 @@ class EntityGraphChannel:
398
398
  include_global=include_global,
399
399
  include_shared=include_shared,
400
400
  )
401
+ # Withheld rows must not enter the entity map at all. They carry
402
+ # their whole cluster's pooled entity list, which is exactly why
403
+ # they out-ranked real memories here in the first place — leaving
404
+ # them in the map would keep spending this channel's budget on
405
+ # candidates that hydration then discards.
401
406
  rows = self._db.execute(
402
407
  "SELECT fact_id, canonical_entities_json "
403
- f"FROM atomic_facts WHERE {where} "
408
+ f"FROM atomic_facts WHERE {where}"
409
+ f"{self._db.visible_fact_clause()} "
404
410
  "ORDER BY created_at DESC LIMIT ?",
405
411
  (*params, _unbounded_facts_ceiling()),
406
412
  )
@@ -651,7 +657,7 @@ class EntityGraphChannel:
651
657
  max_score = max(sc for _, sc in results)
652
658
  if max_score > 0:
653
659
  results = [(fid, sc / max_score) for fid, sc in results]
654
- results.sort(key=lambda x: x[1], reverse=True)
660
+ results.sort(key=lambda x: (-x[1], x[0]))
655
661
  return filter_authorized_results(
656
662
  self._db,
657
663
  results,
@@ -261,8 +261,14 @@ class HopfieldChannel:
261
261
  # Step 4: Similarity to all patterns
262
262
  similarities = memory_matrix @ retrieved # shape (n,)
263
263
 
264
- # Step 5: Top-K selection
265
- top_indices = np.argsort(-similarities)[:top_k]
264
+ # Step 5: Top-K selection with stable tie-break on fact_id.
265
+ # argsort(-similarities) is score-only: equal similarities are ordered
266
+ # by row index, which tracks the order facts were loaded from the DB
267
+ # and varies between sessions. lexsort breaks ties on fact_id so the
268
+ # same query over the same store returns the same ranking every time.
269
+ id_arr = np.array(fact_ids, dtype=object)
270
+ order = np.lexsort((id_arr, -similarities)) # primary: -similarity, secondary: fact_id
271
+ top_indices = order[:top_k]
266
272
  results: list[tuple[str, float]] = [
267
273
  (fact_ids[int(i)], float(similarities[i]))
268
274
  for i in top_indices
@@ -91,7 +91,7 @@ class ProfileChannel:
91
91
  seen.add(fid)
92
92
  results.append((fid, 0.95))
93
93
 
94
- results.sort(key=lambda x: x[1], reverse=True)
94
+ results.sort(key=lambda x: (-x[1], x[0]))
95
95
  return results[:top_k]
96
96
 
97
97
  @staticmethod
@@ -88,7 +88,7 @@ class QuantizationAwareSearch:
88
88
  seen[fid] = score
89
89
 
90
90
  # Sort by score descending
91
- merged = sorted(seen.items(), key=lambda x: x[1], reverse=True)
91
+ merged = sorted(seen.items(), key=lambda x: (-x[1], x[0]))
92
92
  return merged[:top_k]
93
93
 
94
94
  # -- Tier helpers (encapsulate error handling) -------------------------
@@ -596,7 +596,7 @@ class RemoteReranker:
596
596
  (fact, float(score))
597
597
  for (fact, _), score in zip(ranked, scores)
598
598
  ]
599
- scored.sort(key=lambda pair: pair[1], reverse=True)
599
+ scored.sort(key=lambda pair: (-pair[1], pair[0].fact_id))
600
600
  return scored[:top_k], True, "applied"
601
601
 
602
602
  def score_pair(self, query: str, document: str) -> float:
@@ -735,7 +735,7 @@ class RemoteReranker:
735
735
  def _fusion_order(
736
736
  candidates: list[tuple[AtomicFact, float]],
737
737
  ) -> list[tuple[AtomicFact, float]]:
738
- return sorted(candidates, key=lambda pair: pair[1], reverse=True)
738
+ return sorted(candidates, key=lambda pair: (-pair[1], pair[0].fact_id))
739
739
 
740
740
 
741
741
  class _RetryableRemoteError(RemoteRerankerError):
@@ -599,7 +599,7 @@ class CrossEncoderReranker:
599
599
  if not self._model_loaded:
600
600
  if not self._shutdown_event.is_set() and not self._worker_loading:
601
601
  self._start_background_warmup()
602
- sorted_cands = sorted(candidates, key=lambda x: x[1], reverse=True)
602
+ sorted_cands = sorted(candidates, key=lambda x: (-x[1], x[0].fact_id))
603
603
  return sorted_cands[:top_k], False, "fallback_not_ready"
604
604
 
605
605
  documents = [fact.content for fact, _ in candidates]
@@ -617,7 +617,7 @@ class CrossEncoderReranker:
617
617
 
618
618
  if resp is None or not resp.get("ok"):
619
619
  # Fallback: return by existing score
620
- sorted_cands = sorted(candidates, key=lambda x: x[1], reverse=True)
620
+ sorted_cands = sorted(candidates, key=lambda x: (-x[1], x[0].fact_id))
621
621
  return sorted_cands[:top_k], False, "fallback_busy_or_unavailable"
622
622
 
623
623
  scores = resp["scores"]
@@ -625,7 +625,7 @@ class CrossEncoderReranker:
625
625
  (fact, float(score))
626
626
  for (fact, _), score in zip(candidates, scores)
627
627
  ]
628
- scored.sort(key=lambda x: x[1], reverse=True)
628
+ scored.sort(key=lambda x: (-x[1], x[0].fact_id))
629
629
  return scored[:top_k], True, "applied"
630
630
 
631
631
  def score_pair(self, query: str, document: str) -> float:
@@ -53,9 +53,30 @@ def authorized_fact_ids(
53
53
  include_shared=include_shared,
54
54
  )
55
55
  placeholders = ",".join("?" for _ in unique_ids)
56
+ # Mirror the primary path's visibility rule, not just its scope rule.
57
+ # This branch exists for lightweight wrappers that expose execute() but
58
+ # not get_facts_by_ids, and it was authorizing withheld and
59
+ # soft-deleted rows that the primary path refuses -- so any channel
60
+ # whose db object took this branch had a different idea of what is
61
+ # visible than the engine that hydrates its results.
62
+ # Resolved on the TYPE, not the instance. A MagicMock fabricates any
63
+ # attribute you ask for, so an instance check returns a callable that
64
+ # returns another MagicMock, whose repr then lands in the SQL string and
65
+ # makes the whole query a syntax error -- and this function's `except`
66
+ # turns that into an empty authorized set, i.e. every candidate silently
67
+ # dropped. tests/test_retrieval/test_spreading_activation.py caught it
68
+ # by passing exactly such a mock. The same reasoning is already written
69
+ # up in retrieval/engine.py for the reranker's optional contract.
70
+ visible = ""
71
+ clause_fn = getattr(type(db), "visible_fact_clause", None)
72
+ if callable(clause_fn):
73
+ try:
74
+ visible = clause_fn(db)
75
+ except Exception: # noqa: BLE001 -- fall back to scope-only
76
+ visible = ""
56
77
  rows = db.execute(
57
78
  f"SELECT fact_id FROM atomic_facts WHERE fact_id IN ({placeholders}) "
58
- f"AND {where}",
79
+ f"AND {where}{visible}",
59
80
  (*unique_ids, *params),
60
81
  )
61
82
  if not isinstance(rows, list):
@@ -332,7 +332,7 @@ class SemanticChannel:
332
332
  for fact_id, score in external_scores:
333
333
  combined[fact_id] = max(combined.get(fact_id, 0.0), score)
334
334
  knn_results = sorted(
335
- combined.items(), key=lambda item: item[1], reverse=True,
335
+ combined.items(), key=lambda item: (-item[1], item[0]),
336
336
  )[:top_k * 2]
337
337
  if not knn_results:
338
338
  return [] # Caller falls through to full scan
@@ -401,7 +401,7 @@ class SemanticChannel:
401
401
  if sim > 0.05:
402
402
  scored.append((fact.fact_id, sim))
403
403
 
404
- scored.sort(key=lambda x: x[1], reverse=True)
404
+ scored.sort(key=lambda x: (-x[1], x[0]))
405
405
  return scored[:top_k]
406
406
 
407
407
  def _search_full_scan(
@@ -463,7 +463,7 @@ class SemanticChannel:
463
463
  if sim > 0.05:
464
464
  scored.append((fact.fact_id, sim))
465
465
 
466
- scored.sort(key=lambda x: x[1], reverse=True)
466
+ scored.sort(key=lambda x: (-x[1], x[0]))
467
467
  return scored[:top_k]
468
468
 
469
469
  @staticmethod
@@ -234,7 +234,7 @@ class SpreadingActivation:
234
234
 
235
235
  # Return top-K sorted by activation
236
236
  results = sorted(
237
- activations.items(), key=lambda x: x[1], reverse=True,
237
+ activations.items(), key=lambda x: (-x[1], x[0]),
238
238
  )
239
239
  return filter_authorized_results(
240
240
  self._db,
@@ -296,7 +296,7 @@ class SpreadingActivation:
296
296
  continue
297
297
  score = (float(np.dot(q_vec, fact_vec) / denominator) + 1.0) / 2.0
298
298
  scored.append((fact.fact_id, score))
299
- return sorted(scored, key=lambda item: item[1], reverse=True)[:self._config.top_m]
299
+ return sorted(scored, key=lambda item: (-item[1], item[0]))[:self._config.top_m]
300
300
 
301
301
  def _propagate(
302
302
  self,
@@ -391,7 +391,7 @@ class SpreadingActivation:
391
391
 
392
392
  # Step 3: Lateral inhibition -- keep only top-M
393
393
  sorted_nodes = sorted(
394
- new_activations.items(), key=lambda x: x[1], reverse=True,
394
+ new_activations.items(), key=lambda x: (-x[1], x[0]),
395
395
  )
396
396
  top_m_nodes = sorted_nodes[: cfg.top_m]
397
397
 
@@ -445,30 +445,30 @@ class SpreadingActivation:
445
445
  SELECT * FROM (
446
446
  SELECT target_id AS neighbor_id, weight FROM graph_edges AS ge
447
447
  WHERE source_id = ? AND {graph_where}
448
- ORDER BY weight DESC LIMIT ?
448
+ ORDER BY weight DESC, target_id ASC LIMIT ?
449
449
  )
450
450
  UNION ALL
451
451
  SELECT * FROM (
452
452
  SELECT target_fact_id AS neighbor_id, weight
453
453
  FROM association_edges AS ae
454
454
  WHERE source_fact_id = ? AND {assoc_where}
455
- ORDER BY weight DESC LIMIT ?
455
+ ORDER BY weight DESC, target_fact_id ASC LIMIT ?
456
456
  )
457
457
  UNION ALL
458
458
  SELECT * FROM (
459
459
  SELECT source_id AS neighbor_id, weight FROM graph_edges AS ge
460
460
  WHERE target_id = ? AND {graph_where}
461
- ORDER BY weight DESC LIMIT ?
461
+ ORDER BY weight DESC, source_id ASC LIMIT ?
462
462
  )
463
463
  UNION ALL
464
464
  SELECT * FROM (
465
465
  SELECT source_fact_id AS neighbor_id, weight
466
466
  FROM association_edges AS ae
467
467
  WHERE target_fact_id = ? AND {assoc_where}
468
- ORDER BY weight DESC LIMIT ?
468
+ ORDER BY weight DESC, source_fact_id ASC LIMIT ?
469
469
  )
470
470
  )
471
- ORDER BY weight DESC
471
+ ORDER BY weight DESC, neighbor_id ASC
472
472
  LIMIT ?
473
473
  """,
474
474
  (
@@ -22,6 +22,17 @@ STRATEGY_PRESETS: dict[str, dict[str, float]] = {
22
22
  "opinion": {"semantic": 1.8, "bm25": 0.6, "entity_graph": 0.8, "temporal": 0.3, "spreading_activation": 0.5, "hopfield": 0.5},
23
23
  "factual": {"semantic": 1.2, "bm25": 1.4, "entity_graph": 1.0, "temporal": 0.6, "spreading_activation": 0.8, "hopfield": 0.8},
24
24
  "entity": {"semantic": 1.0, "bm25": 1.2, "entity_graph": 3.0, "temporal": 0.5, "spreading_activation": 1.5, "hopfield": 0.9},
25
+ # A question about the present carries almost no topical information in its
26
+ # words: "what am I working on" matches anything containing "working",
27
+ # including a note from a month ago about a working tree. Word-matching was
28
+ # set to 1.5 here, ABOVE the 1.4 of the preset this one exists to replace,
29
+ # so the new path boosted the exact signal that caused the original failure.
30
+ # It is now the weakest signal, because for this question it is the least
31
+ # informative one; time is what the question is actually about.
32
+ "recency": {
33
+ "temporal": 2.5, "bm25": 0.7, "semantic": 1.2,
34
+ "entity_graph": 1.0, "spreading_activation": 0.8, "hopfield": 0.5,
35
+ },
25
36
  "general": {},
26
37
  "vague": {"semantic": 0.8, "bm25": 0.5, "entity_graph": 0.6, "temporal": 0.3, "spreading_activation": 1.5, "hopfield": 1.1},
27
38
  }
@@ -32,6 +43,17 @@ _TEMPORAL_WORDS: frozenset[str] = frozenset({
32
43
  "until", "while", "between", "january", "february", "march",
33
44
  "april", "may", "june", "july", "august", "september", "october",
34
45
  "november", "december",
46
+ "now", "today", "yesterday",
47
+ "tonight", "tomorrow",
48
+ # "current" was removed earlier because "what is the current database schema"
49
+ # was being answered by newest-fact recency instead of topical search.
50
+ # "latest" and "currently" carry the same over-trigger: in "what is the
51
+ # latest authentication design" or "what is the currently supported format"
52
+ # they mean "most recent version of X", not "at what time did X happen".
53
+ # Routing those queries to temporal causes the recency fallback to dump the
54
+ # 50 newest facts with no topic filter at temporal weight 2.0, burying the
55
+ # subject the user named. Present-activity phrases ("currently working",
56
+ # "currently doing", "currently focused") are covered by _RECENCY_PHRASES.
35
57
  })
36
58
 
37
59
  _MULTI_HOP_PHRASES: tuple[str, ...] = (
@@ -63,6 +85,20 @@ _CAUSAL_TEMPORAL_WORDS: frozenset[str] = frozenset({
63
85
  "join", "visit", "return",
64
86
  })
65
87
 
88
+ # Phrases that signal a present-activity or ongoing-state question.
89
+ # Checked after multi_hop, entity and temporal word filters so retrospective
90
+ # queries ("what did I work on recently") do not short-circuit here.
91
+ # Order inside the tuple is irrelevant; all are substring-matched against the
92
+ # lowercased query.
93
+ _RECENCY_PHRASES: tuple[str, ...] = (
94
+ "working on",
95
+ "am i working", "are we working", "been working",
96
+ "what's happening", "what is happening",
97
+ "right now", "these days", "at the moment",
98
+ "currently doing", "currently working", "currently focused",
99
+ "what have i been", "what am i doing", "what have we been",
100
+ )
101
+
66
102
  _AGGREGATION_WORDS: frozenset[str] = frozenset({
67
103
  "all", "list", "every", "everything", "various", "different",
68
104
  "many", "several", "multiple", "summarize", "overview",
@@ -101,9 +137,42 @@ class QueryStrategy:
101
137
  confidence: float = 0.5
102
138
 
103
139
 
140
+
141
+ def _recency_enabled(config: object | None = None) -> bool:
142
+ """Whether the present-tense path is switched on.
143
+
144
+ The env var SLM_DISABLE_RECENCY_STRATEGY=1 is checked first (incident
145
+ override). If unset, the RetrievalConfig.enable_recency_strategy field is
146
+ consulted when a config object is supplied. Defaults to on when neither
147
+ is present.
148
+
149
+ Kept as a module-level function so the classifier stays usable without an
150
+ engine, which is how the gate and several tests call it.
151
+ """
152
+ import os
153
+
154
+ if os.environ.get("SLM_DISABLE_RECENCY_STRATEGY", "0") == "1":
155
+ return False
156
+ if config is not None and not getattr(config, "enable_recency_strategy", True):
157
+ return False
158
+ return True
159
+
160
+
104
161
  class QueryStrategyClassifier:
105
162
  """Classifies queries and produces adaptive channel weights."""
106
163
 
164
+ def __init__(self, config: object | None = None) -> None:
165
+ """Initialise the classifier.
166
+
167
+ Args:
168
+ config: Optional RetrievalConfig-like object. When supplied,
169
+ ``enable_recency_strategy=False`` on that object acts as a
170
+ persistent rollback without requiring an env var change.
171
+ The env var SLM_DISABLE_RECENCY_STRATEGY=1 always takes
172
+ precedence.
173
+ """
174
+ self._config = config
175
+
107
176
  def classify(self, query: str, base_weights: dict[str, float]) -> QueryStrategy:
108
177
  """Classify query and return adapted weights."""
109
178
  qtype = self._detect_type(query)
@@ -135,6 +204,18 @@ class QueryStrategyClassifier:
135
204
  if len(proper_nouns) >= 2 and words & _CAUSAL_TEMPORAL_WORDS:
136
205
  return "multi_hop"
137
206
 
207
+ # Checked BEFORE the single-word test below. "what am I working on right
208
+ # now" contains "now", so the word test claimed it first and routed a
209
+ # question about the present down the retrospective path — which weights
210
+ # word-matching at 1.5 against this path's 0.7, and word-matching on
211
+ # "working" is exactly what surfaced a month-old note about a working
212
+ # tree. A whole phrase states intent; a single word only hints at it.
213
+ #
214
+ # The enable_recency_strategy config field and SLM_DISABLE_RECENCY_STRATEGY
215
+ # env var both feed into _recency_enabled(). When either signals disabled,
216
+ # this block is skipped and the query falls through to subsequent checks.
217
+ if _recency_enabled(self._config) and any(p in q for p in _RECENCY_PHRASES):
218
+ return "recency"
138
219
  if words & _TEMPORAL_WORDS:
139
220
  return "temporal"
140
221
  if words & _AGGREGATION_WORDS:
@@ -152,3 +233,16 @@ class QueryStrategyClassifier:
152
233
  if any(p in q for p in _VAGUE_PHRASES):
153
234
  return "vague"
154
235
  return "general"
236
+
237
+
238
+ _DEFAULT_CLASSIFIER = QueryStrategyClassifier()
239
+
240
+
241
+ def classify_query(query: str, base_weights: dict[str, float] | None = None) -> QueryStrategy:
242
+ """Classify a query and return adapted channel weights.
243
+
244
+ Convenience function for callers that do not maintain a classifier instance.
245
+ Uses an empty weight dict when base_weights is omitted, which leaves
246
+ absolute weights undefined — useful for type-detection only.
247
+ """
248
+ return _DEFAULT_CLASSIFIER.classify(query, base_weights or {})
@@ -81,6 +81,7 @@ class TemporalChannel:
81
81
  top_k: int = 30,
82
82
  include_global: bool | None = None,
83
83
  include_shared: bool | None = None,
84
+ query_type: str = "general",
84
85
  ) -> list[tuple[str, float]]:
85
86
  """Search for temporally relevant facts.
86
87
 
@@ -117,11 +118,41 @@ class TemporalChannel:
117
118
  )
118
119
 
119
120
  # Strategy 2: Date proximity search
120
- if query_dt is None and not entity_results:
121
- return []
121
+ if query_dt is None:
122
+ recent: list[tuple[str, float]] = []
123
+ # For a question that IS about the present ("what am I working on"),
124
+ # recency is the answer, and it runs regardless of what else matched.
125
+ # It used to sit inside a guard that also required the entity search
126
+ # to be empty — inherited from the case where there is simply nothing
127
+ # to do — so on a real store, where something almost always matches,
128
+ # it effectively never ran.
129
+ #
130
+ # For a merely time-FLAVOURED question ("what is the latest
131
+ # authentication design"), recency is a last resort, not the answer:
132
+ # this channel returns up to 50 newest facts with no regard for topic,
133
+ # and at temporal's weight of 2.0 that buries the very subject the
134
+ # user named. So there it runs only when nothing else matched at all.
135
+ if query_type == "recency" or (
136
+ query_type == "temporal" and not entity_results
137
+ ):
138
+ recent = self._recency_fallback(
139
+ profile_id,
140
+ include_global=include_global,
141
+ include_shared=include_shared,
142
+ )
143
+ if not entity_results:
144
+ return recent
145
+ if recent:
146
+ # Both signals are real: an entity the question named, and the
147
+ # fact that the question is about now. Recency leads because
148
+ # that is what this channel was asked about; entity matches
149
+ # follow, and anything already present keeps its better place.
150
+ seen = {fid for fid, _ in recent}
151
+ return recent + [(f, s) for f, s in entity_results if f not in seen]
122
152
 
123
153
  events = self._load_events(
124
154
  profile_id, include_global=include_global, include_shared=include_shared,
155
+ near_date=query_dt.date().isoformat() if query_dt is not None else None,
125
156
  )
126
157
  scored: dict[str, float] = {}
127
158
 
@@ -155,7 +186,7 @@ class TemporalChannel:
155
186
  fid = ev["fact_id"]
156
187
  scored[fid] = max(scored.get(fid, 0.0), best)
157
188
 
158
- results = sorted(scored.items(), key=lambda x: x[1], reverse=True)
189
+ results = sorted(scored.items(), key=lambda x: (-x[1], x[0]))
159
190
  return results[:top_k]
160
191
 
161
192
  def _entity_temporal_search(
@@ -200,6 +231,14 @@ class TemporalChannel:
200
231
  )
201
232
 
202
233
  for name in names[:3]: # Limit to first 3 entity mentions
234
+ # An empty name would match the placeholder entity that dated
235
+ # facts with no resolved entity attach their temporal events to
236
+ # (core/store_pipeline.py::_ensure_unresolved_entity, whose
237
+ # canonical_name is ''). Entity extraction does not produce an
238
+ # empty name, so this guard costs nothing and removes the one way
239
+ # the placeholder could ever surface as a real entity match.
240
+ if not name or not name.strip():
241
+ continue
203
242
  # Resolve the entity and event in one scope-filtered query. Looking
204
243
  # up the entity only in the requester's profile made global events
205
244
  # owned by another profile undiscoverable before authorization was
@@ -208,7 +247,13 @@ class TemporalChannel:
208
247
  "SELECT te.fact_id FROM temporal_events AS te "
209
248
  "JOIN canonical_entities AS ce ON ce.entity_id = te.entity_id "
210
249
  "JOIN atomic_facts AS af ON af.fact_id = te.fact_id "
211
- f"WHERE {where} AND LOWER(ce.canonical_name) = LOWER(?)",
250
+ f"WHERE {where} AND LOWER(ce.canonical_name) = LOWER(?) "
251
+ # The score below is derived from each row's POSITION in this
252
+ # result. Position must reflect temporal order so that the comment
253
+ # "first events more likely relevant" holds: oldest fact first,
254
+ # tie-broken by fact_id so two facts created in the same instant
255
+ # produce the same score on two runs.
256
+ "ORDER BY af.created_at ASC, te.fact_id ASC",
212
257
  (*params, name),
213
258
  )
214
259
  for row in rows:
@@ -227,7 +272,102 @@ class TemporalChannel:
227
272
  profile_id: str,
228
273
  include_global: bool | None = None,
229
274
  include_shared: bool | None = None,
275
+ near_date: str | None = None,
230
276
  ) -> list[dict]:
277
+ """Load a bounded slice of temporal events.
278
+
279
+ ``near_date`` decides WHICH slice. Without it the newest events are
280
+ taken, which suits "what is recent". With it the events closest to that
281
+ date are taken, which is the only slice that can answer a question about
282
+ a particular time — the newest-first bound silently excluded anything
283
+ old, so a question about last year returned nothing rather than slowly.
284
+ """
285
+ if include_global is None:
286
+ include_global = bool(getattr(self, "include_global", False))
287
+ if include_shared is None:
288
+ include_shared = bool(getattr(self, "include_shared", False))
289
+ where, params = _scope_where(
290
+ profile_id,
291
+ include_global=include_global,
292
+ include_shared=include_shared,
293
+ prefix="af",
294
+ )
295
+ # The bound has to match what the caller is looking for. Taking the
296
+ # newest 5,000 rows is right when the question is "what is recent", and
297
+ # wrong when it is "what happened in March 2024" — those events carry old
298
+ # rowids and were simply never loaded, so the answer was missing rather
299
+ # than slow. When a target date is known, bound by proximity to THAT date
300
+ # instead; the scan stays bounded either way.
301
+ if near_date is not None:
302
+ # Include events that carry only interval_start/interval_end with no
303
+ # referenced_date or observation_date. The original filter required
304
+ # at least one of the point-date columns to be non-NULL, which
305
+ # excluded duration events ("during March 2024") entirely. The
306
+ # ORDER BY now uses the best available date column so that duration
307
+ # events are ranked by their interval_start when no point date exists.
308
+ rows = self._db.execute(
309
+ "SELECT te.fact_id, te.observation_date, te.referenced_date, "
310
+ "te.interval_start, te.interval_end, af.created_at "
311
+ "FROM temporal_events AS te "
312
+ "JOIN atomic_facts AS af ON af.fact_id = te.fact_id "
313
+ f"WHERE {where} "
314
+ " AND (te.referenced_date IS NOT NULL "
315
+ " OR te.observation_date IS NOT NULL "
316
+ " OR te.interval_start IS NOT NULL) "
317
+ # Thousands of events can tie on the proximity expression when
318
+ # they share a date, and a tie with no secondary key is broken by
319
+ # storage order. That decides which of them survive the LIMIT.
320
+ "ORDER BY ABS(julianday(COALESCE(te.referenced_date, "
321
+ " te.observation_date, "
322
+ " te.interval_start)) "
323
+ " - julianday(?)) ASC, te.fact_id ASC "
324
+ "LIMIT 5000",
325
+ (*params, near_date),
326
+ )
327
+ else:
328
+ rows = self._db.execute(
329
+ "SELECT te.fact_id, te.observation_date, te.referenced_date, "
330
+ "te.interval_start, te.interval_end, af.created_at "
331
+ "FROM temporal_events AS te "
332
+ "JOIN atomic_facts AS af ON af.fact_id = te.fact_id "
333
+ f"WHERE {where} "
334
+ "ORDER BY te.rowid DESC LIMIT 5000",
335
+ (*params,),
336
+ )
337
+ return [dict(r) for r in rows]
338
+
339
+ def _recency_fallback(
340
+ self,
341
+ profile_id: str,
342
+ include_global: bool | None,
343
+ include_shared: bool | None,
344
+ ) -> list[tuple[str, float]]:
345
+ """Return recently created facts with Gaussian age-decay scoring.
346
+
347
+ Called when the query carries no date and the caller has said the
348
+ question is about the present. It deliberately does not depend on the
349
+ entity search being empty — requiring that made this unreachable on any
350
+ store where something matches, which is most of them.
351
+
352
+ One entry per fact. Facts scored here compete in fusion against semantic
353
+ and BM25 results, and fusion ranks facts, so repeating a fact spends
354
+ ranks without adding candidates.
355
+
356
+ Scoring: Gaussian with sigma=7 days. Facts older than 90 days score
357
+ below 0.01 and are excluded. Returns at most 50 (fact_id, score) pairs,
358
+ ordered highest-score first.
359
+
360
+ Source table: atomic_facts, not temporal_events. The materializer
361
+ populates temporal_events asynchronously and only for facts with both
362
+ canonical entities and resolved dates. A plain note written moments ago
363
+ never receives a temporal_events row until that background pass runs, so
364
+ a join against temporal_events makes newly written facts structurally
365
+ invisible here — exactly when the caller needs them most.
366
+ """
367
+ _SIGMA = 7.0 # days — tighter than _proximity_score's 30d
368
+ _MAX_AGE_DAYS = 90.0 # cut-off: exp(-(90^2)/(2*7^2)) ≈ 0.0
369
+ now_dt = datetime.now(tz=timezone.utc)
370
+
231
371
  if include_global is None:
232
372
  include_global = bool(getattr(self, "include_global", False))
233
373
  if include_shared is None:
@@ -238,15 +378,44 @@ class TemporalChannel:
238
378
  include_shared=include_shared,
239
379
  prefix="af",
240
380
  )
381
+ # The recency fallback is where these rows won before: they have no
382
+ # temporal_events at all, so they reached the top through created_at
383
+ # alone. Fifty slots, and 1,195 withheld rows all written recently.
241
384
  rows = self._db.execute(
242
- "SELECT te.fact_id, te.observation_date, te.referenced_date, "
243
- "te.interval_start, te.interval_end "
244
- "FROM temporal_events AS te "
245
- "JOIN atomic_facts AS af ON af.fact_id = te.fact_id "
246
- f"WHERE {where}",
385
+ "SELECT af.fact_id, af.created_at "
386
+ "FROM atomic_facts AS af "
387
+ f"WHERE {where}"
388
+ f"{self._db.visible_fact_clause('af')} "
389
+ " AND af.created_at >= datetime('now', '-90 days') "
390
+ "ORDER BY af.created_at DESC, af.fact_id ASC "
391
+ "LIMIT 50",
247
392
  (*params,),
248
393
  )
249
- return [dict(r) for r in rows]
394
+
395
+ best: dict[str, float] = {}
396
+ for row in rows:
397
+ d = dict(row)
398
+ fid = d.get("fact_id")
399
+ if not fid:
400
+ continue
401
+ created = _parse_iso(d.get("created_at"))
402
+ if created is None:
403
+ continue
404
+ utc_created = _as_utc(created)
405
+ if utc_created is None:
406
+ continue
407
+ age_days = max(
408
+ 0.0,
409
+ (now_dt - utc_created).total_seconds() / 86400.0,
410
+ )
411
+ if age_days > _MAX_AGE_DAYS:
412
+ continue
413
+ score = math.exp(-(age_days ** 2) / (2.0 * _SIGMA * _SIGMA))
414
+ if score > 0.01 and score > best.get(fid, 0.0):
415
+ best[fid] = score
416
+
417
+ out = sorted(best.items(), key=lambda kv: (-kv[1], kv[0]))
418
+ return out[:50]
250
419
 
251
420
  @staticmethod
252
421
  def _try_parse(text: str) -> datetime | None:
@@ -460,7 +460,7 @@ class TemporalValidityFilter:
460
460
  ]
461
461
  # Re-sort descending so demoted facts fall below currently-valid
462
462
  # facts in this channel's rank order.
463
- new_list.sort(key=lambda pair: pair[1], reverse=True)
463
+ new_list.sort(key=lambda pair: (-pair[1], pair[0]))
464
464
  demoted[channel_name] = new_list
465
465
  return demoted
466
466