superlocalmemory 4.0.10 → 4.1.2
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 +12 -2
- package/CHANGELOG.md +244 -0
- package/README.md +40 -75
- package/package.json +6 -3
- 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 +357 -18
- 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 +24 -2
- 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/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_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- 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/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- 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/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- 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 +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/recall_serializer.py +9 -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/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/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +241 -7
- package/src/superlocalmemory/storage/_migration_internals.py +54 -2
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- 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 +119 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- 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 +35 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- 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 +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Move an existing store onto the graph and vector backends, by itself.
|
|
6
|
+
|
|
7
|
+
Cozo (graph) and LanceDB (vectors) have shipped as required dependencies since
|
|
8
|
+
3.7, and until now they sat unused: the projections were only built when
|
|
9
|
+
somebody ran `slm db scale prepare` and then `verify` and then `promote` by
|
|
10
|
+
hand. Almost nobody did, so almost every store kept answering graph and
|
|
11
|
+
similarity questions out of SQLite.
|
|
12
|
+
|
|
13
|
+
This runs that same sequence on the first start after an upgrade, so an existing
|
|
14
|
+
user gets the backends without doing anything.
|
|
15
|
+
|
|
16
|
+
WHAT IT WILL NOT DO
|
|
17
|
+
|
|
18
|
+
**It will not leave a store worse than it found it.** The lifecycle it drives
|
|
19
|
+
already stages, verifies against the canonical counts, and keeps a rollback —
|
|
20
|
+
this only decides when to call it. If verification does not match, nothing is
|
|
21
|
+
promoted and the store keeps answering from SQLite.
|
|
22
|
+
|
|
23
|
+
**It will not stop a store from working.** If the libraries are absent or fail
|
|
24
|
+
to import, or the projection cannot be built, the daemon serves from SQLite and
|
|
25
|
+
says so. Refusing to start would be a worse outcome than the one being fixed:
|
|
26
|
+
a user whose native extension does not match their interpreter would have no
|
|
27
|
+
product at all, and that is not a hypothetical — it was the state of the
|
|
28
|
+
author's own machine while this was written.
|
|
29
|
+
|
|
30
|
+
**It will not run twice.** Once the state is `promoted` there is nothing to do,
|
|
31
|
+
and a store mid-repair is left for the repair path rather than restarted.
|
|
32
|
+
|
|
33
|
+
WHAT IT COSTS, MEASURED
|
|
34
|
+
|
|
35
|
+
On a real 604 MB store — 5,283 memories, 137,104 edges, 395,107 links, 5,278
|
|
36
|
+
vectors — prepare took 10 s, verify 5 s, promote 5 s, and the two projections
|
|
37
|
+
occupy 16 MB each. It happens once.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
__all__ = ["AutoPromotionResult", "auto_promote_scale_backends"]
|
|
49
|
+
|
|
50
|
+
#: The two the projections need. Absence is a reason to stay on SQLite, never a
|
|
51
|
+
#: reason to fail.
|
|
52
|
+
REQUIRED_LIBRARIES = ("pycozo", "lancedb")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AutoPromotionResult:
|
|
57
|
+
"""What happened, in terms a status endpoint can show a person."""
|
|
58
|
+
|
|
59
|
+
attempted: bool
|
|
60
|
+
promoted: bool
|
|
61
|
+
reason: str
|
|
62
|
+
stage_id: str = ""
|
|
63
|
+
restart_required: bool = False
|
|
64
|
+
|
|
65
|
+
def as_dict(self) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"attempted": self.attempted,
|
|
68
|
+
"promoted": self.promoted,
|
|
69
|
+
"reason": self.reason,
|
|
70
|
+
"stage_id": self.stage_id,
|
|
71
|
+
"restart_required": self.restart_required,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _missing_libraries() -> list[str]:
|
|
76
|
+
import importlib
|
|
77
|
+
|
|
78
|
+
missing: list[str] = []
|
|
79
|
+
for name in REQUIRED_LIBRARIES:
|
|
80
|
+
try:
|
|
81
|
+
importlib.import_module(name)
|
|
82
|
+
except Exception: # noqa: BLE001 - any import failure means unusable
|
|
83
|
+
missing.append(name)
|
|
84
|
+
return missing
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resumable_stage(status: dict[str, Any]) -> str:
|
|
88
|
+
"""The newest stage already built and not yet promoted, if there is one.
|
|
89
|
+
|
|
90
|
+
A stage that is `prepared` or `verified` represents work already done
|
|
91
|
+
against the same store. Building another one repeats it and leaves the
|
|
92
|
+
first behind.
|
|
93
|
+
"""
|
|
94
|
+
usable = [
|
|
95
|
+
stage for stage in (status.get("stages") or [])
|
|
96
|
+
if str(stage.get("state")) in {"prepared", "verified"}
|
|
97
|
+
and stage.get("stage_id")
|
|
98
|
+
]
|
|
99
|
+
if not usable:
|
|
100
|
+
return ""
|
|
101
|
+
usable.sort(key=lambda stage: str(stage.get("created_at", "")))
|
|
102
|
+
return str(usable[-1]["stage_id"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def auto_promote_scale_backends(config: Any) -> AutoPromotionResult:
|
|
106
|
+
"""Build and promote the projections if this store has not got them yet.
|
|
107
|
+
|
|
108
|
+
Returns what happened rather than raising: every outcome here is a state the
|
|
109
|
+
daemon carries on from, and the caller shows it.
|
|
110
|
+
"""
|
|
111
|
+
state = str(getattr(config, "scale_engine_state", "") or "local_core").lower()
|
|
112
|
+
if state == "promoted":
|
|
113
|
+
return AutoPromotionResult(False, True, "already promoted")
|
|
114
|
+
|
|
115
|
+
# A switch that exists and is honoured elsewhere is a switch this must
|
|
116
|
+
# honour too. The background path has read it since 3.7; starting to move a
|
|
117
|
+
# user's store on a setting that says not to would be worse than never
|
|
118
|
+
# having automated it.
|
|
119
|
+
if not bool(getattr(config, "scale_auto_promote_enabled", True)):
|
|
120
|
+
return AutoPromotionResult(
|
|
121
|
+
False, False, "automatic promotion is switched off in the configuration",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
missing = _missing_libraries()
|
|
125
|
+
if missing:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"graph and vector backends unavailable (%s will not import); "
|
|
128
|
+
"serving from SQLite", ", ".join(missing),
|
|
129
|
+
)
|
|
130
|
+
return AutoPromotionResult(
|
|
131
|
+
False, False, f"{', '.join(missing)} will not import",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
from superlocalmemory.core.scale_engine import ScaleEngineManager
|
|
136
|
+
except Exception as exc: # noqa: BLE001
|
|
137
|
+
return AutoPromotionResult(False, False, f"scale engine unavailable: {exc}")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
manager = ScaleEngineManager(config)
|
|
141
|
+
except Exception as exc: # noqa: BLE001
|
|
142
|
+
return AutoPromotionResult(False, False, f"could not open the store: {exc}")
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
status = manager.status()
|
|
146
|
+
except Exception as exc: # noqa: BLE001
|
|
147
|
+
return AutoPromotionResult(False, False, f"could not read the state: {exc}")
|
|
148
|
+
|
|
149
|
+
if status.get("migration_repair_required"):
|
|
150
|
+
# An interrupted promotion has its own recovery path, and starting a
|
|
151
|
+
# fresh one on top of it would be building over a half-finished move.
|
|
152
|
+
logger.warning(
|
|
153
|
+
"a previous promotion did not finish; leaving it for repair rather "
|
|
154
|
+
"than starting another",
|
|
155
|
+
)
|
|
156
|
+
return AutoPromotionResult(False, False, "a previous promotion needs repair")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
# Resume a stage that is already built rather than building another.
|
|
160
|
+
# Only "promoted" used to stop this, so a start that prepared and then
|
|
161
|
+
# failed to verify left the state at "prepared" and the NEXT start
|
|
162
|
+
# built a second stage — and the one after that a third, with the
|
|
163
|
+
# staging directory growing every time.
|
|
164
|
+
stage_id = _resumable_stage(status)
|
|
165
|
+
if stage_id:
|
|
166
|
+
logger.info("resuming the projection already staged as %s", stage_id)
|
|
167
|
+
else:
|
|
168
|
+
prepared = manager.prepare()
|
|
169
|
+
stage_id = str(prepared.get("stage_id", ""))
|
|
170
|
+
verified = manager.verify(stage_id)
|
|
171
|
+
if str(verified.get("state")) != "verified":
|
|
172
|
+
logger.warning(
|
|
173
|
+
"projection did not match the canonical store; not promoting",
|
|
174
|
+
)
|
|
175
|
+
return AutoPromotionResult(
|
|
176
|
+
True, False, "the projection did not match the store", stage_id,
|
|
177
|
+
)
|
|
178
|
+
promoted = manager.promote(stage_id)
|
|
179
|
+
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
|
180
|
+
logger.warning(
|
|
181
|
+
"could not move onto the graph and vector backends (%s); serving "
|
|
182
|
+
"from SQLite", exc,
|
|
183
|
+
)
|
|
184
|
+
return AutoPromotionResult(True, False, str(exc))
|
|
185
|
+
|
|
186
|
+
logger.info(
|
|
187
|
+
"graph and vector backends promoted (stage %s); they serve after the "
|
|
188
|
+
"next start", promoted.get("stage_id", ""),
|
|
189
|
+
)
|
|
190
|
+
return AutoPromotionResult(
|
|
191
|
+
True,
|
|
192
|
+
True,
|
|
193
|
+
"promoted",
|
|
194
|
+
str(promoted.get("stage_id", "")),
|
|
195
|
+
bool(promoted.get("restart_required", True)),
|
|
196
|
+
)
|
|
@@ -555,10 +555,24 @@ class ScaleEngineManager:
|
|
|
555
555
|
projects them: dedup entity IDs per fact, drop empties. This bridge is
|
|
556
556
|
what lets Cozo map a query seed into the fact graph; if its import
|
|
557
557
|
silently fails, count parity on entities/edges/vectors still passes but
|
|
558
|
-
entity recall returns empty — so it must be verified explicitly.
|
|
558
|
+
entity recall returns empty — so it must be verified explicitly.
|
|
559
|
+
|
|
560
|
+
Withheld and archived facts are excluded, because the import excludes
|
|
561
|
+
them. On this store that is not a rounding difference: 1,291 withheld
|
|
562
|
+
facts hold 379,591 of the 395,524 bridge rows — about 294 entity
|
|
563
|
+
references each, against roughly 4 for an ordinary memory. That is the
|
|
564
|
+
"pooled entity list" the retrieval channel refuses to let into its entity
|
|
565
|
+
map, and it is why a withheld row out-ranks a real one. Counting them
|
|
566
|
+
here made the parity check demand a projection the import must not build,
|
|
567
|
+
and promotion failed on it."""
|
|
568
|
+
from superlocalmemory.storage.database import (
|
|
569
|
+
visible_fact_clause_for_connection,
|
|
570
|
+
)
|
|
571
|
+
|
|
559
572
|
total = 0
|
|
560
573
|
for (raw,) in conn.execute(
|
|
561
|
-
"SELECT canonical_entities_json FROM atomic_facts WHERE profile_id=?"
|
|
574
|
+
"SELECT canonical_entities_json FROM atomic_facts WHERE profile_id=?"
|
|
575
|
+
+ visible_fact_clause_for_connection(conn),
|
|
562
576
|
(self.profile_id,),
|
|
563
577
|
):
|
|
564
578
|
try:
|
|
@@ -27,7 +27,27 @@ def _bounded(value: object, default: float = 0.0) -> float:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def finalize_score_contract(response: RecallResponse) -> RecallResponse:
|
|
30
|
-
"""Finalize aliases, rank positions, and response abstention metadata.
|
|
30
|
+
"""Finalize aliases, rank positions, and response abstention metadata.
|
|
31
|
+
|
|
32
|
+
``score`` IS NOT THE ORDERING KEY, and a caller that sorts by it will get a
|
|
33
|
+
different answer than the one this returns. That is deliberate, and it is
|
|
34
|
+
load-bearing:
|
|
35
|
+
|
|
36
|
+
* ``score`` answers "how well does this match the query" — a property of the
|
|
37
|
+
result and the query alone.
|
|
38
|
+
* ``rank_position``, assigned here from the order of the list, answers "what
|
|
39
|
+
is recommended, and in what order". Ranking also weighs whether a memory
|
|
40
|
+
has helped before and whether the session was just looking at it, neither
|
|
41
|
+
of which is a property of the query.
|
|
42
|
+
|
|
43
|
+
So the two can disagree, and the top result can legitimately carry a lower
|
|
44
|
+
``score`` than the one beneath it. Consumers should present the list in the
|
|
45
|
+
order given, or sort by ``rank_position``; ``ranking_score`` carries the
|
|
46
|
+
internal utility for anyone who needs to re-derive it.
|
|
47
|
+
|
|
48
|
+
This ordering is NOT recomputed here. Re-sorting by ``score`` at this point
|
|
49
|
+
would silently undo every ranking pass that ran before it.
|
|
50
|
+
"""
|
|
31
51
|
for position, result in enumerate(response.results or (), start=1):
|
|
32
52
|
relevance = _bounded(
|
|
33
53
|
result.relevance_score
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Which session ids name a conversation, and which were invented for one call.
|
|
5
|
+
|
|
6
|
+
TWO DIFFERENT JOBS, ONE STRING
|
|
7
|
+
------------------------------
|
|
8
|
+
A session id is used for two unrelated things:
|
|
9
|
+
|
|
10
|
+
1. **Bookkeeping.** A recall needs *some* handle so a downstream reference can be
|
|
11
|
+
traced back to it. Any unique string does. When a caller supplies none, the
|
|
12
|
+
HTTP and MCP fronts invent one — ``http:<milliseconds>`` per request, or
|
|
13
|
+
``mcp:<agent_id>`` per client.
|
|
14
|
+
|
|
15
|
+
2. **Continuity.** The working set carries what a conversation was recently
|
|
16
|
+
shown into its next turn. That needs an id that actually identifies a
|
|
17
|
+
conversation, because it decides what gets ranked higher.
|
|
18
|
+
|
|
19
|
+
An invented id is fine for the first and wrong for the second, and the two are
|
|
20
|
+
wrong in opposite directions:
|
|
21
|
+
|
|
22
|
+
* ``http:<ms>`` is unique per request, so every dashboard search registered a
|
|
23
|
+
new working set. Enough of them and the registry hits its cap and evicts the
|
|
24
|
+
least-recently-touched entry — which is a real conversation sitting idle
|
|
25
|
+
between turns. The next turn of that conversation is cold, with no error.
|
|
26
|
+
* ``mcp:<agent_id>`` is SHARED by every client that did not send an id, so two
|
|
27
|
+
unrelated clients pooled one seven-slot set and promoted each other's
|
|
28
|
+
memories.
|
|
29
|
+
|
|
30
|
+
Neither mattered while the parameter was ignored, which it was until continuity
|
|
31
|
+
was built on it. That is what made this easy to miss.
|
|
32
|
+
|
|
33
|
+
ONE DEFINITION
|
|
34
|
+
--------------
|
|
35
|
+
Both the creators and the reader use this module, so a new front cannot invent a
|
|
36
|
+
third prefix that continuity silently accepts. Two lists of the same thing is how
|
|
37
|
+
one ends up wrong.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"SYNTHETIC_PREFIXES",
|
|
44
|
+
"is_conversation",
|
|
45
|
+
"synthetic_session_id",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
#: Prefixes marking an id that a front invented rather than received.
|
|
49
|
+
#:
|
|
50
|
+
#: The colon is deliberate: a real client id is a uuid or a hex string and does
|
|
51
|
+
#: not contain one, so a genuine id cannot be mistaken for an invented one.
|
|
52
|
+
SYNTHETIC_PREFIXES: tuple[str, ...] = ("http:", "mcp:", "cli:", "probe:")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def synthetic_session_id(kind: str, discriminator: str = "") -> str:
|
|
56
|
+
"""Build an id for bookkeeping that continuity will correctly ignore.
|
|
57
|
+
|
|
58
|
+
``kind`` names the front that invented it, so a log line says where an
|
|
59
|
+
unattributed recall came from.
|
|
60
|
+
"""
|
|
61
|
+
prefix = kind if kind.endswith(":") else f"{kind}:"
|
|
62
|
+
return f"{prefix}{discriminator}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_conversation(
|
|
66
|
+
session_id: str | None, profile_id: str | None = None,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Whether this pair identifies a conversation across turns.
|
|
69
|
+
|
|
70
|
+
False for an empty id and for anything a front invented. Continuity engages
|
|
71
|
+
only when this is True, so the default for an unidentified caller is the
|
|
72
|
+
behaviour that existed before continuity: every recall starts cold.
|
|
73
|
+
|
|
74
|
+
``profile_id`` is checked too when given. The working set is keyed on both,
|
|
75
|
+
and an empty profile is not a profile: ``None`` and ``""`` would normalise to
|
|
76
|
+
the same key, so two unidentified callers sharing a session id would share
|
|
77
|
+
one set. Every caller in this codebase resolves a real profile before
|
|
78
|
+
reaching here, which is exactly why the aliasing would go unnoticed if it
|
|
79
|
+
ever stopped being true.
|
|
80
|
+
"""
|
|
81
|
+
if not session_id:
|
|
82
|
+
return False
|
|
83
|
+
if profile_id is not None and not profile_id:
|
|
84
|
+
return False
|
|
85
|
+
return not session_id.startswith(SYNTHETIC_PREFIXES)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""What "status" means, in one place, for every surface that answers it.
|
|
6
|
+
|
|
7
|
+
Three surfaces answered the same question — MCP ``get_status``, the HTTP
|
|
8
|
+
dashboard and ``slm status`` — and returned three different field sets. Two of
|
|
9
|
+
them omitted the graph counts, which are the numbers that say whether the graph
|
|
10
|
+
is healthy; one omitted the version, which is the first thing anyone asks for
|
|
11
|
+
in a bug report. Each carried its own copy of the same three COUNT queries.
|
|
12
|
+
|
|
13
|
+
This module holds the agreed field set and the queries behind it. A surface may
|
|
14
|
+
add fields of its own — the dashboard needs a display name for the mode, the
|
|
15
|
+
daemon needs its own pid and uptime — but it may not be missing one of these.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sqlite3
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
#: Fields every status surface emits. A surface with extra fields is fine; a
|
|
24
|
+
#: surface missing one of these is a defect, and a test asserts it.
|
|
25
|
+
CANONICAL_STATUS_FIELDS: tuple[str, ...] = (
|
|
26
|
+
"mode",
|
|
27
|
+
"provider",
|
|
28
|
+
"profile",
|
|
29
|
+
"base_dir",
|
|
30
|
+
"db_path",
|
|
31
|
+
"db_size_mb",
|
|
32
|
+
"fact_count",
|
|
33
|
+
"entity_count",
|
|
34
|
+
"edge_count",
|
|
35
|
+
"profile_generation",
|
|
36
|
+
"version",
|
|
37
|
+
"projection_queue_depth",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: The counts, and the one query each comes from. Profile-scoped without
|
|
41
|
+
#: exception — a count that ignores the active profile is a wrong answer on any
|
|
42
|
+
#: machine with more than one workspace.
|
|
43
|
+
#:
|
|
44
|
+
#: ``fact_count`` is completed at call time with the visibility predicate: it is
|
|
45
|
+
#: the number an owner reads as "how much do I remember", so soft-deleted and
|
|
46
|
+
#: withheld rows must not be in it. Counting them raw is how the dashboard came
|
|
47
|
+
#: to report 5,317 against 4,018 real memories on the author's store.
|
|
48
|
+
COUNT_QUERIES: dict[str, str] = {
|
|
49
|
+
"fact_count": "SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
|
|
50
|
+
"entity_count": "SELECT COUNT(*) FROM canonical_entities WHERE profile_id = ?",
|
|
51
|
+
"edge_count": "SELECT COUNT(*) FROM graph_edges WHERE profile_id = ?",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def counts_from_sqlite(conn: sqlite3.Connection, profile_id: str) -> dict[str, int]:
|
|
56
|
+
"""Every count on the contract, from one connection.
|
|
57
|
+
|
|
58
|
+
A table that is not there yet — a store mid-migration, or one built before
|
|
59
|
+
the graph existed — reports zero rather than failing the whole status call.
|
|
60
|
+
A status endpoint that raises tells an operator nothing.
|
|
61
|
+
"""
|
|
62
|
+
from superlocalmemory.storage.database import visible_fact_clause_for_connection
|
|
63
|
+
|
|
64
|
+
counts: dict[str, int] = {}
|
|
65
|
+
for field, sql in COUNT_QUERIES.items():
|
|
66
|
+
if field == "fact_count":
|
|
67
|
+
sql += visible_fact_clause_for_connection(conn)
|
|
68
|
+
try:
|
|
69
|
+
row = conn.execute(sql, (profile_id,)).fetchone()
|
|
70
|
+
counts[field] = int(row[0]) if row else 0
|
|
71
|
+
except sqlite3.Error:
|
|
72
|
+
counts[field] = 0
|
|
73
|
+
return counts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def projection_queue_depth(conn: sqlite3.Connection) -> int:
|
|
77
|
+
"""Facts stored but not yet carried into the graph and vector projections.
|
|
78
|
+
|
|
79
|
+
Zero is the healthy steady state. A number that does not fall means the
|
|
80
|
+
projections have stopped keeping up with the store, which is the one failure
|
|
81
|
+
that would otherwise be invisible: the memory is safely in SQLite, so
|
|
82
|
+
nothing errors, and it is simply missing from the graph until someone
|
|
83
|
+
notices recall got worse.
|
|
84
|
+
|
|
85
|
+
Deliberately NOT profile-scoped. The worker is one queue for the whole
|
|
86
|
+
store, and a projection stalled on another workspace's facts is still a
|
|
87
|
+
stalled projection. Scoping it per profile would let a status page report
|
|
88
|
+
zero while the drain was wedged.
|
|
89
|
+
"""
|
|
90
|
+
from superlocalmemory.storage.projection_outbox import DEPTH_SQL
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
row = conn.execute(DEPTH_SQL).fetchone()
|
|
94
|
+
except sqlite3.Error:
|
|
95
|
+
# A store that predates the queue has nothing pending by definition.
|
|
96
|
+
return 0
|
|
97
|
+
return int(row[0]) if row else 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def store_size_mb(db_path: Path | str | None) -> float:
|
|
101
|
+
"""Size of the store on disk, or 0.0 when there is nothing to measure."""
|
|
102
|
+
if not db_path:
|
|
103
|
+
return 0.0
|
|
104
|
+
path = Path(db_path)
|
|
105
|
+
try:
|
|
106
|
+
return round(path.stat().st_size / (1024 * 1024), 2)
|
|
107
|
+
except OSError:
|
|
108
|
+
return 0.0
|
|
@@ -78,10 +78,10 @@ class WorkerPool:
|
|
|
78
78
|
) -> dict:
|
|
79
79
|
"""Run recall in worker subprocess. Returns result dict.
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
``session_id`` threads through to ``engine.recall``, where it gives
|
|
82
|
+
the recall continuity with earlier turns of the same session. It does
|
|
83
|
+
NOT create an outcome row — this docstring claimed one for several
|
|
84
|
+
releases and none was ever written.
|
|
85
85
|
|
|
86
86
|
v3.6.15 multi-scope: ``include_global``/``include_shared`` are forwarded
|
|
87
87
|
to the worker (and on to ``engine.recall``). ``None`` is sent verbatim
|