superlocalmemory 4.0.9 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +308 -20
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +26 -4
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +71 -42
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +26 -2
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +193 -17
- package/src/superlocalmemory/server/ui.py +24 -1
- package/src/superlocalmemory/server/unified_daemon.py +292 -9
- package/src/superlocalmemory/storage/_migration_internals.py +35 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +598 -82
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +130 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +110 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +10 -19
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +209 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Work out which session a tool call belongs to, the same way every time.
|
|
5
|
+
|
|
6
|
+
``recall`` has resolved this through a four-step ladder since S9-DASH-10 — the
|
|
7
|
+
explicit argument, then the environment, then the hook registry, then a stable
|
|
8
|
+
per-agent fallback — so an engagement signal lands on the right pending outcome.
|
|
9
|
+
``remember`` never had it. It took ``session_id: str = ""`` and stored whatever
|
|
10
|
+
it was handed, which for a caller that does not pass one is nothing.
|
|
11
|
+
|
|
12
|
+
Measured on the author's store: **192 of 3,894 genuine facts carry a session_id
|
|
13
|
+
(4.9%)**, and 4 of the 200 most recent (2%). Every one of the rest was written
|
|
14
|
+
through a path that could have known and did not.
|
|
15
|
+
|
|
16
|
+
That is not bookkeeping. ``RetrievalEngine`` promotes results so the top of an
|
|
17
|
+
answer spans more than one session (its ``sessions_in_top`` pass), and a fact
|
|
18
|
+
with no session_id can never be promoted by it — so the diversity mechanism was
|
|
19
|
+
running against a corpus where 95% of rows were indistinguishable. It also means
|
|
20
|
+
"what did we discuss in that session" has almost nothing to match on.
|
|
21
|
+
|
|
22
|
+
One implementation, called by both tools, so the read path and the write path
|
|
23
|
+
cannot disagree about which session they are in.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
__all__ = ["resolve_session_id", "SESSION_ENV_VARS"]
|
|
34
|
+
|
|
35
|
+
#: Checked in order. Hosts set one or the other; SLM's own takes precedence so a
|
|
36
|
+
#: user can override a host that sets its variable to something unhelpful.
|
|
37
|
+
SESSION_ENV_VARS = ("SLM_SESSION_ID", "CLAUDE_SESSION_ID")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_session_id(
|
|
41
|
+
explicit: str = "",
|
|
42
|
+
*,
|
|
43
|
+
agent_id: str = "unknown",
|
|
44
|
+
allow_agent_fallback: bool = True,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Best available session id for this call. Never raises.
|
|
47
|
+
|
|
48
|
+
Order, most to least specific:
|
|
49
|
+
|
|
50
|
+
1. ``explicit`` — what the caller passed. Always wins.
|
|
51
|
+
2. ``SLM_SESSION_ID`` / ``CLAUDE_SESSION_ID`` from the environment.
|
|
52
|
+
3. The hook registry: the session whose parent process is ours, else the
|
|
53
|
+
most recently active one inside 60 seconds. Parent-PID lookup is
|
|
54
|
+
collision-free across parallel host sessions, because each MCP
|
|
55
|
+
server's parent is the editor that spawned it.
|
|
56
|
+
4. ``mcp:<agent_id>`` — stable per agent, and deliberately NOT matched by
|
|
57
|
+
the Stop hook, so the reaper settles those outcomes at a neutral 0.5
|
|
58
|
+
rather than crediting or blaming a session that never existed.
|
|
59
|
+
|
|
60
|
+
``allow_agent_fallback=False`` stops before step 4 and returns "". Use it
|
|
61
|
+
where a synthetic id would be worse than none: grouping memories under
|
|
62
|
+
``mcp:<agent>`` would put every memory an agent ever wrote into one bucket
|
|
63
|
+
and make session-diversity promotion rank them as a single session, which
|
|
64
|
+
is the opposite of what it is for.
|
|
65
|
+
"""
|
|
66
|
+
if explicit and explicit.strip():
|
|
67
|
+
return explicit.strip()
|
|
68
|
+
|
|
69
|
+
for name in SESSION_ENV_VARS:
|
|
70
|
+
value = os.environ.get(name)
|
|
71
|
+
if value and value.strip():
|
|
72
|
+
return value.strip()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from superlocalmemory.hooks.session_registry import (
|
|
76
|
+
lookup_by_parent,
|
|
77
|
+
most_recent_active,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
found = (
|
|
81
|
+
lookup_by_parent(within_seconds=60)
|
|
82
|
+
or most_recent_active(agent_type="claude", within_seconds=60)
|
|
83
|
+
or ""
|
|
84
|
+
)
|
|
85
|
+
if found:
|
|
86
|
+
return found
|
|
87
|
+
except Exception as exc: # noqa: BLE001 — a hint must never fail a call
|
|
88
|
+
logger.debug("session registry lookup unavailable: %s", exc)
|
|
89
|
+
|
|
90
|
+
if allow_agent_fallback:
|
|
91
|
+
return f"mcp:{agent_id}"
|
|
92
|
+
return ""
|
|
@@ -287,7 +287,7 @@ def _upcoming_scheduled_facts(engine, now: datetime.datetime) -> list[dict]:
|
|
|
287
287
|
"SELECT fact_id, content, referenced_date"
|
|
288
288
|
" FROM atomic_facts"
|
|
289
289
|
" WHERE profile_id = ?"
|
|
290
|
-
" AND fact_type = '
|
|
290
|
+
" AND fact_type = 'prospective'"
|
|
291
291
|
" AND referenced_date IS NOT NULL"
|
|
292
292
|
" AND referenced_date >= ?"
|
|
293
293
|
" AND referenced_date < ?"
|
|
@@ -312,6 +312,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
312
312
|
# 1. session_init — Auto-recall project context at session start
|
|
313
313
|
# ------------------------------------------------------------------
|
|
314
314
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
315
|
+
@admits(OperationKind.RECALL)
|
|
315
316
|
async def session_init(
|
|
316
317
|
project_path: str = "",
|
|
317
318
|
query: str = "",
|
|
@@ -20,7 +20,7 @@ from typing import Callable
|
|
|
20
20
|
from mcp.types import ToolAnnotations
|
|
21
21
|
|
|
22
22
|
from superlocalmemory.core.admission import admits
|
|
23
|
-
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
23
|
+
from superlocalmemory.core.config import CANONICAL_LIST_LIMIT, CANONICAL_RECALL_LIMIT
|
|
24
24
|
from superlocalmemory.core.operation_request import OperationKind
|
|
25
25
|
from superlocalmemory.infra.data_root import state_path
|
|
26
26
|
from superlocalmemory.mcp._daemon_proxy import daemon_unavailable_error
|
|
@@ -29,6 +29,19 @@ from superlocalmemory.mcp.shared import authorize_mcp_mutation
|
|
|
29
29
|
logger = logging.getLogger(__name__)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _projection_queue_depth(db: object) -> int:
|
|
33
|
+
"""Facts queued for the graph and vector projections, or 0 when there are none.
|
|
34
|
+
|
|
35
|
+
Imported inside the function: this module is loaded on every MCP start over
|
|
36
|
+
stdio, where import cost is startup latency a user feels.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
from superlocalmemory.storage import projection_outbox
|
|
40
|
+
return projection_outbox.depth(db)
|
|
41
|
+
except Exception:
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
32
45
|
async def _runtime_profile(get_engine: Callable, explicit: str = "") -> str:
|
|
33
46
|
"""Resolve an MCP default profile from daemon runtime truth."""
|
|
34
47
|
if explicit:
|
|
@@ -73,6 +86,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
73
86
|
scope: str | None = None,
|
|
74
87
|
shared_with: str = "",
|
|
75
88
|
idempotency_key: str = "",
|
|
89
|
+
session_date: str = "",
|
|
76
90
|
) -> dict:
|
|
77
91
|
"""Store content to memory with intelligent indexing.
|
|
78
92
|
|
|
@@ -82,11 +96,35 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
82
96
|
Multi-scope: ``scope`` sets visibility (personal/shared/global).
|
|
83
97
|
``shared_with`` is a comma-separated list of profile_ids for
|
|
84
98
|
shared scope.
|
|
99
|
+
|
|
100
|
+
``session_date`` says WHEN the memory is about, as opposed to when it
|
|
101
|
+
was written. Omit it and the memory is dated today, which is what every
|
|
102
|
+
memory got before 4.0.10 because there was no way to say otherwise.
|
|
103
|
+
Accepts YYYY-MM-DD or a full ISO 8601 timestamp.
|
|
85
104
|
"""
|
|
86
105
|
# v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
|
|
87
106
|
if agent_id == "mcp_client":
|
|
88
107
|
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
89
108
|
agent_id = get_current_agent_id()
|
|
109
|
+
# Bind the write to a session the same way the read path does.
|
|
110
|
+
#
|
|
111
|
+
# recall has resolved this through a four-step ladder since S9-DASH-10;
|
|
112
|
+
# remember stored whatever it was handed, which for a caller that does
|
|
113
|
+
# not pass one is nothing. Result on the author's store: 192 of 3,894
|
|
114
|
+
# facts carry a session_id (4.9%). The engine's session-diversity
|
|
115
|
+
# promotion cannot promote a fact with no session, so it was running
|
|
116
|
+
# against a corpus where 95% of rows looked like the same session.
|
|
117
|
+
#
|
|
118
|
+
# allow_agent_fallback is OFF here, unlike recall. `mcp:<agent_id>` is a
|
|
119
|
+
# useful key for settling one outcome; as a stored session_id it would
|
|
120
|
+
# file every memory an agent ever wrote under one session, and diversity
|
|
121
|
+
# promotion would then treat a whole history as a single conversation —
|
|
122
|
+
# worse than the empty string it replaces.
|
|
123
|
+
from superlocalmemory.mcp.session_binding import resolve_session_id
|
|
124
|
+
|
|
125
|
+
session_id = resolve_session_id(
|
|
126
|
+
session_id, agent_id=agent_id, allow_agent_fallback=False,
|
|
127
|
+
)
|
|
90
128
|
meta = {
|
|
91
129
|
"project": project,
|
|
92
130
|
"importance": importance,
|
|
@@ -141,6 +179,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
141
179
|
"content": content, "tags": tags, "metadata": meta,
|
|
142
180
|
"scope": scope, "shared_with": _shared_list,
|
|
143
181
|
"session_id": session_id,
|
|
182
|
+
"session_date": session_date,
|
|
144
183
|
"idempotency_key": effective_idempotency_key or None,
|
|
145
184
|
})
|
|
146
185
|
if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
|
|
@@ -320,45 +359,20 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
320
359
|
import asyncio
|
|
321
360
|
try:
|
|
322
361
|
from superlocalmemory.mcp._daemon_proxy import choose_pool
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
effective_sid =
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
effective_sid = (
|
|
338
|
-
_os.environ.get("SLM_SESSION_ID")
|
|
339
|
-
or _os.environ.get("CLAUDE_SESSION_ID")
|
|
340
|
-
or ""
|
|
341
|
-
)
|
|
342
|
-
if not effective_sid:
|
|
343
|
-
try:
|
|
344
|
-
from superlocalmemory.hooks.session_registry import (
|
|
345
|
-
lookup_by_parent,
|
|
346
|
-
most_recent_active,
|
|
347
|
-
)
|
|
348
|
-
# Parent-PID lookup is collision-free across multiple
|
|
349
|
-
# parallel Claude sessions (each MCP server's parent
|
|
350
|
-
# is the IDE that spawned it).
|
|
351
|
-
effective_sid = (
|
|
352
|
-
lookup_by_parent(within_seconds=60)
|
|
353
|
-
or most_recent_active(
|
|
354
|
-
agent_type="claude", within_seconds=60,
|
|
355
|
-
)
|
|
356
|
-
or ""
|
|
357
|
-
)
|
|
358
|
-
except Exception:
|
|
359
|
-
pass
|
|
360
|
-
if not effective_sid:
|
|
361
|
-
effective_sid = f"mcp:{agent_id}"
|
|
362
|
+
from superlocalmemory.mcp.session_binding import resolve_session_id
|
|
363
|
+
|
|
364
|
+
# S9-DASH-10's four-step ladder, now shared with remember() so the
|
|
365
|
+
# read path and the write path cannot disagree about which session
|
|
366
|
+
# they are in. remember() had no ladder at all, which is why 95% of
|
|
367
|
+
# stored facts carry no session_id. See mcp/session_binding.py.
|
|
368
|
+
#
|
|
369
|
+
# The per-agent fallback stays ON here: this id settles a pending
|
|
370
|
+
# outcome, and `mcp:<agent_id>` is deliberately not matched by the
|
|
371
|
+
# Stop hook, so the reaper settles it at a neutral 0.5 rather than
|
|
372
|
+
# attributing engagement to a session that never existed.
|
|
373
|
+
effective_sid = resolve_session_id(
|
|
374
|
+
session_id, agent_id=agent_id, allow_agent_fallback=True,
|
|
375
|
+
)
|
|
362
376
|
# Resolve the daemon proxy inside the worker too. ``choose_pool``
|
|
363
377
|
# verifies daemon ownership through a synchronous /health request;
|
|
364
378
|
# when this tool is served by the daemon's mounted HTTP MCP app,
|
|
@@ -424,6 +438,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
424
438
|
"score_contract_version": result.get("score_contract_version", "2"),
|
|
425
439
|
"calibration_status": result.get("calibration_status", "uncalibrated"),
|
|
426
440
|
"calibration_id": result.get("calibration_id"),
|
|
441
|
+
# Quote this back to report_outcome and the report ties to
|
|
442
|
+
# this exact answer instead of being matched by guesswork.
|
|
443
|
+
"query_id": result.get("query_id", ""),
|
|
427
444
|
"answer_confidence": result.get("answer_confidence"),
|
|
428
445
|
"abstained": result.get("abstained", False),
|
|
429
446
|
"abstention_reason": result.get("abstention_reason"),
|
|
@@ -435,7 +452,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
435
452
|
|
|
436
453
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
437
454
|
@admits(OperationKind.RECALL)
|
|
438
|
-
async def search(query: str, limit: int =
|
|
455
|
+
async def search(query: str, limit: int = CANONICAL_RECALL_LIMIT) -> dict:
|
|
439
456
|
"""Full-text search across memories using FTS5 with BM25 ranking."""
|
|
440
457
|
try:
|
|
441
458
|
engine = get_engine()
|
|
@@ -456,6 +473,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
456
473
|
return {"success": False, "error": str(exc)}
|
|
457
474
|
|
|
458
475
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
476
|
+
@admits(OperationKind.RECALL)
|
|
459
477
|
async def fetch(fact_ids: str) -> dict:
|
|
460
478
|
"""Fetch full details for specific fact IDs (comma-separated)."""
|
|
461
479
|
try:
|
|
@@ -483,7 +501,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
483
501
|
return {"success": False, "error": str(exc)}
|
|
484
502
|
|
|
485
503
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
486
|
-
|
|
504
|
+
@admits(OperationKind.RECALL)
|
|
505
|
+
async def list_recent(limit: int = CANONICAL_LIST_LIMIT) -> dict:
|
|
487
506
|
"""List most recently stored memories, newest first."""
|
|
488
507
|
try:
|
|
489
508
|
engine = get_engine()
|
|
@@ -510,6 +529,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
510
529
|
async def get_status() -> dict:
|
|
511
530
|
"""Get memory system status: fact count, entity count, mode, profile, db size."""
|
|
512
531
|
try:
|
|
532
|
+
# Same source the HTTP surface reads, imported here rather than at
|
|
533
|
+
# module scope: that module costs ~260ms and MCP starts over stdio.
|
|
534
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
535
|
+
|
|
513
536
|
import asyncio
|
|
514
537
|
import os
|
|
515
538
|
|
|
@@ -539,6 +562,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
539
562
|
"profile_generation": int(
|
|
540
563
|
daemon_status.get("profile_generation", 0)
|
|
541
564
|
),
|
|
565
|
+
"version": SLM_VERSION,
|
|
566
|
+
"projection_queue_depth": int(
|
|
567
|
+
daemon_status.get("projection_queue_depth", 0)
|
|
568
|
+
),
|
|
542
569
|
}
|
|
543
570
|
|
|
544
571
|
engine = get_engine()
|
|
@@ -575,6 +602,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
575
602
|
"entity_count": entity_count,
|
|
576
603
|
"edge_count": edge_count,
|
|
577
604
|
"profile_generation": 0,
|
|
605
|
+
"version": SLM_VERSION,
|
|
606
|
+
"projection_queue_depth": _projection_queue_depth(engine._db),
|
|
578
607
|
}
|
|
579
608
|
except Exception as exc:
|
|
580
609
|
logger.exception("get_status failed")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
3
|
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
4
|
|
|
5
|
-
"""
|
|
5
|
+
"""Operational Recovery & Admin Remediation MCP tools (2 tools).
|
|
6
6
|
|
|
7
7
|
list_failed_operations — Surface dead-letter, degraded, and exhausted ops.
|
|
8
8
|
resolve_operation — Admin retry/force-reconcile/cancel for stuck ops.
|
|
@@ -28,7 +28,7 @@ _VALID_ACTIONS = frozenset({"retry", "force_reconcile", "cancel"})
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def register_ops_tools(server, get_engine: Callable) -> None:
|
|
31
|
-
"""Register
|
|
31
|
+
"""Register operational-recovery MCP tools on *server*."""
|
|
32
32
|
|
|
33
33
|
# ------------------------------------------------------------------
|
|
34
34
|
# 1. list_failed_operations — surface all stuck/failed/degraded ops
|
|
@@ -36,6 +36,7 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
36
36
|
memory_ids: str,
|
|
37
37
|
outcome: str,
|
|
38
38
|
context: str = "",
|
|
39
|
+
recall_query_id: str = "",
|
|
39
40
|
) -> dict:
|
|
40
41
|
"""Report outcome of using recalled memories.
|
|
41
42
|
|
|
@@ -46,6 +47,10 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
46
47
|
memory_ids: Comma-separated list of fact/memory IDs.
|
|
47
48
|
outcome: One of 'success', 'failure', 'partial'.
|
|
48
49
|
context: Optional freetext context about the outcome.
|
|
50
|
+
recall_query_id: The ``query_id`` that came back with the recall
|
|
51
|
+
this report is about. Passing it ties the report to that exact
|
|
52
|
+
answer; leaving it out falls back to matching on which memories
|
|
53
|
+
overlap, within a time window.
|
|
49
54
|
"""
|
|
50
55
|
try:
|
|
51
56
|
engine = get_engine()
|
|
@@ -54,7 +59,20 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
54
59
|
"update",
|
|
55
60
|
mutation_source="mcp-report-outcome",
|
|
56
61
|
)
|
|
57
|
-
from superlocalmemory.learning.outcomes import
|
|
62
|
+
from superlocalmemory.learning.outcomes import (
|
|
63
|
+
VALID_OUTCOMES,
|
|
64
|
+
OutcomeTracker,
|
|
65
|
+
)
|
|
66
|
+
if outcome not in VALID_OUTCOMES:
|
|
67
|
+
# Answered here rather than as a stack trace: the caller is an
|
|
68
|
+
# assistant that can correct itself if told what is allowed.
|
|
69
|
+
return {
|
|
70
|
+
"success": False,
|
|
71
|
+
"error": (
|
|
72
|
+
f"outcome must be one of {sorted(VALID_OUTCOMES)}, "
|
|
73
|
+
f"not {outcome!r}"
|
|
74
|
+
),
|
|
75
|
+
}
|
|
58
76
|
tracker = OutcomeTracker(engine._db)
|
|
59
77
|
ids = [mid.strip() for mid in memory_ids.split(",") if mid.strip()]
|
|
60
78
|
ctx = {"note": context} if context else None
|
|
@@ -64,6 +82,7 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
64
82
|
outcome=outcome,
|
|
65
83
|
profile_id=engine.profile_id,
|
|
66
84
|
context=ctx,
|
|
85
|
+
recall_query_id=str(recall_query_id or "").strip(),
|
|
67
86
|
)
|
|
68
87
|
|
|
69
88
|
# v3.4.7: Bridge outcomes → learning signals for two-way learning.
|
|
@@ -66,7 +66,17 @@ _BEHAVIORAL_TYPE_MAP: dict[str, str] = {
|
|
|
66
66
|
"query_type": "workflow_pattern",
|
|
67
67
|
"time_of_day": "workflow_pattern",
|
|
68
68
|
"refinement": "communication_style",
|
|
69
|
-
"interest"
|
|
69
|
+
# An "interest" is a word that shows up often in this user's memories. It is
|
|
70
|
+
# NOT a statement about their tooling, and calling it one produced the
|
|
71
|
+
# single worst thing this subsystem has shipped: a prompt injected on every
|
|
72
|
+
# turn reading "the user's preferred technology stack includes: test, gate,
|
|
73
|
+
# practices, compliance, projects, while, their, processing, data".
|
|
74
|
+
#
|
|
75
|
+
# Every one of those words is a real and frequent topic for this user. The
|
|
76
|
+
# values were right; the claim about them was false. Measured on a live
|
|
77
|
+
# store, `_store_patterns` holds correct tech_preference rows alongside
|
|
78
|
+
# these — Node.js, Go, Git, pip — so the two kinds were never the same kind.
|
|
79
|
+
"interest": "topic_interest",
|
|
70
80
|
"archival": "avoidance",
|
|
71
81
|
}
|
|
72
82
|
|
|
@@ -90,6 +100,9 @@ class PatternCategory(str, Enum):
|
|
|
90
100
|
WORKFLOW_PATTERN = "workflow_pattern"
|
|
91
101
|
PROJECT_CONTEXT = "project_context"
|
|
92
102
|
DECISION_HISTORY = "decision_history"
|
|
103
|
+
# Topics that come up a lot in this user's memories. Deliberately separate
|
|
104
|
+
# from TECH_PREFERENCE: a frequent word is not a tooling choice.
|
|
105
|
+
TOPIC_INTEREST = "topic_interest"
|
|
93
106
|
AVOIDANCE = "avoidance"
|
|
94
107
|
CUSTOM = "custom"
|
|
95
108
|
|
|
@@ -53,6 +53,11 @@ CATEGORY_TEMPLATES: dict[str, str] = {
|
|
|
53
53
|
"Current active project: {project_name}. "
|
|
54
54
|
"Key context: {context_summary}."
|
|
55
55
|
),
|
|
56
|
+
# Says only what is actually known — that these subjects recur — rather
|
|
57
|
+
# than inferring a preference, a project or a tool choice from them.
|
|
58
|
+
"topic_interest": (
|
|
59
|
+
"Subjects that come up often in the user's notes: {topics}."
|
|
60
|
+
),
|
|
56
61
|
"decision_history": (
|
|
57
62
|
"Recent key decisions: {decisions}. "
|
|
58
63
|
"These reflect the user's current direction."
|
|
@@ -75,6 +80,7 @@ CATEGORY_PRIORITY_ORDER: list[str] = [
|
|
|
75
80
|
"behavioral", # v3.4.7: behavioral assertions after communication style
|
|
76
81
|
"workflow_pattern",
|
|
77
82
|
"project_context",
|
|
83
|
+
"topic_interest",
|
|
78
84
|
"decision_history",
|
|
79
85
|
"avoidance",
|
|
80
86
|
]
|
|
@@ -105,6 +111,82 @@ class SoftPromptTemplate:
|
|
|
105
111
|
# SoftPromptGenerator class
|
|
106
112
|
# ---------------------------------------------------------------------------
|
|
107
113
|
|
|
114
|
+
def _empty_words() -> frozenset[str]:
|
|
115
|
+
"""Words that say nothing about a person when listed as a preference.
|
|
116
|
+
|
|
117
|
+
Read from the list the miner already filters on, so there is one definition
|
|
118
|
+
to extend rather than two that drift.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
from superlocalmemory.learning.pattern_miner_constants import STOPWORDS
|
|
122
|
+
|
|
123
|
+
return frozenset(STOPWORDS)
|
|
124
|
+
except Exception: # pragma: no cover — the check degrades to "keep it"
|
|
125
|
+
return frozenset()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_EMPTY_WORDS = _empty_words()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_substantive(category: str, values: dict[str, str]) -> bool:
|
|
132
|
+
"""Whether a rendered prompt says anything at all about the user.
|
|
133
|
+
|
|
134
|
+
Deliberately weak, and it got that way by being wrong in the other
|
|
135
|
+
direction first. The original version required a ``tech_preference`` claim to
|
|
136
|
+
name something from a fixed vocabulary of technologies — which discarded
|
|
137
|
+
``Node.js``, ``Git``, ``pip``, ``npm``, ``zig`` and every stack the list did
|
|
138
|
+
not happen to enumerate. Those were the GENUINE rows on a live store. A
|
|
139
|
+
filter that silently drops real preferences to catch fake ones is a worse
|
|
140
|
+
failure than the one it was added for, because nothing reports it.
|
|
141
|
+
|
|
142
|
+
What it was actually added for no longer arrives here. The live nonsense —
|
|
143
|
+
"preferred technology stack includes: test, gate, practices, compliance,
|
|
144
|
+
projects, while, their, processing, data" — came from word-frequency topics
|
|
145
|
+
being mapped onto this category, and they are now their own category, where
|
|
146
|
+
the same words form a true statement. This is the remaining floor: a prompt
|
|
147
|
+
built entirely out of words that appear in most English sentences says
|
|
148
|
+
nothing, whatever category it lands in.
|
|
149
|
+
|
|
150
|
+
Length is checked per TERM and only against that word list, never as a
|
|
151
|
+
minimum: "CTO", "AWS", "npm" and "R" are all shorter than a threshold would
|
|
152
|
+
allow and all mean something.
|
|
153
|
+
"""
|
|
154
|
+
filled = [
|
|
155
|
+
v.strip() for v in values.values()
|
|
156
|
+
if isinstance(v, str) and v.strip()
|
|
157
|
+
]
|
|
158
|
+
if not filled:
|
|
159
|
+
return False
|
|
160
|
+
terms = [
|
|
161
|
+
t.strip().lower()
|
|
162
|
+
for value in filled
|
|
163
|
+
for t in value.replace(";", ",").split(",")
|
|
164
|
+
if t.strip()
|
|
165
|
+
]
|
|
166
|
+
if not terms:
|
|
167
|
+
return False
|
|
168
|
+
return any(term not in _EMPTY_WORDS for term in terms)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fix_stutter(content: str) -> str:
|
|
172
|
+
"""Remove the duplicated conjunction where a template meets its value.
|
|
173
|
+
|
|
174
|
+
``"The user typically {workflow_description}"`` was rendering as "The user
|
|
175
|
+
typically When when using X" — the template supplies the lead-in and the
|
|
176
|
+
value already starts with its own. Observed on 19 of 34 stored prompts.
|
|
177
|
+
"""
|
|
178
|
+
import re as _re
|
|
179
|
+
|
|
180
|
+
for word in ("when", "typically", "prefers", "often", "usually"):
|
|
181
|
+
content = _re.sub(
|
|
182
|
+
rf"\b({word})\s+{word}\b", r"\1", content, flags=_re.IGNORECASE,
|
|
183
|
+
)
|
|
184
|
+
# "typically When when" collapses to "typically When"; then the lead-in and
|
|
185
|
+
# the value's own opener are adjacent duplicates of different case.
|
|
186
|
+
content = _re.sub(r"\btypically\s+When\b", "typically, when", content)
|
|
187
|
+
return content
|
|
188
|
+
|
|
189
|
+
|
|
108
190
|
class SoftPromptGenerator:
|
|
109
191
|
"""Convert extracted pattern assertions into natural language soft prompts.
|
|
110
192
|
|
|
@@ -243,12 +325,25 @@ class SoftPromptGenerator:
|
|
|
243
325
|
|
|
244
326
|
# Clean up
|
|
245
327
|
content = self._clean_content(content)
|
|
328
|
+
content = _fix_stutter(content)
|
|
246
329
|
|
|
247
330
|
# Filter PII
|
|
248
331
|
content = self._pii_filter.filter_text(content)
|
|
249
332
|
if not content.strip():
|
|
250
333
|
return None
|
|
251
334
|
|
|
335
|
+
# A prompt that says nothing must not be injected. These go into the
|
|
336
|
+
# model's context on every turn, so an empty claim is not neutral — it
|
|
337
|
+
# spends the budget and asserts something false. Measured on a live
|
|
338
|
+
# store: 15 of 34 stored prompts read "the user's preferred technology
|
|
339
|
+
# stack includes: data, processing, their, projects, test", built from
|
|
340
|
+
# common words that happened to appear near a technology keyword.
|
|
341
|
+
if not _is_substantive(category, values):
|
|
342
|
+
logger.debug(
|
|
343
|
+
"soft prompt for %r dropped: no substantive values", category,
|
|
344
|
+
)
|
|
345
|
+
return None
|
|
346
|
+
|
|
252
347
|
# Trim to 100 tokens per category
|
|
253
348
|
content = self._trim_to_tokens(content, 100)
|
|
254
349
|
|
|
@@ -305,6 +400,9 @@ class SoftPromptGenerator:
|
|
|
305
400
|
elif category == "workflow_pattern":
|
|
306
401
|
values["workflow_description"] = "; ".join(pat_values)
|
|
307
402
|
|
|
403
|
+
elif category == "topic_interest":
|
|
404
|
+
values["topics"] = ", ".join(pat_values)
|
|
405
|
+
|
|
308
406
|
elif category == "project_context":
|
|
309
407
|
values["project_name"] = pat_values[0] if pat_values else ""
|
|
310
408
|
values["context_summary"] = ", ".join(pat_values[1:])
|
|
@@ -54,6 +54,65 @@ def tokenize(text: str) -> list[str]:
|
|
|
54
54
|
return [t for t in tokens if t not in _STOPWORDS]
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
#: Saturation constant for the BM25 -> [0,1) transform. Chosen from measurement,
|
|
58
|
+
#: not taste: raw scores on a live store run ~2.5 to ~15 across query
|
|
59
|
+
#: lengths, so k = 5 puts an ordinary match near 0.4 and a strong one near 0.7,
|
|
60
|
+
#: leaving headroom at both ends rather than pinning everything to one corner.
|
|
61
|
+
_BM25_SATURATION = 5.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_unit_scale(scored: list[tuple[str, float]]) -> list[tuple[str, float]]:
|
|
65
|
+
"""Map BM25 scores into [0, 1) without disturbing order or magnitude.
|
|
66
|
+
|
|
67
|
+
WHY ANY OF THIS. ``engine.apply_channel_weights`` re-scores a candidate as
|
|
68
|
+
``sum(channel_scores[ch] * weights[ch])`` — a SUM of raw channel scores.
|
|
69
|
+
Every other channel is bounded: semantic cosine and Fisher-Rao are [0, 1],
|
|
70
|
+
the temporal proximity score is Gaussian on [0, 1]. BM25 is not. Measured on
|
|
71
|
+
a live store, one query at a time::
|
|
72
|
+
|
|
73
|
+
"slm release" max 2.845
|
|
74
|
+
"memory" max 3.865
|
|
75
|
+
"the release ships once both audits are clean" max 10.150
|
|
76
|
+
|
|
77
|
+
So the sum was decided by a scale rather than by evidence, and any weight
|
|
78
|
+
the bandit converged on was a correction for that scale — wrong the moment
|
|
79
|
+
the query length changed.
|
|
80
|
+
|
|
81
|
+
WHY NOT DIVIDE BY THE BATCH MAXIMUM. That was the first implementation here
|
|
82
|
+
and it is wrong, which an existing test caught before it shipped:
|
|
83
|
+
``test_real_fts5_exact_hit_keeps_bounded_slot`` exists to prove *"a real
|
|
84
|
+
sub-1.0 FTS5 hit remains visible under semantic pressure"*. Dividing by the
|
|
85
|
+
batch maximum makes the best result exactly 1.0 **whatever it scored**, so a
|
|
86
|
+
query with one weak lexical match reports full confidence and outranks a
|
|
87
|
+
semantic channel it should lose to. Batch-relative scaling manufactures
|
|
88
|
+
confidence out of an empty batch, and it also makes a fact's score depend on
|
|
89
|
+
which other facts happened to come back — the same query returning a
|
|
90
|
+
different number on a different day, which HARD-RULES RULE 6 puts above
|
|
91
|
+
speed.
|
|
92
|
+
|
|
93
|
+
A saturating transform has neither problem: ``s / (s + k)`` is strictly
|
|
94
|
+
increasing, so order is untouched; it is bounded below 1.0, so nothing is
|
|
95
|
+
ever certain; and it depends only on the score itself, so it is repeatable.
|
|
96
|
+
Applied to the measurements above: 2.676 -> 0.35, 2.845 -> 0.36,
|
|
97
|
+
3.865 -> 0.44, 10.150 -> 0.67.
|
|
98
|
+
|
|
99
|
+
FUSION IS UNAFFECTED, verified by reading ``fusion.weighted_rrf`` rather
|
|
100
|
+
than assuming: it computes ``fused += w / (k + rank)`` and keeps the score
|
|
101
|
+
only for reporting. Rescaling a value nothing divides by cannot move a
|
|
102
|
+
fused rank.
|
|
103
|
+
|
|
104
|
+
Non-positive scores map to 0.0. FTS5's ``bm25()`` is <= 0 and is negated
|
|
105
|
+
here, so a value at or below zero means "no lexical evidence", and that is
|
|
106
|
+
what it should contribute to a sum.
|
|
107
|
+
"""
|
|
108
|
+
if not scored:
|
|
109
|
+
return scored
|
|
110
|
+
return [
|
|
111
|
+
(fid, (s / (s + _BM25_SATURATION)) if s > 0.0 else 0.0)
|
|
112
|
+
for fid, s in scored
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
57
116
|
class BM25Channel:
|
|
58
117
|
"""Persistent BM25Plus index for keyword retrieval.
|
|
59
118
|
|
|
@@ -221,14 +280,10 @@ class BM25Channel:
|
|
|
221
280
|
include_shared=include_shared,
|
|
222
281
|
prefix="af",
|
|
223
282
|
)
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
archive_clause = (
|
|
228
|
-
" AND COALESCE(af.archive_status, 'live') != 'archived'"
|
|
229
|
-
if self._db._has_archive_status()
|
|
230
|
-
else ""
|
|
231
|
-
)
|
|
283
|
+
# Soft-deleted and withheld rows are not live. Filtered HERE rather
|
|
284
|
+
# than only at hydration so neither spends one of this channel's top_k
|
|
285
|
+
# slots; the shared clause keeps the definition in one place.
|
|
286
|
+
archive_clause = self._db.visible_fact_clause("af")
|
|
232
287
|
sql = (
|
|
233
288
|
"SELECT af.fact_id AS fact_id, bm25(atomic_facts_fts) AS rank "
|
|
234
289
|
"FROM atomic_facts_fts "
|
|
@@ -304,10 +359,10 @@ class BM25Channel:
|
|
|
304
359
|
# Falls back to rank_bm25 ONLY if the FTS5 table is genuinely
|
|
305
360
|
# unavailable (raises) — e.g. a pre-FTS legacy DB.
|
|
306
361
|
try:
|
|
307
|
-
return self._fts5_search(
|
|
362
|
+
return _to_unit_scale(self._fts5_search(
|
|
308
363
|
query, profile_id, top_k,
|
|
309
364
|
include_global=include_global, include_shared=include_shared,
|
|
310
|
-
)
|
|
365
|
+
))
|
|
311
366
|
except Exception as exc: # pragma: no cover — legacy/missing FTS table
|
|
312
367
|
logger.debug(
|
|
313
368
|
"BM25 FTS5 path unavailable, using rank_bm25 fallback: %s", exc,
|
|
@@ -342,7 +397,9 @@ class BM25Channel:
|
|
|
342
397
|
scored.append((self._fact_ids[i], bonus))
|
|
343
398
|
|
|
344
399
|
scored.sort(key=lambda x: (-x[1], x[0]))
|
|
345
|
-
|
|
400
|
+
# Same rescale as the FTS5 path. This fallback applies a 1.5x exact
|
|
401
|
+
# phrase bonus, so its raw ceiling is higher still.
|
|
402
|
+
return _to_unit_scale(scored[:top_k])
|
|
346
403
|
|
|
347
404
|
def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
|
|
348
405
|
"""Replace a fact's representation in the live index and persist new tokens.
|