superlocalmemory 4.0.7 → 4.0.8

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 (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +3 -3
  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/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/cli/summary_cmd.py +23 -3
  27. package/src/superlocalmemory/code_graph/bridge/maintenance.py +7 -1
  28. package/src/superlocalmemory/core/consolidation_engine.py +14 -15
  29. package/src/superlocalmemory/core/recall_worker.py +4 -0
  30. package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
  31. package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
  32. package/src/superlocalmemory/learning/pattern_miner.py +12 -7
  33. package/src/superlocalmemory/mcp/profiles.py +10 -3
  34. package/src/superlocalmemory/mcp/server.py +7 -0
  35. package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
  36. package/src/superlocalmemory/server/consolidation_runner.py +140 -0
  37. package/src/superlocalmemory/server/recall_serializer.py +34 -2
  38. package/src/superlocalmemory/server/routes/agents.py +52 -8
  39. package/src/superlocalmemory/server/routes/brain.py +108 -0
  40. package/src/superlocalmemory/server/routes/memories.py +153 -0
  41. package/src/superlocalmemory/server/routes/v3_api.py +24 -46
  42. package/src/superlocalmemory/server/unified_daemon.py +107 -0
  43. package/src/superlocalmemory/summaries/base.py +159 -0
  44. package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
  45. package/src/superlocalmemory/summaries/project_work_log.py +23 -7
  46. package/src/superlocalmemory/summaries/session_summary.py +9 -5
  47. package/src/superlocalmemory/ui/index.html +9 -3
  48. package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
  49. package/src/superlocalmemory/ui/js/od-memories.js +337 -12
  50. package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
  51. package/src/superlocalmemory/ui/js/od-operations.js +1 -150
  52. package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
  53. package/src/superlocalmemory/ui/js/od-shell.js +10 -0
@@ -0,0 +1,147 @@
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
+ """MCP surface for the readable summary layer (issue #113).
6
+
7
+ WHY THIS EXISTS
8
+ ---------------
9
+ 4.0.6 shipped the summary generators with no caller at all. 4.0.7 added
10
+ ``slm summary``, which fixed it for a person at a terminal and left agents with
11
+ nothing — the changelog said the defect was "no command, tool or endpoint" and
12
+ only the command was built. This is the tool half.
13
+
14
+ It matters more than the CLI: the natural consumer of "what did I work on
15
+ yesterday" is the agent holding the conversation, not a human running a command.
16
+
17
+ CONTRACT
18
+ --------
19
+ Read-only, profile-scoped, and honest about coverage. Every response carries
20
+ ``coverage`` and ``source_fact_ids``, so a caller can tell a summary of 4% of a
21
+ session from a summary of all of it, and can drill back to the memories it came
22
+ from. Callers must not present a partial summary as complete; the field exists
23
+ precisely so they do not have to guess.
24
+
25
+ NOT ON THE HOT PATH. Summaries read memory.db directly and are invoked on
26
+ demand; nothing here runs during remember or recall.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from datetime import date, timedelta
33
+ from typing import Any, Callable
34
+
35
+ from mcp.types import ToolAnnotations
36
+
37
+ from superlocalmemory.core.admission import admits
38
+ from superlocalmemory.core.operation_request import OperationKind
39
+ from superlocalmemory.infra.data_root import state_path
40
+
41
+ logger = logging.getLogger("superlocalmemory.mcp.summaries")
42
+
43
+ #: Accepted values for the ``kind`` argument.
44
+ _KINDS = ("day", "project", "session")
45
+
46
+
47
+ def _result_payload(result: Any) -> dict[str, Any]:
48
+ """Shape a SummaryResult for the wire.
49
+
50
+ ``coverage`` and ``source_fact_ids`` are non-negotiable parts of the
51
+ response: a summary that cannot be traced back, or that hides how much it
52
+ covered, is the opaque generic summary issue #113 asked us not to build.
53
+ """
54
+ return {
55
+ "success": True,
56
+ "kind": result.kind,
57
+ "profile_id": result.profile_id,
58
+ "summary": result.content,
59
+ "coverage": result.coverage,
60
+ "generated_by": result.generated_by,
61
+ "source_fact_ids": result.source_fact_ids,
62
+ "source_count": len(result.source_fact_ids),
63
+ "metadata": result.metadata,
64
+ }
65
+
66
+
67
+ def _error(message: str, **extra: Any) -> dict[str, Any]:
68
+ out = {"success": False, "error": message}
69
+ out.update(extra)
70
+ return out
71
+
72
+
73
+ def register_summary_tools(server: Any, get_engine: Callable[[], Any]) -> None:
74
+ """Register the read-only summary tool."""
75
+
76
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
77
+ @admits(OperationKind.RECALL)
78
+ async def get_memory_summary(
79
+ kind: str = "day",
80
+ target: str = "",
81
+ ) -> dict[str, Any]:
82
+ """Summarise your memories: a day, a project, or one session.
83
+
84
+ Args:
85
+ kind: "day", "project", or "session".
86
+ target: For "day", an ISO date, "today" or "yesterday" (default
87
+ today). For "project", a directory path (default: none — supply
88
+ one). For "session", the session id.
89
+
90
+ Returns a summary plus ``coverage`` and ``source_fact_ids``. Coverage is
91
+ not decoration: session data is sparse — roughly 4% of facts carry a
92
+ session id — so a session summary is usually partial. Do not present a
93
+ partial summary as a complete record of what happened.
94
+
95
+ No language model is required; summaries are extractive unless the
96
+ profile runs a local or cloud model, in which case that writes them.
97
+ """
98
+ kind = (kind or "day").strip().lower()
99
+ if kind not in _KINDS:
100
+ return _error(
101
+ f"unknown summary kind {kind!r}; expected one of {', '.join(_KINDS)}"
102
+ )
103
+
104
+ engine = get_engine()
105
+ profile_id = getattr(engine, "profile_id", "default")
106
+ db_path = state_path("memory.db")
107
+ if not db_path.exists():
108
+ return _error("no memory database found", db_path=str(db_path))
109
+
110
+ # The engine's config drives Mode B/C enrichment. Passing None would
111
+ # silently force the extractive path for every caller regardless of
112
+ # mode — the exact bug the CLI shipped with in 4.0.7.
113
+ config = getattr(engine, "config", None)
114
+
115
+ try:
116
+ if kind == "day":
117
+ from superlocalmemory.summaries import generate_daily_reflection
118
+
119
+ day = (target or "").strip() or date.today().isoformat()
120
+ if day == "today":
121
+ day = date.today().isoformat()
122
+ elif day == "yesterday":
123
+ day = (date.today() - timedelta(days=1)).isoformat()
124
+ result = generate_daily_reflection(db_path, day, profile_id, config)
125
+
126
+ elif kind == "project":
127
+ from superlocalmemory.summaries import generate_project_work_log
128
+
129
+ if not (target or "").strip():
130
+ return _error("kind='project' requires target=<project path>")
131
+ result = generate_project_work_log(
132
+ db_path, target.strip(), profile_id, config,
133
+ )
134
+
135
+ else: # session
136
+ from superlocalmemory.summaries import generate_session_summary
137
+
138
+ if not (target or "").strip():
139
+ return _error("kind='session' requires target=<session id>")
140
+ result = generate_session_summary(
141
+ db_path, target.strip(), profile_id, config,
142
+ )
143
+ except Exception as exc:
144
+ logger.warning("summary generation failed (%s/%s): %s", kind, target, exc)
145
+ return _error(f"summary generation failed: {exc}", kind=kind)
146
+
147
+ return _result_payload(result)
@@ -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).
@@ -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:
@@ -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(