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
@@ -170,6 +170,7 @@ def serialize_recall_response(
170
170
  total_max: int = 12000,
171
171
  full: bool = False,
172
172
  include_source: bool = False,
173
+ include_marker: bool = False,
173
174
  ) -> tuple[list[dict], bool]:
174
175
  """Convert a RecallResponse into budgeted, source-disciplined dicts.
175
176
 
@@ -185,10 +186,33 @@ def serialize_recall_response(
185
186
  total_max: Total content char budget before stubs (config-driven).
186
187
  full: Bypass clamping/stubs (additive escape hatch).
187
188
  include_source: Return full source_content (else ≤280-char preview).
189
+ include_marker: Emit each result's HMAC usage marker. See below.
188
190
 
189
191
  Returns:
190
192
  (results, no_confident_match) — results is a list of dicts; the bool
191
193
  is the evidence-floor signal lifted from the response (additive).
194
+
195
+ THE MARKER, AND WHY IT WAS MISSING
196
+ ----------------------------------
197
+ ``run_recall`` sets ``result.marker`` on every result — an HMAC of the
198
+ fact id, computed on the hot path already. Until 4.0.8 **no serialiser
199
+ ever read it**, so the value was computed and discarded on every recall.
200
+
201
+ That one omission broke the entire closed learning loop. The
202
+ ``post_tool_outcome`` hook settles an outcome by finding a validated
203
+ ``slm:fact:<id>:<hmac8>`` marker in a later tool response; with markers
204
+ never reaching the agent it found nothing, every outcome settled at the
205
+ formula's 0.5 base, and the consequences were visible all the way out to
206
+ the dashboard: 162 outcomes at the default label, all 294 source-quality
207
+ observations at exactly 0.5, therefore ``alpha == beta`` for all 18
208
+ sources and "no quality signal has settled", and 165 bandit arms with 4
209
+ plays between them.
210
+
211
+ Off by default, and gated by the caller on ``session_id``. A marker costs
212
+ roughly 33 characters of the agent's context per result, and it can only
213
+ buy a signal when a ``pending_outcomes`` row exists to settle — which
214
+ happens only for session-bearing recalls. Spending context on an ad-hoc
215
+ recall that could never learn from it is pure waste.
192
216
  """
193
217
  memory_map = memory_map or {}
194
218
  # T-inject: one shared "now" so every result's age label is consistent.
@@ -200,7 +224,7 @@ def serialize_recall_response(
200
224
  _created = getattr(fact, "created_at", "") or ""
201
225
  fact_type = getattr(fact, "fact_type", None)
202
226
  lifecycle = getattr(fact, "lifecycle", None)
203
- raw.append({
227
+ entry = {
204
228
  "fact_id": fact.fact_id,
205
229
  "memory_id": fact.memory_id,
206
230
  "content": fact.content or "",
@@ -235,7 +259,15 @@ def serialize_recall_response(
235
259
  # weigh recency without doing date math. "" when undated.
236
260
  "age_label": relative_age(_created, _now),
237
261
  "evidence_chain": list(getattr(r, "evidence_chain", []) or []),
238
- })
262
+ }
263
+ # Only when asked, and only when the engine actually produced one —
264
+ # an empty key would be indistinguishable from a marker that failed
265
+ # to compute, and the hook validates before trusting anything anyway.
266
+ if include_marker:
267
+ marker = getattr(r, "marker", "") or ""
268
+ if marker:
269
+ entry["marker"] = marker
270
+ raw.append(entry)
239
271
 
240
272
  # F-3 source discipline, then F-2 budget — order matters (discipline first
241
273
  # so the template firewall runs before any preview slicing).
@@ -106,24 +106,68 @@ async def get_agent_memory_activity(
106
106
  conn = get_read_connection(DB_PATH)
107
107
  try:
108
108
  try:
109
- rows = conn.execute(
110
- "SELECT CASE WHEN trusted_actor_id='' THEN 'unknown' "
111
- "ELSE trusted_actor_id END AS agent_id, "
109
+ # Group by the agent's OWN identity, not by the capability that
110
+ # authorised the write.
111
+ #
112
+ # This grouped by ``trusted_actor_id``, which on a real store is
113
+ # a capability digest — 43 distinct agents rendered as 43 rows of
114
+ # ``daemon-capability:923b7d6e616f46d3...`` and not one readable
115
+ # name. For a pane whose entire purpose is telling agents apart,
116
+ # that is the same as showing nothing.
117
+ #
118
+ # The real name was already being stored the whole time: writers
119
+ # pass ``agent_id`` and it lands in ``raw_metadata_json``. On this
120
+ # store that yields claude-desktop, claude, gemini, codex, grok
121
+ # and mcp_client. Capability digests remain available per row for
122
+ # audit — they answer "what was allowed to write this", which is a
123
+ # different and also useful question, just not this pane's.
124
+ # raw_metadata_json is absent on stores predating it. Try the
125
+ # identity query and fall back to capability grouping if the
126
+ # column is missing — without this, the OperationalError is
127
+ # caught below and an older install shows ZERO agents while
128
+ # having plenty. Deliberately not PRAGMA table_info: this
129
+ # handler is on the dashboard read path, which is gated against
130
+ # anything that parses as DDL.
131
+ _tail = (
112
132
  "COUNT(*) AS cnt, MAX(created_at) AS last_active, "
113
- "GROUP_CONCAT(DISTINCT source_type) AS sources "
133
+ "GROUP_CONCAT(DISTINCT source_type) AS sources, "
134
+ "COUNT(DISTINCT NULLIF(trusted_actor_id, '')) AS capabilities "
114
135
  "FROM ingestion_operations WHERE profile_id=? "
115
136
  "GROUP BY agent_id ORDER BY cnt DESC, agent_id ASC "
116
- "LIMIT 500",
117
- (pid,),
118
- ).fetchall()
137
+ "LIMIT 500"
138
+ )
139
+ try:
140
+ rows = conn.execute(
141
+ "SELECT COALESCE("
142
+ " NULLIF(json_extract(raw_metadata_json, '$.agent_id'), ''),"
143
+ " NULLIF(trusted_actor_id, ''),"
144
+ " 'unknown'"
145
+ ") AS agent_id, " + _tail,
146
+ (pid,),
147
+ ).fetchall()
148
+ except sqlite3.OperationalError:
149
+ rows = conn.execute(
150
+ "SELECT COALESCE(NULLIF(trusted_actor_id, ''), 'unknown')"
151
+ " AS agent_id, " + _tail,
152
+ (pid,),
153
+ ).fetchall()
119
154
  for r in rows:
155
+ name = r["agent_id"]
120
156
  agents.append({
121
- "agent_id": r["agent_id"],
157
+ "agent_id": name,
122
158
  "count": r["cnt"],
123
159
  "last_active": r["last_active"],
124
160
  "source_types": (
125
161
  [s for s in (r["sources"] or "").split(",") if s]
126
162
  ),
163
+ # How many distinct capabilities this agent wrote under.
164
+ "capability_count": r["capabilities"],
165
+ # True when we fell back to a digest — the UI can then say
166
+ # "this writer did not identify itself" instead of
167
+ # presenting a hash as though it were a name.
168
+ "identified": not str(name).startswith(
169
+ ("daemon-capability:", "local-capability:")
170
+ ) and name != "unknown",
127
171
  })
128
172
  total += r["cnt"]
129
173
  except sqlite3.OperationalError:
@@ -1764,6 +1764,114 @@ async def patterns_deprecated(
1764
1764
  }
1765
1765
 
1766
1766
 
1767
+ @router.get("/bounded-loops/evidence",
1768
+ dependencies=[Depends(require_install_token)])
1769
+ async def bounded_loops_evidence(
1770
+ request: Request, profile_id: str | None = None, limit: int = 20,
1771
+ ) -> dict:
1772
+ """Terminal Bounded Loops runs this profile has observed.
1773
+
1774
+ Bounded Loops is a SEPARATE PRODUCT. SLM is one optional consumer of a
1775
+ document any MCP client can request over the published contract
1776
+ ``bounded-loops.dev/slm-bridge/v1``; neither product depends on the other,
1777
+ and installing either alone is complete.
1778
+
1779
+ What travels is deliberately narrow, and the pane must not imply otherwise:
1780
+
1781
+ * **Observation, not authorization.** ``eligible_for_learning`` is a hard
1782
+ field in the contract and is always ``False`` in v1. A SUCCEEDED run is
1783
+ not permission to retrain, re-rank or route on it. Nothing in SLM treats
1784
+ it as such, and this endpoint returns the flag so the UI can say so.
1785
+ * **Digests, not paths.** ``workspace_id`` is a hash precisely so a client's
1786
+ directory name never reaches a memory system. Gate reasons, artifact
1787
+ contents, commands and environment values are excluded at the source.
1788
+ * **``local_hash_chain_only``.** The receipt log is an append-only hash
1789
+ chain on local disk: tampering is detectable by anyone holding an earlier
1790
+ head. That is NOT authentication, notarization or independent audit, and
1791
+ calling it "verified" would claim a guarantee no part of the system
1792
+ provides.
1793
+ * **``demonstration``** separates real execution from a scripted replay. A
1794
+ demo run proves the wiring works and proves nothing about the work.
1795
+
1796
+ Read-only, off the hot path, and empty is a normal answer — most installs
1797
+ have no Bounded Loops at all.
1798
+ """
1799
+ profile_id = _authorized_profile(request, profile_id)
1800
+ limit = max(1, min(int(limit or 20), 100))
1801
+
1802
+ out: dict[str, Any] = {
1803
+ "contract": "bounded-loops.dev/slm-bridge/v1",
1804
+ "control_plane": "observation_only",
1805
+ "runs": [],
1806
+ "total": 0,
1807
+ "demonstration_count": 0,
1808
+ "installed": None,
1809
+ }
1810
+
1811
+ try:
1812
+ out["installed"] = bool(_compute_bounded_loops().get("installed"))
1813
+ except Exception: # pragma: no cover — presence probe must never 500
1814
+ out["installed"] = None
1815
+
1816
+ db_path = _learning_db_path()
1817
+ if not db_path.exists():
1818
+ return out
1819
+
1820
+ import sqlite3
1821
+
1822
+ try:
1823
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=3)
1824
+ conn.row_factory = sqlite3.Row
1825
+ try:
1826
+ rows = conn.execute(
1827
+ "SELECT run_id, run_ref, outcome, run_state, demonstration,"
1828
+ " eligible_for_learning, terminal_at, observed_at,"
1829
+ " receipt_sequence, receipt_trust, workspace_id, contract_id"
1830
+ " FROM external_evidence_receipts WHERE profile_id=?"
1831
+ " ORDER BY observed_at DESC LIMIT ?",
1832
+ (profile_id, limit),
1833
+ ).fetchall()
1834
+ total = conn.execute(
1835
+ "SELECT COUNT(*) AS n FROM external_evidence_receipts"
1836
+ " WHERE profile_id=?", (profile_id,),
1837
+ ).fetchone()["n"]
1838
+ demos = conn.execute(
1839
+ "SELECT COUNT(*) AS n FROM external_evidence_receipts"
1840
+ " WHERE profile_id=? AND demonstration=1", (profile_id,),
1841
+ ).fetchone()["n"]
1842
+ finally:
1843
+ conn.close()
1844
+ except sqlite3.Error as exc:
1845
+ # The table only exists once the bridge has been used. Absent is a
1846
+ # normal state, not an error, and must not surface as a failed pane.
1847
+ logger.debug("bounded-loops evidence unavailable: %s", exc)
1848
+ return out
1849
+
1850
+ out["total"] = total
1851
+ out["demonstration_count"] = demos
1852
+ out["runs"] = [
1853
+ {
1854
+ "run_id": r["run_id"],
1855
+ "run_ref": r["run_ref"],
1856
+ "outcome": r["outcome"],
1857
+ # Both, because the mapping to three buckets loses information: a
1858
+ # HALTED run (budget/policy stop) and a FAILED run (work the gate
1859
+ # rejected) are different events.
1860
+ "run_state": r["run_state"],
1861
+ "demonstration": bool(r["demonstration"]),
1862
+ "eligible_for_learning": bool(r["eligible_for_learning"]),
1863
+ "terminal_at": r["terminal_at"],
1864
+ "observed_at": r["observed_at"],
1865
+ "receipt_sequence": r["receipt_sequence"],
1866
+ "trust": r["receipt_trust"],
1867
+ "workspace_id": r["workspace_id"],
1868
+ "contract": r["contract_id"],
1869
+ }
1870
+ for r in rows
1871
+ ]
1872
+ return out
1873
+
1874
+
1767
1875
  @router.get("/behavioral",
1768
1876
  dependencies=[Depends(require_install_token)])
1769
1877
  async def behavioral_deprecated(
@@ -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."""
@@ -1061,6 +1214,7 @@ async def get_fact_detail(request: Request, fact_id: str):
1061
1214
  )
1062
1215
  except Exception:
1063
1216
  row["canonical_entities"] = []
1217
+ row["code_links"] = _code_links_for_fact(fact_id)
1064
1218
  return row
1065
1219
  except HTTPException:
1066
1220
  raise
@@ -1068,6 +1222,66 @@ async def get_fact_detail(request: Request, fact_id: str):
1068
1222
  raise _internal_error("Fact detail error")
1069
1223
 
1070
1224
 
1225
+ def _code_links_for_fact(fact_id: str) -> list[dict]:
1226
+ """Code entities this fact mentions, from the code graph.
1227
+
1228
+ Fail-open by design: this is a display extra on a detail popup. No code graph
1229
+ built, bridge switched off, or database missing all mean "no section shown",
1230
+ never an error on the fact itself. A user who has never touched the code
1231
+ graph must not see a failure because of a feature they do not use.
1232
+
1233
+ Reads code_graph.db, which is a separate database from memory.db and is never
1234
+ opened by the recall path — so nothing here can affect recall.
1235
+ """
1236
+ try:
1237
+ from superlocalmemory.code_graph.config import CodeGraphConfig
1238
+
1239
+ cfg = CodeGraphConfig.load()
1240
+ if not (cfg.enabled and cfg.bridge_enabled):
1241
+ return []
1242
+ db_path = cfg.get_db_path()
1243
+ if not db_path.exists():
1244
+ return []
1245
+
1246
+ import sqlite3
1247
+
1248
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
1249
+ try:
1250
+ conn.row_factory = dict_factory
1251
+ rows = conn.execute(
1252
+ "SELECT cml.link_type, cml.confidence, cml.is_stale, "
1253
+ " cml.enriched_description, "
1254
+ " gn.name, gn.qualified_name, gn.kind, gn.file_path "
1255
+ "FROM code_memory_links cml "
1256
+ "LEFT JOIN graph_nodes gn ON gn.node_id = cml.code_node_id "
1257
+ "WHERE cml.slm_fact_id = ? "
1258
+ "ORDER BY cml.confidence DESC, gn.qualified_name",
1259
+ (fact_id,),
1260
+ ).fetchall()
1261
+ finally:
1262
+ conn.close()
1263
+
1264
+ return [
1265
+ {
1266
+ "name": r.get("name") or "",
1267
+ "qualified_name": r.get("qualified_name") or "",
1268
+ "kind": r.get("kind") or "",
1269
+ "file_path": r.get("file_path") or "",
1270
+ "link_type": r.get("link_type") or "mentions",
1271
+ "confidence": r.get("confidence"),
1272
+ "is_stale": bool(r.get("is_stale")),
1273
+ "description": r.get("enriched_description") or "",
1274
+ }
1275
+ for r in rows
1276
+ # A link whose node is gone (LEFT JOIN produced no row) is a stale
1277
+ # pointer, not something to render as a blank entry.
1278
+ if r.get("qualified_name")
1279
+ ]
1280
+ except Exception:
1281
+ logger.debug("code links unavailable for %s", fact_id, exc_info=True)
1282
+ return []
1283
+
1284
+
1071
1285
  @router.delete("/api/memories/{fact_id}")
1072
1286
  async def delete_memory(request: Request, fact_id: str):
1073
1287
  """Delete a specific memory (atomic fact) by ID."""
@@ -1932,55 +1932,33 @@ async def trigger_consolidation(request: Request):
1932
1932
  profile_id=pid,
1933
1933
  )
1934
1934
 
1935
- # v3.7.8 SEC-M-01: the prior "WorkerPool" fast path called
1936
- # ``pool.send_command(...)``, a method that does not exist on
1937
- # WorkerPool every call raised AttributeError, was silently
1938
- # swallowed by the bare ``except``, and fell through to this direct
1939
- # path unconditionally. That dead branch is removed; consolidation
1940
- # always runs directly against a lease-protected DB connection so a
1941
- # concurrent profile switch cannot commit mid-consolidation.
1942
- #
1943
- # v3.4.64: ConsolidationEngine.consolidate() is CPU/IO bound (seconds
1944
- # to minutes). Calling it directly in an async route blocks the ASGI
1945
- # event loop. Moved into asyncio.to_thread() so the event loop stays
1946
- # live. The runtime.operation() lease is acquired INSIDE the thread —
1947
- # blocking a thread is fine; blocking the event loop is not.
1948
- import asyncio as _asyncio
1949
- from superlocalmemory.core.config import SLMConfig
1950
- from superlocalmemory.storage.database import DatabaseManager
1951
- from superlocalmemory.storage import schema as _schema
1952
- from superlocalmemory.core.consolidation_engine import ConsolidationEngine
1953
- from superlocalmemory.server.profile_runtime import get_profile_runtime
1954
-
1955
- _app_state = request.app.state
1935
+ # 4.0.8: the body of this handler moved to server/consolidation_runner
1936
+ # so the periodic daemon trigger and this endpoint run the SAME code
1937
+ # under the SAME lock. Two copies would be two definitions of
1938
+ # "consolidated", and only one of them would get maintained.
1939
+ from superlocalmemory.server.consolidation_runner import (
1940
+ run_full_consolidation,
1941
+ )
1956
1942
 
1957
- def _run_consolidation() -> dict:
1958
- runtime = get_profile_runtime(_app_state)
1959
- with runtime.operation():
1960
- config = SLMConfig.load()
1961
- db = DatabaseManager(config.db_path)
1962
- db.initialize(_schema)
1963
- engine = ConsolidationEngine(
1964
- db=db, config=config.consolidation, slm_config=config,
1943
+ # background=true returns as soon as the pass is scheduled. The
1944
+ # session-end hook needs this: a full consolidation runs for seconds to
1945
+ # minutes, and a hook that waits for it either blocks the user's shell
1946
+ # or times out and wrongly concludes the run failed.
1947
+ if body.get("background"):
1948
+ import asyncio as _asyncio
1949
+
1950
+ _app_state = request.app.state
1951
+ _asyncio.create_task(
1952
+ run_full_consolidation(
1953
+ _app_state, pid, lightweight=lightweight, trigger="hook",
1965
1954
  )
1966
- res = engine.consolidate(profile_id=pid, lightweight=lightweight)
1967
- # v3.4.1: Auto-trigger behavioral pattern mining after consolidation
1968
- try:
1969
- from superlocalmemory.learning.consolidation_worker import (
1970
- ConsolidationWorker,
1971
- )
1972
- learning_db = config.base_dir / "learning.db"
1973
- cw = ConsolidationWorker(str(config.db_path), str(learning_db))
1974
- pattern_count = cw._generate_patterns(pid, False)
1975
- res["patterns_mined"] = pattern_count
1976
- logger.info(
1977
- "Auto-mined %d patterns after consolidation", pattern_count
1978
- )
1979
- except Exception as exc:
1980
- logger.debug("Pattern mining after consolidation failed: %s", exc)
1981
- return res
1955
+ )
1956
+ authorization.complete()
1957
+ return {"success": True, "started": True, "background": True}
1982
1958
 
1983
- result = await _asyncio.to_thread(_run_consolidation)
1959
+ result = await run_full_consolidation(
1960
+ request.app.state, pid, lightweight=lightweight, trigger="http",
1961
+ )
1984
1962
  authorization.complete()
1985
1963
  return {"success": True, **result}
1986
1964
  except HTTPException: