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.
Files changed (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +357 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. 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
- S9-DASH-02: ``session_id`` threads through to ``engine.recall``
82
- so the outcome-queue gets a pending_outcomes row for this
83
- recall. Without it, hook-based signals have no outcome to
84
- attach to.
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