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.
- package/CHANGELOG.md +73 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/summary_cmd.py +23 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +7 -1
- package/src/superlocalmemory/core/consolidation_engine.py +14 -15
- package/src/superlocalmemory/core/recall_worker.py +4 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
- package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
- package/src/superlocalmemory/learning/pattern_miner.py +12 -7
- package/src/superlocalmemory/mcp/profiles.py +10 -3
- package/src/superlocalmemory/mcp/server.py +7 -0
- package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
- package/src/superlocalmemory/server/consolidation_runner.py +140 -0
- package/src/superlocalmemory/server/recall_serializer.py +34 -2
- package/src/superlocalmemory/server/routes/agents.py +52 -8
- package/src/superlocalmemory/server/routes/brain.py +108 -0
- package/src/superlocalmemory/server/routes/memories.py +153 -0
- package/src/superlocalmemory/server/routes/v3_api.py +24 -46
- package/src/superlocalmemory/server/unified_daemon.py +107 -0
- package/src/superlocalmemory/summaries/base.py +159 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
- package/src/superlocalmemory/summaries/project_work_log.py +23 -7
- package/src/superlocalmemory/summaries/session_summary.py +9 -5
- package/src/superlocalmemory/ui/index.html +9 -3
- package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
- package/src/superlocalmemory/ui/js/od-memories.js +337 -12
- package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
- package/src/superlocalmemory/ui/js/od-operations.js +1 -150
- package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
- 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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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":
|
|
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(
|