superlocalmemory 4.0.6 → 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 +128 -4
- package/README.md +6 -11
- 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/commands.py +14 -0
- package/src/superlocalmemory/cli/main.py +9 -0
- package/src/superlocalmemory/cli/summary_cmd.py +215 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +212 -0
- package/src/superlocalmemory/code_graph/config.py +65 -1
- package/src/superlocalmemory/core/consolidation_engine.py +14 -15
- package/src/superlocalmemory/core/fact_consolidator.py +24 -1
- package/src/superlocalmemory/core/maintenance.py +51 -1
- 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_code_graph.py +47 -3
- 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 +214 -0
- package/src/superlocalmemory/server/routes/v3_api.py +24 -46
- package/src/superlocalmemory/server/unified_daemon.py +107 -0
- package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
- 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 +10 -4
- package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
- 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
|
@@ -678,12 +678,62 @@ def run_maintenance(
|
|
|
678
678
|
"which is distinct from 0 = nothing to merge): %s", exc,
|
|
679
679
|
)
|
|
680
680
|
|
|
681
|
+
# 5. Code↔memory bridge (4.0.7).
|
|
682
|
+
# Resolves code entity mentions in new facts against the code graph and
|
|
683
|
+
# stores the links, plus derived enrichment text, in code_graph.db.
|
|
684
|
+
#
|
|
685
|
+
# RUNS HERE, NOT ON THE WRITE PATH. The bridge was authored to fire from
|
|
686
|
+
# BridgeEventListeners.on_memory_stored, and EventBus._notify_listeners
|
|
687
|
+
# dispatches synchronously on the emitting thread — which would have put
|
|
688
|
+
# entity resolution and enrichment inside every remember. The owner's
|
|
689
|
+
# constraint for 4.0.7 is that remember/recall timing must not move, so the
|
|
690
|
+
# memory-stored subscription is gone and the work happens in this pass.
|
|
691
|
+
# tests/test_code_graph/test_bridge_off_write_path.py fails if it comes back.
|
|
692
|
+
#
|
|
693
|
+
# Writes only code_graph.db, which no recall path opens, so this step cannot
|
|
694
|
+
# affect recall latency or results. Hebbian edges are the one exception and
|
|
695
|
+
# are NOT run here — they land in association_edges, which spreading
|
|
696
|
+
# activation reads; they are generated on explicit request instead.
|
|
697
|
+
counts["bridge_links"] = 0
|
|
698
|
+
counts["bridge_enriched"] = 0
|
|
699
|
+
try:
|
|
700
|
+
from superlocalmemory.code_graph.config import CodeGraphConfig
|
|
701
|
+
|
|
702
|
+
cg_cfg = CodeGraphConfig.load()
|
|
703
|
+
if cg_cfg.enabled and cg_cfg.bridge_enabled:
|
|
704
|
+
from superlocalmemory.code_graph.bridge.maintenance import run_bridge_pass
|
|
705
|
+
from superlocalmemory.code_graph.database import CodeGraphDatabase
|
|
706
|
+
|
|
707
|
+
cg_path = cg_cfg.get_db_path()
|
|
708
|
+
if cg_path.exists():
|
|
709
|
+
bridge_stats = run_bridge_pass(
|
|
710
|
+
db, CodeGraphDatabase(cg_path), profile_id,
|
|
711
|
+
)
|
|
712
|
+
counts["bridge_links"] = bridge_stats.get("links_created", 0)
|
|
713
|
+
counts["bridge_enriched"] = bridge_stats.get("enriched", 0)
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
# -1 rather than 0, for the same reason fact consolidation uses it:
|
|
716
|
+
# a step that never worked must not report the same numbers as a
|
|
717
|
+
# healthy step with no work to do.
|
|
718
|
+
counts["bridge_links"] = -1
|
|
719
|
+
logger.warning(
|
|
720
|
+
"Code bridge pass FAILED during maintenance (reported as -1, "
|
|
721
|
+
"which is distinct from 0 = nothing to link): %s", exc,
|
|
722
|
+
)
|
|
723
|
+
|
|
681
724
|
logger.info(
|
|
682
725
|
"Maintenance complete: %d backfilled, %d Langevin, %d Fisher-coupled, "
|
|
683
|
-
"%d Sheaf, %d entity-summaries, %d facts-consolidated",
|
|
726
|
+
"%d Sheaf, %d entity-summaries, %d facts-consolidated, %d code-links",
|
|
684
727
|
counts["langevin_backfilled"], counts["langevin_updated"],
|
|
685
728
|
counts["fisher_coupled"], counts["sheaf_checked"],
|
|
686
729
|
counts["entity_summaries_consolidated"],
|
|
687
730
|
counts["facts_consolidated"],
|
|
731
|
+
counts["bridge_links"],
|
|
688
732
|
)
|
|
689
733
|
return counts
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# The bridge gate reads code_graph_config.json via CodeGraphConfig.load(), not
|
|
737
|
+
# SLMConfig: SLMConfig has no code_graph block, so there is nothing to read
|
|
738
|
+
# there. cli/setup_wizard.py writes that file; before 4.0.7 no loader existed
|
|
739
|
+
# and every call site hardcoded CodeGraphConfig(enabled=True) instead.
|
|
@@ -106,6 +106,10 @@ def _handle_recall(
|
|
|
106
106
|
memory_map=memory_map,
|
|
107
107
|
per_fact_max=getattr(_rc, "recall_per_fact_max_chars", 2400),
|
|
108
108
|
total_max=getattr(_rc, "recall_total_max_chars", 12000),
|
|
109
|
+
# Option B: markers only on session-bearing recalls. A marker can
|
|
110
|
+
# only buy a learning signal when a pending_outcomes row exists
|
|
111
|
+
# to settle, and those exist only when session_id is present.
|
|
112
|
+
include_marker=bool(session_id),
|
|
109
113
|
)
|
|
110
114
|
return {
|
|
111
115
|
"ok": True,
|
|
@@ -140,9 +140,24 @@ class SkillEvolver:
|
|
|
140
140
|
# automatically. Tests inject their own.
|
|
141
141
|
if budget is None:
|
|
142
142
|
slm_home = canonical_data_root()
|
|
143
|
+
# ``learning_db`` must be learning.db: migration M010 creates
|
|
144
|
+
# evolution_llm_cost_log there. Every production caller
|
|
145
|
+
# (consolidation_engine, routes/evolution, MCP, CLI) passes
|
|
146
|
+
# memory.db as db_path, so handing db_path straight to the budget
|
|
147
|
+
# pointed it at a database where that table does not exist. Every
|
|
148
|
+
# evolution cycle then died with "no such table:
|
|
149
|
+
# evolution_llm_cost_log" — swallowed at debug level in
|
|
150
|
+
# consolidation step 11, so skill evolution silently never ran.
|
|
151
|
+
#
|
|
152
|
+
# Redirect only for the exact production filename. Tests pass
|
|
153
|
+
# ":memory:" or their own tmp file and must keep the old behaviour,
|
|
154
|
+
# rather than having a learning.db invented beside them.
|
|
155
|
+
learning_db = Path(self._db_path)
|
|
156
|
+
if learning_db.name == "memory.db":
|
|
157
|
+
learning_db = learning_db.parent / "learning.db"
|
|
143
158
|
budget = EvolutionBudget(
|
|
144
159
|
profile_id=profile_id,
|
|
145
|
-
learning_db=
|
|
160
|
+
learning_db=learning_db,
|
|
146
161
|
lock_dir=slm_home,
|
|
147
162
|
)
|
|
148
163
|
self._budget = budget
|
|
@@ -768,7 +768,29 @@ def _run_quiet(cmd: list[str], timeout: int = 5, postprocess=None) -> str:
|
|
|
768
768
|
|
|
769
769
|
|
|
770
770
|
def _maybe_consolidate() -> None:
|
|
771
|
-
"""
|
|
771
|
+
"""Ask the daemon for a full consolidation if the last one was >24h ago.
|
|
772
|
+
|
|
773
|
+
Three things were wrong here before 4.0.8, and together they meant the
|
|
774
|
+
behavioural half of consolidation never ran on a real install:
|
|
775
|
+
|
|
776
|
+
1. **It ran the wrong pipeline.** ``slm consolidate --cognitive`` invokes
|
|
777
|
+
``CognitiveConsolidator.run_pipeline()``. Behavioural assertion mining,
|
|
778
|
+
soft prompts, skill performance and skill evolution are steps 8-11 of
|
|
779
|
+
``ConsolidationEngine.consolidate()`` — a different class entirely. On a
|
|
780
|
+
store with thousands of tool events, ``behavioral_assertions`` stayed at
|
|
781
|
+
zero rows while the miner, run once by hand, produced 9 immediately.
|
|
782
|
+
2. **It marked success before trying.** The 24h timestamp was written
|
|
783
|
+
*before* the subprocess launched, so a run that failed instantly still
|
|
784
|
+
bought a full day of silence. The marker is now written only after the
|
|
785
|
+
daemon confirms it scheduled the work.
|
|
786
|
+
3. **It could not fail visibly.** stdout and stderr went to DEVNULL and the
|
|
787
|
+
handler ended in a bare ``except: pass``, so nothing anywhere recorded a
|
|
788
|
+
failure. Errors now surface on stderr, where the hook runner logs them.
|
|
789
|
+
|
|
790
|
+
Non-blocking: the daemon schedules the pass and returns immediately, so
|
|
791
|
+
session end is never held up by a run that takes minutes. The daemon's own
|
|
792
|
+
periodic timer is the backstop for sessions where this hook never fires.
|
|
793
|
+
"""
|
|
772
794
|
try:
|
|
773
795
|
last_ts = 0
|
|
774
796
|
last_consolidation = _last_consolidation_path()
|
|
@@ -780,16 +802,21 @@ def _maybe_consolidate() -> None:
|
|
|
780
802
|
if (now - last_ts) < 86400: # 24 hours
|
|
781
803
|
return
|
|
782
804
|
|
|
783
|
-
|
|
805
|
+
started = _daemon_post(
|
|
806
|
+
"/api/v3/consolidation/trigger",
|
|
807
|
+
{"lightweight": False, "background": True},
|
|
808
|
+
)
|
|
809
|
+
if not started:
|
|
810
|
+
# No marker written — the next session end retries. The daemon's
|
|
811
|
+
# consolidation lock makes a duplicate request a cheap no-op.
|
|
812
|
+
print(
|
|
813
|
+
"slm: consolidation could not be scheduled (daemon unreachable)",
|
|
814
|
+
file=sys.stderr,
|
|
815
|
+
)
|
|
816
|
+
return
|
|
817
|
+
|
|
784
818
|
os.makedirs(os.path.dirname(last_consolidation), exist_ok=True)
|
|
785
819
|
with open(last_consolidation, "w") as f:
|
|
786
820
|
f.write(str(now))
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
subprocess.Popen(
|
|
790
|
-
["slm", "consolidate", "--cognitive"],
|
|
791
|
-
stdout=subprocess.DEVNULL,
|
|
792
|
-
stderr=subprocess.DEVNULL,
|
|
793
|
-
)
|
|
794
|
-
except Exception:
|
|
795
|
-
pass
|
|
821
|
+
except Exception as exc:
|
|
822
|
+
print(f"slm: consolidation trigger failed: {exc}", file=sys.stderr)
|
|
@@ -367,18 +367,23 @@ def _mine_channel_and_coretrieval(
|
|
|
367
367
|
gen += 1
|
|
368
368
|
|
|
369
369
|
try:
|
|
370
|
+
# Column names must match learning.db: the table is
|
|
371
|
+
# (fact_id_a, fact_id_b, co_count). This query asked for
|
|
372
|
+
# (fact_a, fact_b, co_access_count) and therefore raised
|
|
373
|
+
# sqlite3.OperationalError on every run since it was written —
|
|
374
|
+
# caught below, logged at WARNING, and otherwise invisible. Net
|
|
375
|
+
# effect: 912 co-retrieval edges on a real store never produced a
|
|
376
|
+
# single pattern, and the Brain pane had one fewer signal with no
|
|
377
|
+
# indication anything was missing.
|
|
370
378
|
coret_rows = learn_conn.execute(
|
|
371
|
-
"SELECT
|
|
379
|
+
"SELECT fact_id_a, fact_id_b, co_count "
|
|
372
380
|
"FROM co_retrieval_edges "
|
|
373
|
-
"WHERE profile_id = ? AND
|
|
374
|
-
"ORDER BY
|
|
381
|
+
"WHERE profile_id = ? AND co_count >= 3 "
|
|
382
|
+
"ORDER BY co_count DESC LIMIT 20",
|
|
375
383
|
(profile_id,),
|
|
376
384
|
).fetchall()
|
|
377
385
|
if coret_rows and not dry_run:
|
|
378
|
-
top_pair = (
|
|
379
|
-
dict(coret_rows[0]).get("co_access_count", 0)
|
|
380
|
-
if coret_rows else 0
|
|
381
|
-
)
|
|
386
|
+
top_pair = dict(coret_rows[0]).get("co_count", 0)
|
|
382
387
|
store.record_pattern(
|
|
383
388
|
profile_id=profile_id,
|
|
384
389
|
pattern_type="co_retrieval_clusters",
|
|
@@ -17,13 +17,17 @@ from __future__ import annotations
|
|
|
17
17
|
# v3.6.14 WP-01: Named profile definitions
|
|
18
18
|
# ---------------------------------------------------------------------------
|
|
19
19
|
|
|
20
|
-
_PROFILE_CORE: frozenset[str] = frozenset({ #
|
|
20
|
+
_PROFILE_CORE: frozenset[str] = frozenset({ # 17
|
|
21
21
|
"remember", "recall", "search", "fetch", "list_recent", "update_memory", "forget",
|
|
22
22
|
"session_init", "close_session",
|
|
23
23
|
"slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
|
|
24
24
|
# A client that can propose a correction must be able to inspect and
|
|
25
25
|
# authenticate its review; otherwise the core lifecycle is incomplete.
|
|
26
26
|
"review_correction", "list_corrections",
|
|
27
|
+
# v4.0.8: the readable summary layer (issue #113). In CORE because the
|
|
28
|
+
# natural caller is the agent holding the conversation — an assistant
|
|
29
|
+
# asked "what did I work on yesterday" should not need a power profile.
|
|
30
|
+
"get_memory_summary",
|
|
27
31
|
})
|
|
28
32
|
|
|
29
33
|
# Portable Brain evidence must reach the coding-host profile shipped by the
|
|
@@ -34,7 +38,7 @@ _PROFILE_BRAIN: frozenset[str] = frozenset({
|
|
|
34
38
|
"observe_bounded_loop_evidence",
|
|
35
39
|
})
|
|
36
40
|
|
|
37
|
-
_PROFILE_CODE: frozenset[str] = _PROFILE_CORE | _PROFILE_BRAIN | frozenset({ #
|
|
41
|
+
_PROFILE_CODE: frozenset[str] = _PROFILE_CORE | _PROFILE_BRAIN | frozenset({ # 32
|
|
38
42
|
"build_code_graph", "get_blast_radius", "query_graph",
|
|
39
43
|
"semantic_search_code", "get_review_context", "detect_changes",
|
|
40
44
|
# switch_profile lets a plugin/IDE session change the active workspace over
|
|
@@ -64,8 +68,11 @@ _PROFILE_FULL: frozenset[str] = frozenset({
|
|
|
64
68
|
"slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
|
|
65
69
|
# v3.8.0: bounded-loop tools (CLI + /slm-loop command + MCP).
|
|
66
70
|
"slm_loop_run", "slm_loop_history", "slm_loop_show",
|
|
71
|
+
# v4.0.8: readable summaries (#113). In core, so it must be in full too —
|
|
72
|
+
# full is asserted to be a superset of core.
|
|
73
|
+
"get_memory_summary",
|
|
67
74
|
# prestage_context remains registered but deliberately raw-server-only.
|
|
68
|
-
}) | _PROFILE_FULL_MESH #
|
|
75
|
+
}) | _PROFILE_FULL_MESH # 50
|
|
69
76
|
|
|
70
77
|
_PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ # 61
|
|
71
78
|
"get_version", "get_mode", "health", "consistency_check", "recall_trace",
|
|
@@ -105,6 +105,11 @@ _ESSENTIAL_TOOLS: set[str] = {
|
|
|
105
105
|
"observe_bounded_loop_evidence",
|
|
106
106
|
# Update, review, and list form one core correction lifecycle.
|
|
107
107
|
"review_correction", "list_corrections",
|
|
108
|
+
# v4.0.8 (#113): readable summaries. Present here as well as in the named
|
|
109
|
+
# profiles because this set is the FALLBACK surface — it must mirror
|
|
110
|
+
# ``full``, and a tool that ships in the smallest profile ("core") cannot be
|
|
111
|
+
# missing from the fallback without a client silently losing it.
|
|
112
|
+
"get_memory_summary",
|
|
108
113
|
# Memory management (2)
|
|
109
114
|
"forget", "run_maintenance",
|
|
110
115
|
# NOTE: prestage_context IS registered (see register_prestage_tool below)
|
|
@@ -280,6 +285,8 @@ from superlocalmemory.mcp.tools_ops import register_ops_tools
|
|
|
280
285
|
register_ops_tools(_target, get_engine) # Wave-3: operational recovery & admin remediation
|
|
281
286
|
from superlocalmemory.mcp.tools_brain import register_brain_tools
|
|
282
287
|
register_brain_tools(_target, get_engine) # v4.0.2 portable Brain receipts
|
|
288
|
+
from superlocalmemory.mcp.tools_summaries import register_summary_tools
|
|
289
|
+
register_summary_tools(_target, get_engine) # v4.0.8 issue #113 summary reads
|
|
283
290
|
from superlocalmemory.mcp.tools_context import register_prestage_tool
|
|
284
291
|
|
|
285
292
|
|
|
@@ -75,11 +75,36 @@ def _graph_not_built_error() -> dict[str, Any]:
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def _bridge_is_enabled() -> bool:
|
|
79
|
+
"""Whether the code↔memory bridge is switched on in the saved settings."""
|
|
80
|
+
try:
|
|
81
|
+
cfg = _get_service()
|
|
82
|
+
if cfg is not None and getattr(cfg.config, "bridge_enabled", False):
|
|
83
|
+
return True
|
|
84
|
+
# No live service yet (e.g. a tool called before any build): read the
|
|
85
|
+
# saved settings directly rather than reporting "off" by default.
|
|
86
|
+
from superlocalmemory.code_graph.config import CodeGraphConfig
|
|
87
|
+
return bool(CodeGraphConfig.load().bridge_enabled)
|
|
88
|
+
except Exception:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
78
92
|
def _bridge_not_enabled_error() -> dict[str, Any]:
|
|
79
|
-
"""Standard error when bridge is not enabled.
|
|
93
|
+
"""Standard error when the code↔memory bridge is not enabled.
|
|
94
|
+
|
|
95
|
+
The remediation text used to name ``code_graph.bridge.enabled``, which is not
|
|
96
|
+
a key that exists anywhere. The real field is ``bridge_enabled`` in
|
|
97
|
+
``code_graph_config.json``, so anyone who followed this message edited
|
|
98
|
+
nothing that mattered. This helper was also never called from any tool, so
|
|
99
|
+
the message could not appear even when it was correct.
|
|
100
|
+
"""
|
|
80
101
|
return {
|
|
81
102
|
"success": False,
|
|
82
|
-
"error":
|
|
103
|
+
"error": (
|
|
104
|
+
'Code↔memory bridge not enabled. Set "bridge_enabled": true in '
|
|
105
|
+
"~/.superlocalmemory/code_graph_config.json, then run "
|
|
106
|
+
"build_code_graph. Links are created during background maintenance."
|
|
107
|
+
),
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
|
|
@@ -167,7 +192,13 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
167
192
|
p.strip() for p in exclude_patterns.split(",") if p.strip()
|
|
168
193
|
)
|
|
169
194
|
|
|
170
|
-
|
|
195
|
+
# Start from the user's saved settings, then apply this call's
|
|
196
|
+
# arguments. Previously this constructed CodeGraphConfig(**kwargs)
|
|
197
|
+
# from scratch, so every field the caller did not name reverted to a
|
|
198
|
+
# class default — including bridge_enabled, which the setup wizard
|
|
199
|
+
# writes to code_graph_config.json. A user who enabled the code graph
|
|
200
|
+
# during setup therefore had the flag on disk and off in every build.
|
|
201
|
+
config = CodeGraphConfig.load(**config_kwargs)
|
|
171
202
|
global _service
|
|
172
203
|
_service = CodeGraphService(config)
|
|
173
204
|
|
|
@@ -727,6 +758,11 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
727
758
|
"total_edges": stats.get("edges", 0),
|
|
728
759
|
"total_code_memory_links": total_links,
|
|
729
760
|
"stale_links": stale_links,
|
|
761
|
+
# Without this, total_code_memory_links == 0 is ambiguous: it
|
|
762
|
+
# means either "no memory mentions your code" or "the feature
|
|
763
|
+
# that creates links is switched off". Those call for opposite
|
|
764
|
+
# actions, so the reader has to be told which one it is.
|
|
765
|
+
"bridge_enabled": _bridge_is_enabled(),
|
|
730
766
|
"built": stats.get("built", False),
|
|
731
767
|
"db_path": stats.get("db_path", ""),
|
|
732
768
|
}
|
|
@@ -1514,6 +1550,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
1514
1550
|
if err is not None:
|
|
1515
1551
|
return err
|
|
1516
1552
|
|
|
1553
|
+
# Every answer this tool can give comes from code_memory_links, and
|
|
1554
|
+
# only the bridge populates that table. With the bridge off the query
|
|
1555
|
+
# returns an empty list, which reads as "nothing is stale" — the
|
|
1556
|
+
# strongest possible reassurance, produced by a feature that never
|
|
1557
|
+
# ran. Say so instead.
|
|
1558
|
+
if not _bridge_is_enabled():
|
|
1559
|
+
return _bridge_not_enabled_error()
|
|
1560
|
+
|
|
1517
1561
|
db = _get_db()
|
|
1518
1562
|
|
|
1519
1563
|
if scope == "all":
|
|
@@ -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
|