memstack-skill-loader 4.16.0__tar.gz → 4.17.0__tar.gz

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 (73) hide show
  1. {memstack_skill_loader-4.16.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.17.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/__main__.py +6 -0
  5. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/memory_engine.py +266 -35
  6. memstack_skill_loader-4.17.0/src/memstack_skill_loader/memory_migrations.py +370 -0
  7. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/server.py +50 -7
  8. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  9. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +2 -0
  10. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_diary_ingest.py +7 -2
  11. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_engine.py +259 -1
  12. memstack_skill_loader-4.17.0/tests/test_memory_migrations.py +286 -0
  13. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/MANIFEST.in +0 -0
  14. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/README.md +0 -0
  15. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/setup.cfg +0 -0
  16. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/agent_runner.py +0 -0
  17. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/categories.py +0 -0
  18. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/compression.py +0 -0
  19. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/config.py +0 -0
  20. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/dashboard.html +0 -0
  21. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/dashboard.py +0 -0
  22. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/diary_ingest.py +0 -0
  23. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/goal.py +0 -0
  24. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/goal_engine.py +0 -0
  25. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/__init__.py +0 -0
  26. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
  27. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/deny_manager_write.py +0 -0
  28. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/deny_reviewer_git.py +0 -0
  29. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/git_policy.py +0 -0
  30. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/index_meta.py +0 -0
  31. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/indexer.py +0 -0
  32. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/license.py +0 -0
  33. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/memory_db.py +0 -0
  34. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  35. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  36. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  37. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  38. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  39. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  40. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/server.py +0 -0
  41. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  42. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/search.py +0 -0
  43. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/session_context.py +0 -0
  44. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/skill_config.py +0 -0
  45. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/skill_descriptions.json +0 -0
  46. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/stats.py +0 -0
  47. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  48. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/version_check.py +0 -0
  49. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  50. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  51. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  52. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  53. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_advisor_strategy.py +0 -0
  54. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_body_parser_metric.py +0 -0
  55. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_builder_scope.py +0 -0
  56. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_code_read_savings.py +0 -0
  57. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_dashscan.py +0 -0
  58. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_failed_approaches.py +0 -0
  59. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_git_policy.py +0 -0
  60. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_index_meta_guard.py +0 -0
  61. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_license_environment.py +0 -0
  62. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_license_grace.py +0 -0
  63. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_inspector.py +0 -0
  64. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_scoring.py +0 -0
  65. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_tools.py +0 -0
  66. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_pro_compressor.py +0 -0
  67. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_pro_skills_update.py +0 -0
  68. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_pro_source_resolution.py +0 -0
  69. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_project_dir_canonical.py +0 -0
  70. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_search_memory.py +0 -0
  71. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_session_context.py +0 -0
  72. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_skill_drift.py +0 -0
  73. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_wheel_contents.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.16.0
3
+ Version: 4.17.0
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Project-URL: Homepage, https://memstack.pro
6
6
  Project-URL: Documentation, https://memstack.pro/docs
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.16.0"
7
+ version = "4.17.0"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.16.0"
3
+ __version__ = "4.17.0"
@@ -161,6 +161,12 @@ def main():
161
161
  elif len(sys.argv) > 2 and sys.argv[1] == "run":
162
162
  task = " ".join(sys.argv[2:])
163
163
  _run_agents(task)
164
+ elif len(sys.argv) > 1 and sys.argv[1] == "memory-migrate":
165
+ # Data migrations over the Memory Engine fact store. Dispatched here and
166
+ # nowhere else: no import-time hook, no call from MemoryStore, so a
167
+ # migration can only run because someone typed it.
168
+ from .memory_migrations import main as migrate_main
169
+ sys.exit(migrate_main(sys.argv[2:]))
164
170
  elif len(sys.argv) > 1 and sys.argv[1] == "proxy":
165
171
  import argparse
166
172
  parser = argparse.ArgumentParser(prog="memstack proxy")
@@ -185,17 +185,87 @@ def _sql_math_available(conn: sqlite3.Connection) -> bool:
185
185
  return _SQL_MATH_AVAILABLE
186
186
 
187
187
 
188
+ #: Source types whose ``method`` is filled in BULK rather than chosen per fact.
189
+ #: See :func:`display_method` for why the line is drawn on deliberation and not
190
+ #: on who authored the claim.
191
+ #:
192
+ #: This frozenset is the SINGLE definition of that scope. It gates two things
193
+ #: that must move together: how a fact's ``method`` is RENDERED
194
+ #: (:func:`display_method`) and how much retention that word BUYS
195
+ #: (:meth:`MemoryStore._resolve_confidence`). Widening it is a one-line change
196
+ #: that widens both; splitting them would let a source render as qualified
197
+ #: while still being weighted as if it were not.
198
+ _SELF_REPORT_SOURCES = frozenset({"diary"})
199
+
200
+ #: Confidence auto-assigned per method when the caller supplies none. A
201
+ #: hand-set value never consults this table.
202
+ _METHOD_BASE_CONFIDENCE = {
203
+ "verified": 0.85,
204
+ "reported": 0.7,
205
+ "inferred": 0.7,
206
+ "assumed": 0.5,
207
+ }
208
+
209
+ #: Ceiling on the AUTO-ASSIGNED confidence of a fact whose ``source_type`` is in
210
+ #: :data:`_SELF_REPORT_SOURCES`. See
211
+ #: :meth:`MemoryStore._resolve_confidence` for why 0.75 and not 0.70.
212
+ _SELF_REPORT_CONFIDENCE_CEILING = 0.75
213
+
214
+
215
+ def display_method(fact: dict) -> str:
216
+ """Render ``method`` for a reader, qualifying a bulk-declared 'verified'.
217
+
218
+ ``method`` is the ONLY field on a fact that nothing external ever checks.
219
+ ``source_type`` and ``source_ref`` are machine-set by the ingest path from
220
+ the file it read, so they cannot misstate where a fact came from; ``method``
221
+ is a word the writing session chose about its own claim.
222
+
223
+ WHY THE SCOPE IS ``diary`` AND NOT "everything self-authored"
224
+ ------------------------------------------------------------
225
+ ``session`` and ``correction`` facts are self-authored too. Nothing checks
226
+ them either, and NOTHING HERE SHOULD BE READ AS CLAIMING OTHERWISE: a
227
+ ``mem_store`` call carrying ``verified`` is the same session asserting the
228
+ same thing about its own work, and its tag renders unqualified only because
229
+ of where the line is drawn, not because anyone confirmed it.
230
+
231
+ The line is drawn on DELIBERATION, not on authorship or on trust. A diary's
232
+ ``method`` is a template field in a pipe-delimited FACTS block, filled for
233
+ every row of the block at end of session, when the evidence that would
234
+ settle it has already left the writer's context. A ``mem_store`` or
235
+ ``mem_correct`` call is one deliberate act about one fact, made while the
236
+ thing it describes is still in view. Those differ in the ATTENTION each word
237
+ receives, which is what the qualification is about.
238
+
239
+ Do not re-derive this scope from how the values happen to be distributed
240
+ today. That distribution is evidence the split is real, not the reason for
241
+ it, and it can change without the underlying difference changing.
242
+
243
+ This is a RENDERING transform over the stored (source_type, method) pair.
244
+ No new method value is stored, nothing is relabelled, and the stored word is
245
+ unchanged: ``fact['method']`` still reads ``verified``. Any other pair
246
+ renders exactly as before, so the tag degrades to its old output everywhere
247
+ the qualification does not apply.
248
+ """
249
+ method = fact["method"]
250
+ if method == "verified" and fact.get("source_type") in _SELF_REPORT_SOURCES:
251
+ return "self-verified"
252
+ return method
253
+
254
+
188
255
  def provenance_tag(fact: dict) -> str:
189
256
  """Inline provenance tag for a fact, e.g.
190
- ``[verified · diary memory/sessions/2026-07-23-demo.md · recalled 3x]``.
257
+ ``[self-verified · diary memory/sessions/2026-07-23-demo.md · recalled 3x]``.
191
258
 
192
259
  Single source of truth for how a fact's origin is rendered, shared by the
193
- MCP server and the SessionStart living-memory block so the two can't drift.
260
+ MCP server, the dashboard Inspector and the SessionStart living-memory block
261
+ so the three can't drift. The leading token comes from
262
+ :func:`display_method`; see there for why a diary's ``verified`` renders
263
+ qualified and a session's does not.
194
264
  """
195
265
  src = fact["source_type"]
196
266
  if fact.get("source_ref"):
197
267
  src = f"{src} {fact['source_ref']}"
198
- return f"[{fact['method']} · {src} · recalled {fact.get('recall_count', 0)}x]"
268
+ return f"[{display_method(fact)} · {src} · recalled {fact.get('recall_count', 0)}x]"
199
269
 
200
270
 
201
271
  # ---------------------------------------------------------------------------
@@ -368,20 +438,65 @@ class MemoryStore:
368
438
  )
369
439
 
370
440
  @staticmethod
371
- def _resolve_confidence(method: str, confidence: float | None) -> float:
372
- if confidence is None:
373
- if method == "verified":
374
- return 0.85
375
- if method == "assumed":
376
- return 0.5
377
- return 0.7 # reported / inferred -> column default
378
- if not isinstance(confidence, (int, float)) or not (0 < confidence <= 1):
379
- raise InvalidFieldError(
380
- f"confidence must be in (0, 1], got {confidence!r}"
381
- )
382
- if method == "assumed" and confidence > 0.5:
383
- return 0.5 # assumed caps at 0.5 (cap only lowers)
384
- return float(confidence)
441
+ def _resolve_confidence(
442
+ method: str, confidence: float | None, source_type: str
443
+ ) -> float:
444
+ """Confidence for a new fact: a hand-set value, or the method's base
445
+ with the self-report ceiling applied.
446
+
447
+ A HAND-SET value passes through unchanged, subject only to the
448
+ long-standing ``assumed`` cap (which never raises). The ceiling below is
449
+ a claim about what a writer's own WORD is worth when nothing chose a
450
+ number per fact, so it applies only where the number was auto-assigned.
451
+ A caller who deliberately passes ``confidence=0.85`` on a diary fact
452
+ keeps 0.85; the ceiling has nothing to say about a value someone chose.
453
+
454
+ WHY A CEILING EXISTS AT ALL
455
+ ---------------------------
456
+ ``confidence`` is the ONLY route by which ``method`` reaches recall
457
+ ranking: :func:`recall_score` reads confidence, age, recall count and
458
+ status, and never reads ``method``. So the word a session picks for its
459
+ own claim is, by itself, what decides whether that claim is recited back
460
+ at the top of the next session. For a source whose ``method`` is filled
461
+ in bulk (:data:`_SELF_REPORT_SOURCES`), that is a self-declared word
462
+ buying a self-declared retention advantage.
463
+
464
+ WHY 0.75 AND NOT 0.70
465
+ ---------------------
466
+ 0.70 is the base for ``reported`` and ``inferred``, so a ceiling of 0.70
467
+ would make a diary's ``verified`` numerically identical to a diary's
468
+ ``reported`` and erase the one distinction the method vocabulary draws
469
+ well. It also over-corrects: measured against this repo's store, 0.70
470
+ removed EVERY diary fact from the session-start injection window
471
+ (9 of 13 slots before, 0 of 15 after), replacing a window dominated by
472
+ one source with one that excludes it. 0.75 keeps the ordering
473
+ ``verified`` > ``reported`` inside a diary while ending the automatic
474
+ sweep of the window: the same measurement puts diary facts at 6 of 13
475
+ slots.
476
+
477
+ WHY ``min`` AND NOT ASSIGNMENT
478
+ ------------------------------
479
+ The ceiling only ever LOWERS. Today ``verified`` (0.85) is the sole base
480
+ above it, so it is the only method the ceiling touches. Writing this as
481
+ ``base = 0.75`` would look equivalent and would silently RAISE
482
+ ``reported``, ``inferred`` and ``assumed`` the moment
483
+ :data:`_SELF_REPORT_SOURCES` is widened to a source that uses them.
484
+ """
485
+ if confidence is not None:
486
+ if not isinstance(confidence, (int, float)) or not (0 < confidence <= 1):
487
+ raise InvalidFieldError(
488
+ f"confidence must be in (0, 1], got {confidence!r}"
489
+ )
490
+ if method == "assumed" and confidence > 0.5:
491
+ return 0.5 # assumed caps at 0.5 (cap only lowers)
492
+ return float(confidence)
493
+
494
+ # method is already validated against _VALID_METHODS by
495
+ # _validate_common, which every insert path calls first.
496
+ base = _METHOD_BASE_CONFIDENCE[method]
497
+ if source_type in _SELF_REPORT_SOURCES:
498
+ base = min(base, _SELF_REPORT_CONFIDENCE_CEILING)
499
+ return base
385
500
 
386
501
  @classmethod
387
502
  def _validate_common(
@@ -418,7 +533,7 @@ class MemoryStore:
418
533
  _test_created_at: str | None,
419
534
  ) -> int:
420
535
  self._validate_common(project, subject, source_type, method)
421
- conf = self._resolve_confidence(method, confidence)
536
+ conf = self._resolve_confidence(method, confidence, source_type)
422
537
 
423
538
  cols = [
424
539
  "project", "subject", "claim", "detail", "supersedes",
@@ -503,14 +618,27 @@ class MemoryStore:
503
618
  confidence: float | None = None,
504
619
  source_ref: str | None = None,
505
620
  entities: Iterable[str] | None = None,
621
+ subject: str | None = None,
506
622
  _test_created_at: str | None = None,
507
623
  ) -> int:
508
624
  """Supersede ``old_id`` with a new fact; return the new id.
509
625
 
510
- The correction inherits ``project`` and ``subject`` from the fact being
511
- corrected. Refused if ``old_id`` is not the live tip the error names
512
- the current tip. The insert of the new fact and the status flip of the
513
- old fact happen in a single transaction.
626
+ The correction always inherits ``project``. It inherits ``subject`` too
627
+ unless ``subject`` is given, which re-files the corrected fact under a
628
+ new dotted-path, for the case where the correction disproves the very
629
+ thing the old subject named. The new subject is validated like any
630
+ other; the OLD row keeps its original subject forever, because identity
631
+ columns are immutable once written.
632
+
633
+ Renaming leaves the chain intact (``_find_tip`` walks ``supersedes``,
634
+ never ``subject``) but does strand callers who query the old subject.
635
+ :meth:`renamed_subjects` is the forward pointer that resolves them, and
636
+ :meth:`list_facts` / :meth:`recall` consult it rather than returning a
637
+ silent empty.
638
+
639
+ Refused if ``old_id`` is not the live tip. The error names the current
640
+ tip. The insert of the new fact and the status flip of the old fact
641
+ happen in a single transaction.
514
642
  """
515
643
  conn = self._connect()
516
644
  try:
@@ -535,7 +663,7 @@ class MemoryStore:
535
663
  new_id = self._insert_fact(
536
664
  conn,
537
665
  project=old["project"],
538
- subject=old["subject"],
666
+ subject=old["subject"] if subject is None else subject,
539
667
  claim=claim,
540
668
  source_type=source_type,
541
669
  method=method,
@@ -694,6 +822,48 @@ class MemoryStore:
694
822
  finally:
695
823
  conn.close()
696
824
 
825
+ def renamed_subjects(self, project: str, subject: str) -> list[str]:
826
+ """Subjects that chains once filed under ``subject`` now live under.
827
+
828
+ The forward pointer for a subject rename. A correction may re-file a
829
+ fact (see :meth:`correct_fact`), which strands anything querying the old
830
+ dotted-path: the superseded row still carries it, but a live-status
831
+ filter drops that row and the query returns empty with no error: the
832
+ same wrong-input-looks-like-no-data failure the subject filter has
833
+ always been able to produce.
834
+
835
+ Returns the DISTINCT live subjects reached by following each superseded
836
+ row with this subject to its chain tip, excluding ``subject`` itself.
837
+ Empty when nothing was renamed, which is every chain written before
838
+ renaming existed, so this is inert on any store that predates it.
839
+ """
840
+ conn = self._connect()
841
+ try:
842
+ return self._renamed_subjects(conn, project, subject)
843
+ finally:
844
+ conn.close()
845
+
846
+ def _renamed_subjects(
847
+ self, conn: sqlite3.Connection, project: str, subject: str
848
+ ) -> list[str]:
849
+ """:meth:`renamed_subjects` on a caller-owned connection."""
850
+ rows = conn.execute(
851
+ "SELECT id FROM memory_facts "
852
+ "WHERE project=? AND subject=? AND status='superseded'",
853
+ (project, subject),
854
+ ).fetchall()
855
+ out: list[str] = []
856
+ for row in rows:
857
+ tip = conn.execute(
858
+ "SELECT subject, status FROM memory_facts WHERE id=?",
859
+ (self._find_tip(conn, row["id"]),),
860
+ ).fetchone()
861
+ if tip is None or tip["status"] != "live":
862
+ continue
863
+ if tip["subject"] != subject and tip["subject"] not in out:
864
+ out.append(tip["subject"])
865
+ return out
866
+
697
867
  def list_facts(
698
868
  self,
699
869
  project: str,
@@ -707,22 +877,45 @@ class MemoryStore:
707
877
  Ordered by insertion (``id DESC``), so a ``limit`` below the project's
708
878
  live count truncates by *recency of insertion*, not by relevance. Use
709
879
  :meth:`recall` when you want the highest-scoring facts.
880
+
881
+ A live-status subject query that matches nothing falls back to
882
+ :meth:`renamed_subjects`, so a subject that a correction re-filed
883
+ resolves forward to its tip instead of returning a silent empty. The
884
+ fallback runs ONLY when the direct match is empty: if the old subject
885
+ still has live facts of its own the query is answerable as asked, and
886
+ second-guessing it would surface rows the caller did not ask for.
710
887
  """
711
888
  if limit > _MAX_ROWS:
712
889
  limit = _MAX_ROWS
713
890
  conn = self._connect()
714
891
  try:
715
- sql = "SELECT * FROM memory_facts WHERE project=? AND status=?"
716
- params: list[Any] = [project, status]
717
- if subject is not None:
718
- sql += " AND subject=?"
719
- params.append(subject)
720
- sql += " ORDER BY id DESC LIMIT ?"
721
- params.append(limit)
722
- return [dict(r) for r in conn.execute(sql, params).fetchall()]
892
+ rows = self._list_facts(conn, project, subject, status, limit)
893
+ if rows or subject is None or status != "live":
894
+ return rows
895
+ for renamed in self._renamed_subjects(conn, project, subject):
896
+ rows += self._list_facts(conn, project, renamed, status, limit)
897
+ return rows[:limit]
723
898
  finally:
724
899
  conn.close()
725
900
 
901
+ @staticmethod
902
+ def _list_facts(
903
+ conn: sqlite3.Connection,
904
+ project: str,
905
+ subject: str | None,
906
+ status: str,
907
+ limit: int,
908
+ ) -> list[dict]:
909
+ """One direct :meth:`list_facts` query, with no rename fallback."""
910
+ sql = "SELECT * FROM memory_facts WHERE project=? AND status=?"
911
+ params: list[Any] = [project, status]
912
+ if subject is not None:
913
+ sql += " AND subject=?"
914
+ params.append(subject)
915
+ sql += " ORDER BY id DESC LIMIT ?"
916
+ params.append(limit)
917
+ return [dict(r) for r in conn.execute(sql, params).fetchall()]
918
+
726
919
  def count_live(self, project: str) -> int:
727
920
  """Number of live facts for ``project``.
728
921
 
@@ -862,12 +1055,40 @@ class MemoryStore:
862
1055
  functions this falls back to scoring every candidate in Python, which
863
1056
  returns identical rows for more wall-clock. Either way the result is the
864
1057
  true top-``limit`` by score, never a slice of the most recently inserted.
1058
+
1059
+ A ``subject`` that matches nothing resolves forward through
1060
+ :meth:`renamed_subjects`, exactly as in :meth:`list_facts`, so a
1061
+ re-filed fact is still reachable by the dotted-path it used to carry.
1062
+ The retry happens at most once: :meth:`_find_tip` already walks a chain
1063
+ to its END, so one hop covers a subject renamed any number of times.
865
1064
  """
866
1065
  if now is None:
867
1066
  now = datetime.now(timezone.utc)
868
1067
  if limit > _MAX_ROWS:
869
1068
  limit = _MAX_ROWS
870
1069
 
1070
+ hits = self._recall_once(project, query, subject, deep, limit, now)
1071
+ if hits or subject is None or query is not None:
1072
+ return hits
1073
+ conn = self._connect()
1074
+ try:
1075
+ renamed = self._renamed_subjects(conn, project, subject)
1076
+ finally:
1077
+ conn.close()
1078
+ for name in renamed:
1079
+ hits += self._recall_once(project, query, name, deep, limit, now)
1080
+ return hits[:limit]
1081
+
1082
+ def _recall_once(
1083
+ self,
1084
+ project: str,
1085
+ query: str | None,
1086
+ subject: str | None,
1087
+ deep: bool,
1088
+ limit: int,
1089
+ now: datetime,
1090
+ ) -> list[dict]:
1091
+ """One scored :meth:`recall` pass, with no rename fallback."""
871
1092
  conn = self._connect()
872
1093
  try:
873
1094
  if _sql_math_available(conn):
@@ -931,13 +1152,23 @@ class MemoryStore:
931
1152
  limit: int,
932
1153
  now: datetime,
933
1154
  ) -> list[dict]:
934
- """Score-ordered recall computed in Python (fallback; see :meth:`recall`)."""
1155
+ """Score-ordered recall computed in Python (fallback; see :meth:`recall`).
1156
+
1157
+ Candidates come from :meth:`_list_facts`, NOT :meth:`list_facts`: the
1158
+ rename fallback lives in :meth:`recall` and must fire once for both
1159
+ paths. Going through the public method would resolve a second time here
1160
+ and return the tip twice on this path only.
1161
+ """
935
1162
  if query is not None:
936
1163
  candidates = self.search_live(query, project=project, limit=_MAX_ROWS)
937
1164
  else:
938
- candidates = self.list_facts(
939
- project=project, subject=subject, limit=_MAX_ROWS
940
- )
1165
+ conn = self._connect()
1166
+ try:
1167
+ candidates = self._list_facts(
1168
+ conn, project, subject, "live", _MAX_ROWS
1169
+ )
1170
+ finally:
1171
+ conn.close()
941
1172
 
942
1173
  scored = []
943
1174
  for fact in candidates: