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.
- package/CHANGELOG.md +219 -1
- package/README.md +6 -6
- 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/scripts/ensure-venv.sh +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 +3 -3
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +1 -1
- package/src/superlocalmemory/cli/commands.py +119 -9
- package/src/superlocalmemory/cli/db_migrate.py +0 -2
- package/src/superlocalmemory/cli/gdpr_io.py +1 -1
- package/src/superlocalmemory/cli/main.py +5 -5
- package/src/superlocalmemory/cli/service_installer.py +2 -1
- package/src/superlocalmemory/cli/setup_wizard.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/config.py +41 -7
- package/src/superlocalmemory/core/consolidation_engine.py +14 -15
- package/src/superlocalmemory/core/context_cache.py +0 -2
- package/src/superlocalmemory/core/engine.py +371 -63
- package/src/superlocalmemory/core/evidence_bundle.py +3 -1
- package/src/superlocalmemory/core/install_detector.py +131 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
- package/src/superlocalmemory/core/recall_worker.py +4 -0
- package/src/superlocalmemory/core/security_primitives.py +3 -6
- package/src/superlocalmemory/core/store_pipeline.py +94 -26
- package/src/superlocalmemory/core/topic_signature.py +0 -2
- package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
- package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
- package/src/superlocalmemory/encoding/graph_builder.py +2 -2
- package/src/superlocalmemory/encoding/scene_builder.py +8 -2
- package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
- package/src/superlocalmemory/hooks/adapter_base.py +0 -2
- package/src/superlocalmemory/hooks/context_payload.py +0 -2
- package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
- package/src/superlocalmemory/hooks/portable_kit.py +8 -8
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
- package/src/superlocalmemory/infra/backup.py +44 -8
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
- package/src/superlocalmemory/learning/arm_catalog.py +0 -2
- package/src/superlocalmemory/learning/bandit.py +0 -2
- package/src/superlocalmemory/learning/bandit_cache.py +0 -2
- package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
- package/src/superlocalmemory/learning/ensemble.py +0 -2
- package/src/superlocalmemory/learning/labeler.py +0 -2
- package/src/superlocalmemory/learning/legacy_migration.py +0 -2
- package/src/superlocalmemory/learning/model_cache.py +0 -2
- package/src/superlocalmemory/learning/pattern_miner.py +12 -7
- package/src/superlocalmemory/learning/ranker.py +0 -2
- package/src/superlocalmemory/learning/reward_archive.py +6 -1
- package/src/superlocalmemory/learning/reward_proxy.py +0 -2
- package/src/superlocalmemory/learning/signal_worker.py +0 -2
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +4 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +7 -3
- package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
- package/src/superlocalmemory/mcp/profiles.py +11 -4
- package/src/superlocalmemory/mcp/server.py +8 -1
- package/src/superlocalmemory/mcp/tools_active.py +56 -0
- package/src/superlocalmemory/mcp/tools_core.py +1 -1
- package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
- package/src/superlocalmemory/optimize/cache/manager.py +2 -2
- package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
- package/src/superlocalmemory/optimize/compress/router.py +1 -1
- package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
- package/src/superlocalmemory/optimize/proxy/server.py +1 -1
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +2 -2
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +9 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +272 -43
- package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
- package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -3
- package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
- package/src/superlocalmemory/retrieval/strategy.py +94 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
- package/src/superlocalmemory/retrieval/vector_store.py +88 -10
- package/src/superlocalmemory/server/consolidation_runner.py +140 -0
- package/src/superlocalmemory/server/recall_serializer.py +44 -2
- package/src/superlocalmemory/server/routes/agents.py +52 -8
- package/src/superlocalmemory/server/routes/brain.py +110 -2
- package/src/superlocalmemory/server/routes/memories.py +153 -0
- package/src/superlocalmemory/server/routes/prewarm.py +4 -4
- package/src/superlocalmemory/server/routes/v3_api.py +24 -46
- package/src/superlocalmemory/server/unified_daemon.py +566 -7
- package/src/superlocalmemory/storage/_schema_version.py +46 -3
- package/src/superlocalmemory/storage/backup.py +531 -0
- package/src/superlocalmemory/storage/database.py +11 -4
- package/src/superlocalmemory/storage/embedding_codec.py +129 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
- package/src/superlocalmemory/storage/migration_runner.py +142 -2
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
- package/src/superlocalmemory/storage/migrations.py +15 -1
- package/src/superlocalmemory/storage/models.py +7 -0
- package/src/superlocalmemory/storage/quantized_store.py +4 -2
- 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 +10 -6
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
- package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
- package/src/superlocalmemory/ui/index.html +9 -3
- package/src/superlocalmemory/ui/js/core.js +1 -1
- package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
- package/src/superlocalmemory/ui/js/od-brain.js +1 -1
- 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,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).
|
|
@@ -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
|
-
|
|
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:
|
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
no FastAPI route mounted it. The hot-path ``post_tool_async_hook``
|
|
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.
|