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.
- {memstack_skill_loader-4.16.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.17.0}/PKG-INFO +1 -1
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/pyproject.toml +1 -1
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/__init__.py +1 -1
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/__main__.py +6 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/memory_engine.py +266 -35
- memstack_skill_loader-4.17.0/src/memstack_skill_loader/memory_migrations.py +370 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/server.py +50 -7
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +2 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_diary_ingest.py +7 -2
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_engine.py +259 -1
- memstack_skill_loader-4.17.0/tests/test_memory_migrations.py +286 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/MANIFEST.in +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/README.md +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/setup.cfg +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/agent_runner.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/categories.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/compression.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/config.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/dashboard.html +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/dashboard.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/diary_ingest.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/goal.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/goal_engine.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/__init__.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/deny_manager_write.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/deny_reviewer_git.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/hooks/git_policy.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/index_meta.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/indexer.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/license.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/memory_db.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/server.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/search.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/session_context.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/skill_config.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/skill_descriptions.json +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/stats.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/version_check.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_advisor_strategy.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_body_parser_metric.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_builder_scope.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_code_read_savings.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_dashscan.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_failed_approaches.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_git_policy.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_index_meta_guard.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_license_environment.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_license_grace.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_inspector.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_scoring.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_memory_tools.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_pro_compressor.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_pro_skills_update.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_pro_source_resolution.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_project_dir_canonical.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_search_memory.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_session_context.py +0 -0
- {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/tests/test_skill_drift.py +0 -0
- {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.
|
|
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.
|
|
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"
|
{memstack_skill_loader-4.16.0 → memstack_skill_loader-4.17.0}/src/memstack_skill_loader/__main__.py
RENAMED
|
@@ -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
|
|
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
|
|
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(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
old
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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:
|