superlocalmemory 4.0.7 → 4.0.9

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 (142) hide show
  1. package/CHANGELOG.md +219 -1
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/scripts/ensure-venv.sh +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 +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +3 -3
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-status/SKILL.md +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/postinstall.js +4 -0
  28. package/src/superlocalmemory/__init__.py +1 -1
  29. package/src/superlocalmemory/cli/_lazy_init.py +1 -1
  30. package/src/superlocalmemory/cli/commands.py +119 -9
  31. package/src/superlocalmemory/cli/db_migrate.py +0 -2
  32. package/src/superlocalmemory/cli/gdpr_io.py +1 -1
  33. package/src/superlocalmemory/cli/main.py +5 -5
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/cli/summary_cmd.py +23 -3
  37. package/src/superlocalmemory/code_graph/bridge/maintenance.py +7 -1
  38. package/src/superlocalmemory/core/config.py +41 -7
  39. package/src/superlocalmemory/core/consolidation_engine.py +14 -15
  40. package/src/superlocalmemory/core/context_cache.py +0 -2
  41. package/src/superlocalmemory/core/engine.py +371 -63
  42. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  43. package/src/superlocalmemory/core/install_detector.py +131 -0
  44. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  45. package/src/superlocalmemory/core/recall_worker.py +4 -0
  46. package/src/superlocalmemory/core/security_primitives.py +3 -6
  47. package/src/superlocalmemory/core/store_pipeline.py +94 -26
  48. package/src/superlocalmemory/core/topic_signature.py +0 -2
  49. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  50. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  51. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  52. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  53. package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
  54. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  55. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  56. package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
  57. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  58. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  59. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  60. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  61. package/src/superlocalmemory/infra/backup.py +44 -8
  62. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  63. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  64. package/src/superlocalmemory/learning/bandit.py +0 -2
  65. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  66. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  67. package/src/superlocalmemory/learning/ensemble.py +0 -2
  68. package/src/superlocalmemory/learning/labeler.py +0 -2
  69. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  70. package/src/superlocalmemory/learning/model_cache.py +0 -2
  71. package/src/superlocalmemory/learning/pattern_miner.py +12 -7
  72. package/src/superlocalmemory/learning/ranker.py +0 -2
  73. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  74. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  75. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  76. package/src/superlocalmemory/math/fisher.py +1 -1
  77. package/src/superlocalmemory/math/hopfield.py +4 -1
  78. package/src/superlocalmemory/math/langevin.py +1 -1
  79. package/src/superlocalmemory/math/sheaf.py +7 -3
  80. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  81. package/src/superlocalmemory/mcp/profiles.py +11 -4
  82. package/src/superlocalmemory/mcp/server.py +8 -1
  83. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  84. package/src/superlocalmemory/mcp/tools_core.py +1 -1
  85. package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
  86. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  87. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  88. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  89. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  90. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  92. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  93. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  94. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
  96. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  97. package/src/superlocalmemory/retrieval/engine.py +272 -43
  98. package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
  99. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  100. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  101. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  102. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  103. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  105. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  106. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  107. package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
  108. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  109. package/src/superlocalmemory/retrieval/vector_store.py +88 -10
  110. package/src/superlocalmemory/server/consolidation_runner.py +140 -0
  111. package/src/superlocalmemory/server/recall_serializer.py +44 -2
  112. package/src/superlocalmemory/server/routes/agents.py +52 -8
  113. package/src/superlocalmemory/server/routes/brain.py +110 -2
  114. package/src/superlocalmemory/server/routes/memories.py +153 -0
  115. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  116. package/src/superlocalmemory/server/routes/v3_api.py +24 -46
  117. package/src/superlocalmemory/server/unified_daemon.py +566 -7
  118. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  119. package/src/superlocalmemory/storage/backup.py +531 -0
  120. package/src/superlocalmemory/storage/database.py +11 -4
  121. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  122. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +142 -2
  124. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  125. package/src/superlocalmemory/storage/migrations.py +15 -1
  126. package/src/superlocalmemory/storage/models.py +7 -0
  127. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  128. package/src/superlocalmemory/summaries/base.py +159 -0
  129. package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
  130. package/src/superlocalmemory/summaries/project_work_log.py +23 -7
  131. package/src/superlocalmemory/summaries/session_summary.py +10 -6
  132. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  133. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  134. package/src/superlocalmemory/ui/index.html +9 -3
  135. package/src/superlocalmemory/ui/js/core.js +1 -1
  136. package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
  137. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  138. package/src/superlocalmemory/ui/js/od-memories.js +337 -12
  139. package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
  140. package/src/superlocalmemory/ui/js/od-operations.js +1 -150
  141. package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
  142. package/src/superlocalmemory/ui/js/od-shell.js +10 -0
@@ -0,0 +1,140 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory | https://qualixar.com
4
+
5
+ """One implementation of full consolidation, shared by every trigger.
6
+
7
+ WHY THIS EXISTS
8
+ ---------------
9
+ Steps 8-11 of ``ConsolidationEngine.consolidate()`` — behavioural assertion
10
+ mining, soft prompts, skill performance, skill evolution — only run on the
11
+ ``lightweight=False`` path. Before 4.0.8 **nothing automatic ever took that
12
+ path**:
13
+
14
+ * ``consolidation_engine.py`` self-triggers with ``lightweight=True`` only.
15
+ * The session-end hook shelled out to ``slm consolidate --cognitive``, which
16
+ runs ``CognitiveConsolidator.run_pipeline()`` — a different class that does
17
+ not contain steps 8-11 at all.
18
+ * ``POST /consolidation/trigger`` did run the full path, but only when a human
19
+ called it.
20
+
21
+ The measurable consequence on a real store: ``behavioral_assertions`` sat at 0
22
+ rows while the miner, run once by hand against the same data, produced 9
23
+ assertions immediately. The Behaviour tab was empty because the miner had never
24
+ executed, not because there was nothing to mine.
25
+
26
+ DESIGN
27
+ ------
28
+ Two triggers, one implementation, one lock:
29
+
30
+ * the daemon's periodic timer (the correctness guarantee), and
31
+ * the session-end hook posting to ``/consolidation/trigger`` (the fast path).
32
+
33
+ ``_LOCK`` serialises them. A second trigger arriving while one is running is
34
+ **skipped, not queued** — consolidation is idempotent catch-up work, so running
35
+ it twice back to back buys nothing and doubles the write pressure.
36
+
37
+ HOT PATH
38
+ --------
39
+ Never called from store or recall. The engine call is CPU/IO bound for seconds
40
+ to minutes, so it runs inside ``asyncio.to_thread`` — blocking a worker thread
41
+ is fine, blocking the event loop is not. The periodic trigger additionally
42
+ refuses to start unless the daemon has been idle (see ``unified_daemon``), so
43
+ scheduled consolidation cannot land in the middle of a burst of remember/recall
44
+ traffic.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import asyncio
50
+ import logging
51
+ from typing import Any
52
+
53
+ logger = logging.getLogger("superlocalmemory.consolidation_runner")
54
+
55
+ #: Serialises the timer and the hook. Module-level: there is one daemon.
56
+ _LOCK = asyncio.Lock()
57
+
58
+
59
+ def is_running() -> bool:
60
+ """True when a consolidation pass currently holds the lock."""
61
+ return _LOCK.locked()
62
+
63
+
64
+ def _consolidate_blocking(app_state: Any, profile_id: str, lightweight: bool) -> dict:
65
+ """Run the engine synchronously. Caller must put this in a thread.
66
+
67
+ Lifted verbatim from ``POST /consolidation/trigger`` so the endpoint and the
68
+ timer cannot drift apart — two copies of this would be two different
69
+ definitions of "consolidated".
70
+ """
71
+ from superlocalmemory.core.config import SLMConfig
72
+ from superlocalmemory.core.consolidation_engine import ConsolidationEngine
73
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
74
+ from superlocalmemory.storage import schema as _schema
75
+ from superlocalmemory.storage.database import DatabaseManager
76
+
77
+ runtime = get_profile_runtime(app_state)
78
+ # Rule 18: hold the operation lease so a concurrent profile switch cannot
79
+ # commit halfway through a consolidation.
80
+ with runtime.operation():
81
+ config = SLMConfig.load()
82
+ db = DatabaseManager(config.db_path)
83
+ db.initialize(_schema)
84
+ engine = ConsolidationEngine(
85
+ db=db, config=config.consolidation, slm_config=config,
86
+ )
87
+ res = engine.consolidate(profile_id=profile_id, lightweight=lightweight)
88
+
89
+ # Behavioural pattern mining writes learning.db rather than memory.db, so
90
+ # it sits outside the engine. Kept non-fatal: a pattern-mining failure
91
+ # must not discard a completed consolidation.
92
+ try:
93
+ from superlocalmemory.learning.consolidation_worker import (
94
+ ConsolidationWorker,
95
+ )
96
+ learning_db = config.base_dir / "learning.db"
97
+ cw = ConsolidationWorker(str(config.db_path), str(learning_db))
98
+ res["patterns_mined"] = cw._generate_patterns(profile_id, False)
99
+ except Exception as exc:
100
+ logger.warning("pattern mining after consolidation failed: %s", exc)
101
+ # -1, not 0: "it broke" and "there was nothing to mine" are
102
+ # different answers and the dashboard must not conflate them.
103
+ res["patterns_mined"] = -1
104
+ return res
105
+
106
+
107
+ async def run_full_consolidation(
108
+ app_state: Any,
109
+ profile_id: str,
110
+ *,
111
+ lightweight: bool = False,
112
+ trigger: str = "manual",
113
+ ) -> dict:
114
+ """Run one consolidation pass, or skip if one is already in flight.
115
+
116
+ Returns the engine result dict, plus ``trigger``. When a pass is already
117
+ running the result is ``{"skipped": True, "reason": "already running"}`` —
118
+ an explicit skip rather than a silent no-op, so a caller can tell "did not
119
+ need to run" from "ran and found nothing".
120
+ """
121
+ if _LOCK.locked():
122
+ logger.info("consolidation skipped (%s): a pass is already running", trigger)
123
+ return {"skipped": True, "reason": "already running", "trigger": trigger}
124
+
125
+ async with _LOCK:
126
+ logger.info(
127
+ "consolidation starting (trigger=%s, profile=%s, lightweight=%s)",
128
+ trigger, profile_id, lightweight,
129
+ )
130
+ result = await asyncio.to_thread(
131
+ _consolidate_blocking, app_state, profile_id, lightweight,
132
+ )
133
+ logger.info(
134
+ "consolidation finished (trigger=%s): assertions=%s patterns=%s",
135
+ trigger,
136
+ (result.get("assertions") or {}).get("created", "n/a"),
137
+ result.get("patterns_mined", "n/a"),
138
+ )
139
+ result["trigger"] = trigger
140
+ return result
@@ -170,6 +170,7 @@ def serialize_recall_response(
170
170
  total_max: int = 12000,
171
171
  full: bool = False,
172
172
  include_source: bool = False,
173
+ include_marker: bool = False,
173
174
  ) -> tuple[list[dict], bool]:
174
175
  """Convert a RecallResponse into budgeted, source-disciplined dicts.
175
176
 
@@ -185,10 +186,33 @@ def serialize_recall_response(
185
186
  total_max: Total content char budget before stubs (config-driven).
186
187
  full: Bypass clamping/stubs (additive escape hatch).
187
188
  include_source: Return full source_content (else ≤280-char preview).
189
+ include_marker: Emit each result's HMAC usage marker. See below.
188
190
 
189
191
  Returns:
190
192
  (results, no_confident_match) — results is a list of dicts; the bool
191
193
  is the evidence-floor signal lifted from the response (additive).
194
+
195
+ THE MARKER, AND WHY IT WAS MISSING
196
+ ----------------------------------
197
+ ``run_recall`` sets ``result.marker`` on every result — an HMAC of the
198
+ fact id, computed on the hot path already. Until 4.0.8 **no serialiser
199
+ ever read it**, so the value was computed and discarded on every recall.
200
+
201
+ That one omission broke the entire closed learning loop. The
202
+ ``post_tool_outcome`` hook settles an outcome by finding a validated
203
+ ``slm:fact:<id>:<hmac8>`` marker in a later tool response; with markers
204
+ never reaching the agent it found nothing, every outcome settled at the
205
+ formula's 0.5 base, and the consequences were visible all the way out to
206
+ the dashboard: 162 outcomes at the default label, all 294 source-quality
207
+ observations at exactly 0.5, therefore ``alpha == beta`` for all 18
208
+ sources and "no quality signal has settled", and 165 bandit arms with 4
209
+ plays between them.
210
+
211
+ Off by default, and gated by the caller on ``session_id``. A marker costs
212
+ roughly 33 characters of the agent's context per result, and it can only
213
+ buy a signal when a ``pending_outcomes`` row exists to settle — which
214
+ happens only for session-bearing recalls. Spending context on an ad-hoc
215
+ recall that could never learn from it is pure waste.
192
216
  """
193
217
  memory_map = memory_map or {}
194
218
  # T-inject: one shared "now" so every result's age label is consistent.
@@ -200,7 +224,7 @@ def serialize_recall_response(
200
224
  _created = getattr(fact, "created_at", "") or ""
201
225
  fact_type = getattr(fact, "fact_type", None)
202
226
  lifecycle = getattr(fact, "lifecycle", None)
203
- raw.append({
227
+ entry = {
204
228
  "fact_id": fact.fact_id,
205
229
  "memory_id": fact.memory_id,
206
230
  "content": fact.content or "",
@@ -235,7 +259,15 @@ def serialize_recall_response(
235
259
  # weigh recency without doing date math. "" when undated.
236
260
  "age_label": relative_age(_created, _now),
237
261
  "evidence_chain": list(getattr(r, "evidence_chain", []) or []),
238
- })
262
+ }
263
+ # Only when asked, and only when the engine actually produced one —
264
+ # an empty key would be indistinguishable from a marker that failed
265
+ # to compute, and the hook validates before trusting anything anyway.
266
+ if include_marker:
267
+ marker = getattr(r, "marker", "") or ""
268
+ if marker:
269
+ entry["marker"] = marker
270
+ raw.append(entry)
239
271
 
240
272
  # F-3 source discipline, then F-2 budget — order matters (discipline first
241
273
  # so the template firewall runs before any preview slicing).
@@ -266,4 +298,14 @@ def recall_response_metadata(response: Any) -> dict:
266
298
  # Q2b: thematic community summary (pure pass-through; computed upstream
267
299
  # in the engine where DB access is available). None on most recalls.
268
300
  "thematic_context": getattr(response, "community_context", None),
301
+ # Channels abandoned at the hang guard, so their candidates are absent
302
+ # from this answer. Empty on a healthy recall, which is the normal case.
303
+ # Non-empty is the one situation in which asking the same question twice
304
+ # may legitimately give different answers, so it has to travel with the
305
+ # response rather than living only in a server log — otherwise a caller
306
+ # comparing two runs has no way to tell an incomplete answer from a
307
+ # changed one. A list, because JSON has no tuple.
308
+ "incomplete_channels": list(
309
+ getattr(response, "incomplete_channels", ()) or ()
310
+ ),
269
311
  }
@@ -106,24 +106,68 @@ async def get_agent_memory_activity(
106
106
  conn = get_read_connection(DB_PATH)
107
107
  try:
108
108
  try:
109
- rows = conn.execute(
110
- "SELECT CASE WHEN trusted_actor_id='' THEN 'unknown' "
111
- "ELSE trusted_actor_id END AS agent_id, "
109
+ # Group by the agent's OWN identity, not by the capability that
110
+ # authorised the write.
111
+ #
112
+ # This grouped by ``trusted_actor_id``, which on a real store is
113
+ # a capability digest — 43 distinct agents rendered as 43 rows of
114
+ # ``daemon-capability:923b7d6e616f46d3...`` and not one readable
115
+ # name. For a pane whose entire purpose is telling agents apart,
116
+ # that is the same as showing nothing.
117
+ #
118
+ # The real name was already being stored the whole time: writers
119
+ # pass ``agent_id`` and it lands in ``raw_metadata_json``. On this
120
+ # store that yields claude-desktop, claude, gemini, codex, grok
121
+ # and mcp_client. Capability digests remain available per row for
122
+ # audit — they answer "what was allowed to write this", which is a
123
+ # different and also useful question, just not this pane's.
124
+ # raw_metadata_json is absent on stores predating it. Try the
125
+ # identity query and fall back to capability grouping if the
126
+ # column is missing — without this, the OperationalError is
127
+ # caught below and an older install shows ZERO agents while
128
+ # having plenty. Deliberately not PRAGMA table_info: this
129
+ # handler is on the dashboard read path, which is gated against
130
+ # anything that parses as DDL.
131
+ _tail = (
112
132
  "COUNT(*) AS cnt, MAX(created_at) AS last_active, "
113
- "GROUP_CONCAT(DISTINCT source_type) AS sources "
133
+ "GROUP_CONCAT(DISTINCT source_type) AS sources, "
134
+ "COUNT(DISTINCT NULLIF(trusted_actor_id, '')) AS capabilities "
114
135
  "FROM ingestion_operations WHERE profile_id=? "
115
136
  "GROUP BY agent_id ORDER BY cnt DESC, agent_id ASC "
116
- "LIMIT 500",
117
- (pid,),
118
- ).fetchall()
137
+ "LIMIT 500"
138
+ )
139
+ try:
140
+ rows = conn.execute(
141
+ "SELECT COALESCE("
142
+ " NULLIF(json_extract(raw_metadata_json, '$.agent_id'), ''),"
143
+ " NULLIF(trusted_actor_id, ''),"
144
+ " 'unknown'"
145
+ ") AS agent_id, " + _tail,
146
+ (pid,),
147
+ ).fetchall()
148
+ except sqlite3.OperationalError:
149
+ rows = conn.execute(
150
+ "SELECT COALESCE(NULLIF(trusted_actor_id, ''), 'unknown')"
151
+ " AS agent_id, " + _tail,
152
+ (pid,),
153
+ ).fetchall()
119
154
  for r in rows:
155
+ name = r["agent_id"]
120
156
  agents.append({
121
- "agent_id": r["agent_id"],
157
+ "agent_id": name,
122
158
  "count": r["cnt"],
123
159
  "last_active": r["last_active"],
124
160
  "source_types": (
125
161
  [s for s in (r["sources"] or "").split(",") if s]
126
162
  ),
163
+ # How many distinct capabilities this agent wrote under.
164
+ "capability_count": r["capabilities"],
165
+ # True when we fell back to a digest — the UI can then say
166
+ # "this writer did not identify itself" instead of
167
+ # presenting a hash as though it were a name.
168
+ "identified": not str(name).startswith(
169
+ ("daemon-capability:", "local-capability:")
170
+ ) and name != "unknown",
127
171
  })
128
172
  total += r["cnt"]
129
173
  except sqlite3.OperationalError:
@@ -806,7 +806,7 @@ def _compute_cross_platform() -> dict:
806
806
  ``memory.db`` (LLD-07 M004). On any adapter error, that adapter
807
807
  reports ``active: false`` with ``reason: error:<ExcName>`` rather
808
808
  than crashing the whole Brain endpoint (LLD-04 §2 — "honest, never
809
- fake"). An unimportable adapter means the install is missing Wave 2C
809
+ fake"). An unimportable adapter means the install is missing the
810
810
  components, which is legitimate for an older 3.4.20 → 3.4.22 upgrade
811
811
  mid-migration.
812
812
  """
@@ -953,7 +953,7 @@ def _compute_active_clients(profile_id: str) -> dict:
953
953
  try:
954
954
  from superlocalmemory.hooks.session_registry import active_client_summary
955
955
  clients = active_client_summary(profile_id, within_seconds=300)
956
- except Exception as exc: # distinguish failure from emptiness (Wave 4)
956
+ except Exception as exc: # distinguish failure from emptiness (important: these are different states)
957
957
  registry_ok = False
958
958
  reg_status = "error"
959
959
  logger.debug("active_clients: registry error: %s", exc)
@@ -1764,6 +1764,114 @@ async def patterns_deprecated(
1764
1764
  }
1765
1765
 
1766
1766
 
1767
+ @router.get("/bounded-loops/evidence",
1768
+ dependencies=[Depends(require_install_token)])
1769
+ async def bounded_loops_evidence(
1770
+ request: Request, profile_id: str | None = None, limit: int = 20,
1771
+ ) -> dict:
1772
+ """Terminal Bounded Loops runs this profile has observed.
1773
+
1774
+ Bounded Loops is a SEPARATE PRODUCT. SLM is one optional consumer of a
1775
+ document any MCP client can request over the published contract
1776
+ ``bounded-loops.dev/slm-bridge/v1``; neither product depends on the other,
1777
+ and installing either alone is complete.
1778
+
1779
+ What travels is deliberately narrow, and the pane must not imply otherwise:
1780
+
1781
+ * **Observation, not authorization.** ``eligible_for_learning`` is a hard
1782
+ field in the contract and is always ``False`` in v1. A SUCCEEDED run is
1783
+ not permission to retrain, re-rank or route on it. Nothing in SLM treats
1784
+ it as such, and this endpoint returns the flag so the UI can say so.
1785
+ * **Digests, not paths.** ``workspace_id`` is a hash precisely so a client's
1786
+ directory name never reaches a memory system. Gate reasons, artifact
1787
+ contents, commands and environment values are excluded at the source.
1788
+ * **``local_hash_chain_only``.** The receipt log is an append-only hash
1789
+ chain on local disk: tampering is detectable by anyone holding an earlier
1790
+ head. That is NOT authentication, notarization or independent audit, and
1791
+ calling it "verified" would claim a guarantee no part of the system
1792
+ provides.
1793
+ * **``demonstration``** separates real execution from a scripted replay. A
1794
+ demo run proves the wiring works and proves nothing about the work.
1795
+
1796
+ Read-only, off the hot path, and empty is a normal answer — most installs
1797
+ have no Bounded Loops at all.
1798
+ """
1799
+ profile_id = _authorized_profile(request, profile_id)
1800
+ limit = max(1, min(int(limit or 20), 100))
1801
+
1802
+ out: dict[str, Any] = {
1803
+ "contract": "bounded-loops.dev/slm-bridge/v1",
1804
+ "control_plane": "observation_only",
1805
+ "runs": [],
1806
+ "total": 0,
1807
+ "demonstration_count": 0,
1808
+ "installed": None,
1809
+ }
1810
+
1811
+ try:
1812
+ out["installed"] = bool(_compute_bounded_loops().get("installed"))
1813
+ except Exception: # pragma: no cover — presence probe must never 500
1814
+ out["installed"] = None
1815
+
1816
+ db_path = _learning_db_path()
1817
+ if not db_path.exists():
1818
+ return out
1819
+
1820
+ import sqlite3
1821
+
1822
+ try:
1823
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=3)
1824
+ conn.row_factory = sqlite3.Row
1825
+ try:
1826
+ rows = conn.execute(
1827
+ "SELECT run_id, run_ref, outcome, run_state, demonstration,"
1828
+ " eligible_for_learning, terminal_at, observed_at,"
1829
+ " receipt_sequence, receipt_trust, workspace_id, contract_id"
1830
+ " FROM external_evidence_receipts WHERE profile_id=?"
1831
+ " ORDER BY observed_at DESC LIMIT ?",
1832
+ (profile_id, limit),
1833
+ ).fetchall()
1834
+ total = conn.execute(
1835
+ "SELECT COUNT(*) AS n FROM external_evidence_receipts"
1836
+ " WHERE profile_id=?", (profile_id,),
1837
+ ).fetchone()["n"]
1838
+ demos = conn.execute(
1839
+ "SELECT COUNT(*) AS n FROM external_evidence_receipts"
1840
+ " WHERE profile_id=? AND demonstration=1", (profile_id,),
1841
+ ).fetchone()["n"]
1842
+ finally:
1843
+ conn.close()
1844
+ except sqlite3.Error as exc:
1845
+ # The table only exists once the bridge has been used. Absent is a
1846
+ # normal state, not an error, and must not surface as a failed pane.
1847
+ logger.debug("bounded-loops evidence unavailable: %s", exc)
1848
+ return out
1849
+
1850
+ out["total"] = total
1851
+ out["demonstration_count"] = demos
1852
+ out["runs"] = [
1853
+ {
1854
+ "run_id": r["run_id"],
1855
+ "run_ref": r["run_ref"],
1856
+ "outcome": r["outcome"],
1857
+ # Both, because the mapping to three buckets loses information: a
1858
+ # HALTED run (budget/policy stop) and a FAILED run (work the gate
1859
+ # rejected) are different events.
1860
+ "run_state": r["run_state"],
1861
+ "demonstration": bool(r["demonstration"]),
1862
+ "eligible_for_learning": bool(r["eligible_for_learning"]),
1863
+ "terminal_at": r["terminal_at"],
1864
+ "observed_at": r["observed_at"],
1865
+ "receipt_sequence": r["receipt_sequence"],
1866
+ "trust": r["receipt_trust"],
1867
+ "workspace_id": r["workspace_id"],
1868
+ "contract": r["contract_id"],
1869
+ }
1870
+ for r in rows
1871
+ ]
1872
+ return out
1873
+
1874
+
1767
1875
  @router.get("/behavioral",
1768
1876
  dependencies=[Depends(require_install_token)])
1769
1877
  async def behavioral_deprecated(
@@ -747,6 +747,159 @@ async def search_memories(request: Request, body: SearchRequest):
747
747
  end_recall()
748
748
 
749
749
 
750
+ @router.get("/api/summary")
751
+ async def get_summary(request: Request, kind: str = "day", target: str = ""):
752
+ """Readable summary of memories: a day, a project, or one session (#113).
753
+
754
+ The dashboard surface for the summary layer. 4.0.6 shipped the generators
755
+ with no caller, 4.0.7 added the CLI, 4.0.8 adds this and the MCP tool — the
756
+ "no command, tool or endpoint" gap, closed at the third point.
757
+
758
+ Always returns ``coverage`` and ``source_fact_ids``: a summary that hides how
759
+ much it covered is the opaque generic summary issue #113 warned against.
760
+ Reads memory.db directly; never runs during remember or recall.
761
+ """
762
+ from datetime import date as _date, timedelta as _timedelta
763
+
764
+ kind = (kind or "day").strip().lower()
765
+ if kind not in ("day", "project", "session"):
766
+ raise HTTPException(status_code=400, detail=f"unknown summary kind '{kind}'")
767
+
768
+ profile = get_active_profile()
769
+ from superlocalmemory.infra.data_root import state_path
770
+ db_path = state_path("memory.db")
771
+ if not db_path.exists():
772
+ raise HTTPException(status_code=404, detail="no memory database")
773
+
774
+ # Pass the loaded config so Mode B/C write the summary. Omitting it silently
775
+ # forces the extractive path for every caller regardless of mode.
776
+ try:
777
+ from superlocalmemory.core.config import SLMConfig
778
+ cfg = SLMConfig.load()
779
+ except Exception:
780
+ cfg = None
781
+
782
+ try:
783
+ if kind == "day":
784
+ from superlocalmemory.summaries import generate_daily_reflection
785
+ day = (target or "").strip() or _date.today().isoformat()
786
+ if day == "today":
787
+ day = _date.today().isoformat()
788
+ elif day == "yesterday":
789
+ day = (_date.today() - _timedelta(days=1)).isoformat()
790
+ result = generate_daily_reflection(db_path, day, profile, cfg)
791
+ elif kind == "project":
792
+ if not (target or "").strip():
793
+ raise HTTPException(status_code=400, detail="project requires target")
794
+ from superlocalmemory.summaries import generate_project_work_log
795
+ result = generate_project_work_log(db_path, target.strip(), profile, cfg)
796
+ else:
797
+ if not (target or "").strip():
798
+ raise HTTPException(status_code=400, detail="session requires target")
799
+ from superlocalmemory.summaries import generate_session_summary
800
+ result = generate_session_summary(db_path, target.strip(), profile, cfg)
801
+ except HTTPException:
802
+ raise
803
+ except Exception:
804
+ raise _internal_error("Summary generation error")
805
+
806
+ return {
807
+ "kind": result.kind,
808
+ "profile_id": result.profile_id,
809
+ "summary": result.content,
810
+ "coverage": result.coverage,
811
+ "generated_by": result.generated_by,
812
+ "source_fact_ids": result.source_fact_ids,
813
+ "source_count": len(result.source_fact_ids),
814
+ "metadata": result.metadata,
815
+ }
816
+
817
+
818
+ @router.get("/api/summary/projects")
819
+ async def get_summary_projects(request: Request):
820
+ """Projects SuperLocalMemory has actually observed, for the summary picker.
821
+
822
+ WHY THIS EXISTS
823
+ ---------------
824
+ SLM is installed globally and the dashboard is a browser tab — it has no
825
+ working directory, so there is no such thing as "this project" from the
826
+ server's point of view. 4.0.8 shipped a "This project" button that sent an
827
+ empty target and produced "project requires target" every time. A button
828
+ that cannot know its own answer is the wrong control; a list of the projects
829
+ we have seen is the right one.
830
+
831
+ Scope comes from ``tool_events.project_path`` — the directory an agent was
832
+ working in when it called SLM. Deliberately NOT ``entity_profiles.
833
+ project_name``, which has exactly one distinct value on a real store (see
834
+ the note at the top of summaries/project_work_log.py).
835
+
836
+ ``tool_events`` is a bounded ring buffer, so this lists recently active
837
+ projects rather than every project in history. ``truncated`` says so
838
+ honestly instead of implying the list is exhaustive.
839
+ """
840
+ profile = get_active_profile()
841
+ try:
842
+ conn = get_db_connection()
843
+ # get_db_connection() hands back a SHARED read connection, and other
844
+ # handlers set row_factory on it. Never index these rows positionally —
845
+ # whichever handler ran last decides whether r[0] is a column or a
846
+ # KeyError. Name the columns and read them by name.
847
+ conn.row_factory = dict_factory
848
+ cursor = conn.cursor()
849
+ cursor.execute(
850
+ """
851
+ SELECT project_path AS path, COUNT(*) AS events
852
+ FROM tool_events
853
+ WHERE project_path IS NOT NULL AND project_path != ''
854
+ AND profile_id = ?
855
+ GROUP BY project_path
856
+ ORDER BY events DESC
857
+ LIMIT 50
858
+ """,
859
+ (profile,),
860
+ )
861
+ rows = cursor.fetchall()
862
+ total = cursor.execute(
863
+ "SELECT COUNT(*) AS n FROM tool_events"
864
+ ).fetchone()["n"]
865
+ except Exception:
866
+ raise _internal_error("Project list error")
867
+
868
+ projects = [
869
+ {
870
+ "path": r["path"],
871
+ "events": r["events"],
872
+ "label": _project_label(r["path"]),
873
+ }
874
+ for r in rows
875
+ ]
876
+ return {
877
+ "projects": projects,
878
+ "profile_id": profile,
879
+ # Surfaced so the UI can explain an unexpectedly short list rather than
880
+ # leaving the user to assume their project was never recorded.
881
+ "truncated": total >= _TOOL_EVENT_RING_SIZE,
882
+ "event_rows": total,
883
+ }
884
+
885
+
886
+ #: tool_events is capped; at the cap the project list is a recent window, not history.
887
+ _TOOL_EVENT_RING_SIZE = 2000
888
+
889
+
890
+ def _project_label(path: str) -> str:
891
+ """Short, human label for a project path.
892
+
893
+ Full paths are long and share prefixes ("/Users/x/Documents/official/..."),
894
+ so a dropdown of raw paths is unreadable. Last two segments keep sibling
895
+ projects distinguishable without the noise.
896
+ """
897
+ parts = [p for p in str(path).replace("\\", "/").split("/") if p]
898
+ if not parts:
899
+ return str(path)
900
+ return "/".join(parts[-2:]) if len(parts) > 1 else parts[-1]
901
+
902
+
750
903
  @router.get("/api/clusters")
751
904
  async def get_clusters(request: Request):
752
905
  """Get cluster information with member counts and statistics."""
@@ -4,10 +4,10 @@
4
4
 
5
5
  """POST /internal/prewarm — populates the context cache for a session.
6
6
 
7
- S8-SK-02 fix: Wave 2A shipped ``hooks/prewarm_auth.authorize`` (gates
8
- loopback → origin → install-token → body-size) and unit-tested it, but
9
- no FastAPI route mounted it. The hot-path ``post_tool_async_hook`` POSTs
10
- to ``/internal/prewarm`` after every tool call to refresh the
7
+ Bug fix: ``hooks/prewarm_auth.authorize`` (which gates loopback →
8
+ origin → install-token → body-size) was implemented and unit-tested,
9
+ but no FastAPI route mounted it. The hot-path ``post_tool_async_hook``
10
+ POSTs to ``/internal/prewarm`` after every tool call to refresh the
11
11
  ``active_brain_cache`` row for the current session/topic. Without a
12
12
  route registered here, those POSTs 404'd silently, the cache never
13
13
  populated, and every ``UserPromptSubmit`` ended up a structural miss.