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.
Files changed (63) hide show
  1. package/CHANGELOG.md +128 -4
  2. package/README.md +6 -11
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/cli/commands.py +14 -0
  27. package/src/superlocalmemory/cli/main.py +9 -0
  28. package/src/superlocalmemory/cli/summary_cmd.py +215 -0
  29. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
  30. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
  31. package/src/superlocalmemory/code_graph/bridge/maintenance.py +212 -0
  32. package/src/superlocalmemory/code_graph/config.py +65 -1
  33. package/src/superlocalmemory/core/consolidation_engine.py +14 -15
  34. package/src/superlocalmemory/core/fact_consolidator.py +24 -1
  35. package/src/superlocalmemory/core/maintenance.py +51 -1
  36. package/src/superlocalmemory/core/recall_worker.py +4 -0
  37. package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
  38. package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
  39. package/src/superlocalmemory/learning/pattern_miner.py +12 -7
  40. package/src/superlocalmemory/mcp/profiles.py +10 -3
  41. package/src/superlocalmemory/mcp/server.py +7 -0
  42. package/src/superlocalmemory/mcp/tools_code_graph.py +47 -3
  43. package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
  44. package/src/superlocalmemory/server/consolidation_runner.py +140 -0
  45. package/src/superlocalmemory/server/recall_serializer.py +34 -2
  46. package/src/superlocalmemory/server/routes/agents.py +52 -8
  47. package/src/superlocalmemory/server/routes/brain.py +108 -0
  48. package/src/superlocalmemory/server/routes/memories.py +214 -0
  49. package/src/superlocalmemory/server/routes/v3_api.py +24 -46
  50. package/src/superlocalmemory/server/unified_daemon.py +107 -0
  51. package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
  52. package/src/superlocalmemory/summaries/base.py +159 -0
  53. package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
  54. package/src/superlocalmemory/summaries/project_work_log.py +23 -7
  55. package/src/superlocalmemory/summaries/session_summary.py +9 -5
  56. package/src/superlocalmemory/ui/index.html +10 -4
  57. package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
  58. package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
  59. package/src/superlocalmemory/ui/js/od-memories.js +337 -12
  60. package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
  61. package/src/superlocalmemory/ui/js/od-operations.js +1 -150
  62. package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
  63. 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=Path(self._db_path),
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
- """Run cognitive consolidation if last run was >24h ago. Non-blocking."""
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
- # Update timestamp FIRST to prevent concurrent runs
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
- # Run consolidation in background (don't block session end)
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 fact_a, fact_b, co_access_count "
379
+ "SELECT fact_id_a, fact_id_b, co_count "
372
380
  "FROM co_retrieval_edges "
373
- "WHERE profile_id = ? AND co_access_count >= 3 "
374
- "ORDER BY co_access_count DESC LIMIT 20",
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({ # 16
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({ # 31
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 # 49
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": "Bridge not enabled. Set code_graph.bridge.enabled = true in config.",
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
- config = CodeGraphConfig(**config_kwargs)
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