superlocalmemory 4.0.5 → 4.0.7

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 (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +8 -9
  3. package/package.json +3 -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/access/rbac.py +106 -0
  27. package/src/superlocalmemory/brain/truth.py +80 -10
  28. package/src/superlocalmemory/cli/__main__.py +17 -0
  29. package/src/superlocalmemory/cli/commands.py +28 -3
  30. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  31. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  32. package/src/superlocalmemory/cli/main.py +85 -0
  33. package/src/superlocalmemory/cli/summary_cmd.py +195 -0
  34. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
  35. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
  37. package/src/superlocalmemory/code_graph/config.py +65 -1
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  44. package/src/superlocalmemory/core/fact_consolidator.py +24 -1
  45. package/src/superlocalmemory/core/maintenance.py +93 -1
  46. package/src/superlocalmemory/core/recall_worker.py +33 -12
  47. package/src/superlocalmemory/infra/backup.py +138 -0
  48. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  49. package/src/superlocalmemory/learning/engagement.py +165 -0
  50. package/src/superlocalmemory/mcp/tools_code_graph.py +78 -7
  51. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  52. package/src/superlocalmemory/retrieval/engine.py +21 -0
  53. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  54. package/src/superlocalmemory/server/routes/brain.py +283 -15
  55. package/src/superlocalmemory/server/routes/learning.py +13 -25
  56. package/src/superlocalmemory/server/routes/memories.py +61 -0
  57. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  58. package/src/superlocalmemory/storage/database.py +36 -0
  59. package/src/superlocalmemory/storage/models.py +12 -4
  60. package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
  61. package/src/superlocalmemory/summaries/__init__.py +37 -0
  62. package/src/superlocalmemory/summaries/base.py +108 -0
  63. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  64. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  65. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  66. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  67. package/src/superlocalmemory/ui/index.html +29 -12
  68. package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
  69. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  70. package/src/superlocalmemory/ui/js/od-brain.js +257 -77
  71. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -0,0 +1,307 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Session Summary — issue #113 bounded summary.
6
+
7
+ CRITICAL DATA HONESTY NOTE
8
+ ---------------------------
9
+ On a real 3,294-fact store, only 127 facts (3.9%) carry a session_id.
10
+ A Session Summary that presents itself as "everything you did this session"
11
+ while silently covering 4% of the facts is the same overclaiming Wave 4
12
+ removed from brain/truth.py.
13
+
14
+ Coverage is ALWAYS disclosed. If the session has too few facts to summarise
15
+ meaningfully we say so; we never silently return partial data as complete.
16
+
17
+ DETERMINISTIC FALLBACK IS MANDATORY
18
+ -------------------------------------
19
+ Mode A users have no LLM at all. Mode B/C users lose theirs whenever Ollama
20
+ or the network is down. Every call to generate_session_summary() MUST return
21
+ a SummaryResult. It may say the data is insufficient; it may never return
22
+ None or an empty result. The extractive fallback path is always active.
23
+
24
+ HOT PATH EXCLUSION
25
+ -------------------
26
+ This module must NEVER be imported from core/recall_pipeline.py or
27
+ core/store_pipeline.py. Summary generation performs multi-table reads and
28
+ optional LLM calls. It belongs in background maintenance or on explicit
29
+ user request only.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import sqlite3
36
+ from datetime import date, timezone
37
+ from pathlib import Path
38
+
39
+ from .base import (
40
+ COVERAGE_FULL,
41
+ COVERAGE_INSUFFICIENT,
42
+ COVERAGE_NO_SESSION,
43
+ COVERAGE_PARTIAL,
44
+ COVERAGE_UNAVAILABLE,
45
+ GENERATED_BY_EXTRACTIVE,
46
+ GENERATED_BY_LLM_B,
47
+ GENERATED_BY_LLM_C,
48
+ SummaryResult,
49
+ get_mode_str,
50
+ )
51
+
52
+ logger = logging.getLogger("superlocalmemory.summaries.session")
53
+
54
+ # Minimum number of session facts to attempt a meaningful summary.
55
+ # Below this threshold we return coverage=insufficient and a stub.
56
+ _MIN_FACTS = 3
57
+
58
+ # Maximum facts to include in the content body (extractive mode).
59
+ _BODY_FACTS = 7
60
+
61
+ # Maximum character length for a single fact in the body.
62
+ _MAX_FACT_CHARS = 250
63
+
64
+
65
+ def generate_session_summary(
66
+ db_path: str | Path,
67
+ session_id: str,
68
+ profile_id: str = "default",
69
+ config: object | None = None,
70
+ ) -> SummaryResult:
71
+ """Generate a Session Summary for a specific session.
72
+
73
+ COVERAGE DISCLOSURE: session data is sparse on real stores (~3.9% of
74
+ facts carry a session_id). The returned SummaryResult.coverage is always
75
+ set to an accurate value — never "full" unless the session is truly
76
+ complete.
77
+
78
+ Args:
79
+ db_path: Path to memory.db.
80
+ session_id: The session identifier (atomic_facts.session_id).
81
+ profile_id: Profile scope — never mix profiles.
82
+ config: Optional SLMConfig for LLM mode (B/C). None = Mode A
83
+ (extractive only, always deterministic).
84
+
85
+ Returns:
86
+ SummaryResult with source_fact_ids for every contributing fact.
87
+ Never returns None.
88
+
89
+ Note:
90
+ This function is NOT on the hot path. It belongs in background
91
+ maintenance or on explicit user request, never inside recall or store.
92
+ """
93
+ db_path = Path(db_path)
94
+
95
+ # ── query ────────────────────────────────────────────────────────────────
96
+ try:
97
+ conn = sqlite3.connect(str(db_path), timeout=5.0)
98
+ conn.row_factory = sqlite3.Row
99
+ conn.execute("PRAGMA query_only=ON")
100
+ try:
101
+ rows = conn.execute(
102
+ """
103
+ SELECT fact_id, content, created_at, importance, lifecycle
104
+ FROM atomic_facts
105
+ WHERE profile_id = ?
106
+ AND session_id = ?
107
+ AND lifecycle != 'archived'
108
+ ORDER BY importance DESC, created_at ASC
109
+ """,
110
+ (profile_id, session_id),
111
+ ).fetchall()
112
+ finally:
113
+ conn.close()
114
+ except Exception as exc:
115
+ logger.warning("session summary query failed for %s: %s", session_id, exc)
116
+ return SummaryResult(
117
+ kind="session",
118
+ profile_id=profile_id,
119
+ content=(
120
+ f"Session summary for '{session_id}' is unavailable: "
121
+ f"data access error."
122
+ ),
123
+ source_fact_ids=[],
124
+ coverage=COVERAGE_UNAVAILABLE,
125
+ generated_by=GENERATED_BY_EXTRACTIVE,
126
+ metadata={"session_id": session_id, "error": str(exc)},
127
+ )
128
+
129
+ # ── coverage decision ─────────────────────────────────────────────────────
130
+ if not rows:
131
+ return SummaryResult(
132
+ kind="session",
133
+ profile_id=profile_id,
134
+ content=(
135
+ f"No facts found for session '{session_id}'. "
136
+ f"Note: only a small fraction of facts carry a session_id "
137
+ f"on the current store — coverage is inherently partial."
138
+ ),
139
+ source_fact_ids=[],
140
+ coverage=COVERAGE_NO_SESSION,
141
+ generated_by=GENERATED_BY_EXTRACTIVE,
142
+ metadata={"session_id": session_id, "fact_count": 0},
143
+ )
144
+
145
+ facts = [dict(r) for r in rows]
146
+ source_fact_ids = [f["fact_id"] for f in facts]
147
+ fact_count = len(facts)
148
+
149
+ if fact_count < _MIN_FACTS:
150
+ coverage = COVERAGE_INSUFFICIENT
151
+ else:
152
+ # Session summaries are structurally partial: they cover only the facts
153
+ # that happened to carry a session_id. On a real store that is ~3.9%
154
+ # of the corpus. Reporting "full" would be dishonest.
155
+ coverage = COVERAGE_PARTIAL
156
+
157
+ # ── extractive summary (deterministic, always available) ──────────────────
158
+ extractive_content = _build_extractive_content(session_id, facts, fact_count)
159
+
160
+ # ── LLM enrichment (optional) ─────────────────────────────────────────────
161
+ mode = get_mode_str(config)
162
+ if fact_count >= _MIN_FACTS and mode in ("b", "c"):
163
+ llm_content, llm_mode = _try_llm(
164
+ f"Summarise the key activities in session {session_id}",
165
+ facts,
166
+ config,
167
+ mode,
168
+ )
169
+ if llm_content:
170
+ return SummaryResult(
171
+ kind="session",
172
+ profile_id=profile_id,
173
+ content=llm_content,
174
+ source_fact_ids=source_fact_ids,
175
+ coverage=coverage,
176
+ generated_by=llm_mode,
177
+ metadata={"session_id": session_id, "fact_count": fact_count},
178
+ )
179
+
180
+ return SummaryResult(
181
+ kind="session",
182
+ profile_id=profile_id,
183
+ content=extractive_content,
184
+ source_fact_ids=source_fact_ids,
185
+ coverage=coverage,
186
+ generated_by=GENERATED_BY_EXTRACTIVE,
187
+ metadata={"session_id": session_id, "fact_count": fact_count},
188
+ )
189
+
190
+
191
+ # ── helpers ──────────────────────────────────────────────────────────────────
192
+
193
+ def _build_extractive_content(
194
+ session_id: str,
195
+ facts: list[dict],
196
+ fact_count: int,
197
+ ) -> str:
198
+ """Build a deterministic extractive summary from session facts.
199
+
200
+ Coverage is disclosed in the header. Top facts by importance are listed.
201
+ This is the fallback path — it must never fail or return an empty string.
202
+ """
203
+ lines = [
204
+ f"Session: {session_id}",
205
+ f"Facts recorded: {fact_count}",
206
+ f"Coverage: partial (session facts are a subset of the full store)",
207
+ "",
208
+ "Top recorded facts:",
209
+ ]
210
+ for f in facts[:_BODY_FACTS]:
211
+ content = f.get("content", "")
212
+ if len(content) > _MAX_FACT_CHARS:
213
+ content = content[:_MAX_FACT_CHARS - 3] + "..."
214
+ lines.append(f" - {content}")
215
+ if fact_count > _BODY_FACTS:
216
+ lines.append(f" ... and {fact_count - _BODY_FACTS} more facts.")
217
+ return "\n".join(lines)
218
+
219
+
220
+ def _try_llm(
221
+ prompt: str,
222
+ facts: list[dict],
223
+ config: object | None,
224
+ mode: str,
225
+ ) -> tuple[str | None, str]:
226
+ """Attempt LLM summarisation. Returns (content, generated_by) or (None, extractive)."""
227
+ if mode == "c":
228
+ result = _call_cloud_llm(prompt, facts, config)
229
+ if result:
230
+ return result, GENERATED_BY_LLM_C
231
+ # fall through to Mode B
232
+ if mode in ("b", "c"):
233
+ result = _call_ollama(prompt, facts, config)
234
+ if result:
235
+ return result, GENERATED_BY_LLM_B
236
+ return None, GENERATED_BY_EXTRACTIVE
237
+
238
+
239
+ def _call_ollama(
240
+ prompt: str,
241
+ facts: list[dict],
242
+ config: object | None,
243
+ ) -> str | None:
244
+ """Mode B: call Ollama. Returns None on any failure."""
245
+ try:
246
+ import json
247
+ import urllib.request
248
+
249
+ api_base = "http://localhost:11434"
250
+ model = "llama3.2"
251
+ timeout = 30
252
+ if config and hasattr(config, "llm"):
253
+ api_base = getattr(config.llm, "api_base", api_base) or api_base
254
+ model = getattr(config.llm, "model", model) or model
255
+ timeout = (
256
+ getattr(config.llm, "timeout_seconds", None)
257
+ or getattr(config.llm, "timeout", None)
258
+ or timeout
259
+ )
260
+
261
+ fact_texts = "\n".join(f"- {f['content']}" for f in facts[:10])
262
+ full_prompt = f"{prompt}\n\nFacts:\n{fact_texts}\n\nRespond in 2-4 sentences."
263
+ payload = json.dumps({
264
+ "model": model,
265
+ "prompt": full_prompt,
266
+ "stream": False,
267
+ "options": {"num_predict": 200},
268
+ }).encode()
269
+ req = urllib.request.Request(
270
+ f"{api_base}/api/generate",
271
+ data=payload,
272
+ headers={"Content-Type": "application/json"},
273
+ )
274
+ resp = urllib.request.urlopen(req, timeout=timeout)
275
+ data = json.loads(resp.read().decode())
276
+ text = data.get("response", "").strip()
277
+ return text if text and len(text) > 20 else None
278
+ except Exception as exc:
279
+ logger.debug("Ollama session summary failed: %s", exc)
280
+ return None
281
+
282
+
283
+ def _call_cloud_llm(
284
+ prompt: str,
285
+ facts: list[dict],
286
+ config: object | None,
287
+ ) -> str | None:
288
+ """Mode C: call the configured cloud LLM. Returns None on any failure."""
289
+ if not config or not hasattr(config, "llm"):
290
+ return None
291
+ try:
292
+ from superlocalmemory.llm.backbone import LLMBackbone
293
+ llm = LLMBackbone(config.llm)
294
+ if not llm.is_available():
295
+ return None
296
+ fact_texts = "\n".join(f"- {f['content']}" for f in facts[:10])
297
+ full_prompt = f"{prompt}\n\nFacts:\n{fact_texts}\n\nRespond in 2-4 sentences."
298
+ text = llm.generate(
299
+ prompt=full_prompt,
300
+ system="You are a concise memory summariser.",
301
+ max_tokens=200,
302
+ temperature=0.1,
303
+ )
304
+ return text.strip() if text and len(text.strip()) > 20 else None
305
+ except Exception as exc:
306
+ logger.debug("Cloud LLM session summary failed: %s", exc)
307
+ return None
@@ -135,6 +135,12 @@ a:hover { text-decoration: underline; }
135
135
  button { font-family: inherit; }
136
136
  ::selection { background: var(--violet-soft); }
137
137
  .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
138
+ /* Monospace values are usually paths, ids or hashes — reference data, not
139
+ prose. Left unbounded they set the width of whatever contains them:
140
+ #dashboard-basedir alone rendered 219px and pushed the Dashboard pane past
141
+ its own right edge. Clip instead of overflowing; the full value stays
142
+ available via the title attribute wherever these are rendered. */
143
+ .mono { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
138
144
  .num { font-variant-numeric: tabular-nums; }
139
145
  .muted { color: var(--fg-2); }
140
146
  .dim { color: var(--fg-3); }
@@ -424,7 +430,15 @@ button { font-family: inherit; }
424
430
  /* ============================================================
425
431
  TABLES / LISTS
426
432
  ============================================================ */
427
- .tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
433
+ /* A wide table scrolls INSIDE its own card at any width, not only on phones.
434
+ The ≤768px block already did exactly this; the rule was simply never applied
435
+ above that breakpoint, so between roughly 800px and 1200px a six-column
436
+ table (Memories renders 783px of columns) overflowed its pane instead. The
437
+ pane does technically scroll, but an inner scroll region with no visible
438
+ affordance reads as "the page is cut off on the right" — which is precisely
439
+ how it was reported. Keeping the scroll inside the card makes the clipped
440
+ edge belong to the table, where a horizontal scrollbar is expected. */
441
+ .tbl { width: 100%; border-collapse: collapse; font-size: 13px; display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; }
428
442
  .tbl th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-3); font-weight: 600; padding: 10px 14px; border-bottom: 1px solid var(--border); }
429
443
  .tbl td { padding: 12px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }
430
444
  .tbl tr:last-child td { border-bottom: 0; }
@@ -615,6 +629,67 @@ button { font-family: inherit; }
615
629
  .kpi-strip { grid-template-columns: 1fr !important; }
616
630
  }
617
631
 
632
+ /* ---- Knowledge-graph panel toggle (≤1100px only) -------------------------
633
+ #odg-panel-toggle: "Details & Chat ↓" floats at centre-bottom of the
634
+ graph stage. Tells the user the stacked inspector / Ask-your-memory panel
635
+ exists and scrolls it into view on click.
636
+ .odg-back-btn: companion at the top of the stacked inspector; scrolls back
637
+ to the graph stage.
638
+ Both are hidden by default. Only revealed at ≤1100px where the inspector
639
+ stacks below the stage instead of sitting beside it in the two-column grid.
640
+ The existing @media (max-width:1100px) block above is NOT modified —
641
+ these are additive rules for the new elements only. -------------------- */
642
+ #odg-panel-toggle,
643
+ .odg-back-btn { display: none; }
644
+
645
+ @media (max-width: 1100px) {
646
+ /* Floating affordance pinned to centre-bottom of the graph stage overlay */
647
+ #odg-panel-toggle {
648
+ display: inline-flex;
649
+ align-items: center;
650
+ gap: 6px;
651
+ position: absolute;
652
+ bottom: 16px;
653
+ left: 50%;
654
+ transform: translateX(-50%);
655
+ z-index: 10;
656
+ height: 36px;
657
+ padding: 0 20px;
658
+ border-radius: var(--r-pill);
659
+ border: 1px solid var(--border-strong);
660
+ background: var(--card);
661
+ color: var(--fg);
662
+ font-size: 13px;
663
+ font-weight: 600;
664
+ cursor: pointer;
665
+ box-shadow: var(--sh-md);
666
+ white-space: nowrap;
667
+ transition: background .15s, color .15s, border-color .15s;
668
+ }
669
+ #odg-panel-toggle:hover { background: var(--violet); color: var(--violet-fg); border-color: transparent; }
670
+ #odg-panel-toggle:focus-visible { outline: 2px solid var(--violet); outline-offset: 3px; }
671
+
672
+ /* Companion button at top of stacked inspector */
673
+ .odg-back-btn {
674
+ display: flex;
675
+ align-items: center;
676
+ justify-content: center;
677
+ gap: 6px;
678
+ width: 100%;
679
+ padding: 9px 16px;
680
+ border: 0;
681
+ border-bottom: 1px solid var(--border);
682
+ background: var(--card-2);
683
+ color: var(--fg-2);
684
+ font-size: 12.5px;
685
+ font-weight: 600;
686
+ cursor: pointer;
687
+ transition: background .15s, color .15s;
688
+ }
689
+ .odg-back-btn:hover { background: var(--violet-soft); color: var(--violet); }
690
+ .odg-back-btn:focus-visible { outline: 2px solid var(--violet); outline-offset: -2px; }
691
+ }
692
+
618
693
  /* reduced motion */
619
694
  @media (prefers-reduced-motion: reduce) {
620
695
  * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
@@ -1508,10 +1508,13 @@
1508
1508
 
1509
1509
  <!-- Cytoscape.js REMOVED in v3.4.1 — replaced by Sigma.js WebGL -->
1510
1510
 
1511
- <!-- Sigma.js v3 + graphology (vendored) -->
1512
- <script src="static/vendor/graphology.umd.min.js"></script>
1513
- <script src="static/vendor/graphology-library.min.js"></script>
1514
- <script src="static/vendor/sigma.min.js"></script>
1511
+ <!-- Sigma.js + graphology RETIRED in 4.0.6 along with js/knowledge-graph.js.
1512
+ The live knowledge graph (js/od-graph.js) is a canvas-2D renderer with
1513
+ its own force simulation and never constructed a Sigma instance — on a
1514
+ running daemon window.sigmaInstance and window.sigmaGraph were both
1515
+ null while the graph was on screen. The remaining references to Sigma
1516
+ helpers in graph-event-bus.js and memory-chat.js are all behind
1517
+ `typeof … === 'function'` guards and degrade silently. -->
1515
1518
 
1516
1519
  <!-- Modular JS (v2.5 — split from monolith app.js) -->
1517
1520
  <script src="static/js/core.js"></script>
@@ -1524,8 +1527,14 @@
1524
1527
 
1525
1528
  <!-- v3.4.1: Event bus (must load before graph + chat) -->
1526
1529
  <script src="static/js/graph-event-bus.js"></script>
1527
- <!-- v3.4.1: Sigma.js Knowledge Graph (defines globals: filterState, loadGraph, renderGraph) -->
1528
- <script src="static/js/knowledge-graph.js"></script>
1530
+ <!-- js/knowledge-graph.js RETIRED in 4.0.6. It rendered into #graph-tab,
1531
+ which measured 0x0 with 0 children on a live daemon, and it logged
1532
+ "[Sigma] Container hidden (0 dimensions), deferring render" on every
1533
+ load without ever resuming — then reported "Graph already rendered".
1534
+ The live renderer is js/od-graph.js, which owns #odg-stage.
1535
+ It also defined window.loadGraph; od-graph.js now provides that shim
1536
+ (core.js:552 calls it unguarded on init). filterState/renderGraph are
1537
+ only consumed behind typeof guards in clusters.js and modal.js. -->
1529
1538
  <!-- Shared UI utilities (showLoadingSpinner, updateGraphStats, etc.) -->
1530
1539
  <script src="static/js/graph-ui.js"></script>
1531
1540
  <!-- Filter functions (filterByCluster, filterByEntity — used by clusters.js, modal.js) -->
@@ -1546,8 +1555,16 @@
1546
1555
  <script src="static/js/settings.js"></script>
1547
1556
  <script src="static/js/events.js"></script>
1548
1557
  <script src="static/js/agents.js"></script>
1549
- <!-- Brain tab (v3.4.21 XSS-safe DOM, token-gated, strict CSP on /api/v3/brain) -->
1550
- <script src="static/js/brain.js?v=380" defer></script>
1558
+ <!-- Brain tab: rendered by od-brain.js (loaded below).
1559
+ js/brain.js (the v3.4.21 implementation) was RETIRED in 4.0.6. Both files
1560
+ were being loaded and both rendered into the Brain pane — od-brain.js into
1561
+ #brain-pane, brain.js into #brain-content nested inside it — so the pane's
1562
+ copy came from whichever wrote last. od-brain.js already defines
1563
+ window.loadBrain (see its "Legacy compatibility" shim) and, loading second,
1564
+ already won that global; but brain.js also bound its own click handlers to
1565
+ its internal closure, so it kept re-rendering the pane behind od-brain.js.
1566
+ The file is retained on disk for one release in case a rollback is needed. -->
1567
+
1551
1568
  <script src="static/js/feedback.js"></script>
1552
1569
  <script src="static/js/lifecycle.js"></script>
1553
1570
  <script src="static/js/compliance.js"></script>
@@ -1558,7 +1575,7 @@
1558
1575
  <script src="static/js/math-health.js"></script>
1559
1576
  <script src="static/js/auto-settings.js"></script>
1560
1577
  <script src="static/js/ide-status.js"></script>
1561
- <script src="static/js/fact-detail.js"></script>
1578
+ <script src="static/js/fact-detail.js?v=e9076ee3"></script>
1562
1579
 
1563
1580
  <!-- Neural Glass shell (v3.4.21 restructured) -->
1564
1581
  <script src="static/js/ng-health.js?v=345"></script>
@@ -1574,18 +1591,18 @@
1574
1591
  <script src="static/js/event-delegation.js?v=379"></script>
1575
1592
  <!-- OD screen modules (v3.7.9): approved-design panes wired to live data.
1576
1593
  Load LAST so their window.load<Screen> overrides win over the legacy loaders. -->
1577
- <script src="static/js/od-brain.js?v=379"></script>
1594
+ <script src="static/js/od-brain.js?v=4f233c96"></script>
1578
1595
  <script src="static/js/od-components.js?v=382"></script>
1579
1596
  <script src="static/js/od-health.js?v=382"></script>
1580
1597
  <script src="static/js/od-operations.js?v=380"></script>
1581
1598
  <script src="static/js/od-compliance-ext.js?v=100"></script>
1582
1599
  <script src="static/js/od-ops-health.js?v=400"></script>
1583
1600
  <script src="static/js/od-team.js?v=379"></script>
1584
- <script src="static/js/od-graph.js?v=379"></script>
1601
+ <script src="static/js/od-graph.js?v=6812bf6c"></script>
1585
1602
  <script src="static/js/od-memories.js?v=379"></script>
1586
1603
  <script src="static/js/od-entities.js?v=379"></script>
1587
1604
  <!-- Multi-Agent Memory pane (v3.8.0): visualises memory written by multiple agents -->
1588
- <script src="static/js/od-agents.js?v=380"></script>
1605
+ <script src="static/js/od-agents.js?v=c75ff3c2"></script>
1589
1606
  <script src="static/js/od-skills.js?v=379"></script>
1590
1607
  <script src="static/js/od-mesh.js?v=379"></script>
1591
1608
  <script src="static/js/od-optimize.js?v=379"></script>
@@ -72,6 +72,67 @@ document.addEventListener('click', function(e) {
72
72
  body.appendChild(ent);
73
73
  }
74
74
 
75
+ // Code this memory refers to (4.0.7). Absent for anyone without a
76
+ // built code graph — the API returns [] rather than an error, so no
77
+ // section appears and nothing looks broken.
78
+ //
79
+ // textContent throughout, never innerHTML: every string here is a
80
+ // qualified name or file path read out of the indexed repository, so
81
+ // it is untrusted input as far as this panel is concerned.
82
+ if (data.code_links && data.code_links.length > 0) {
83
+ var codeWrap = document.createElement('div');
84
+ codeWrap.className = 'mt-2';
85
+
86
+ var codeLabel = document.createElement('strong');
87
+ codeLabel.textContent = 'Code referenced:';
88
+ codeWrap.appendChild(codeLabel);
89
+
90
+ var list = document.createElement('ul');
91
+ list.className = 'mb-0 ps-3';
92
+ list.style.listStyle = 'none';
93
+
94
+ data.code_links.forEach(function (link) {
95
+ var li = document.createElement('li');
96
+ li.className = 'mt-1';
97
+
98
+ var nameEl = document.createElement('code');
99
+ nameEl.textContent = link.qualified_name || link.name || '?';
100
+ li.appendChild(nameEl);
101
+
102
+ if (link.kind) {
103
+ var kind = document.createElement('span');
104
+ kind.className = 'text-muted';
105
+ kind.style.fontSize = '0.75rem';
106
+ kind.textContent = ' ' + link.kind;
107
+ li.appendChild(kind);
108
+ }
109
+
110
+ // A stale link points at code that has moved or gone. Saying
111
+ // so is the whole value of tracking staleness — silently
112
+ // showing it as current would be worse than not showing it.
113
+ if (link.is_stale) {
114
+ var stale = document.createElement('span');
115
+ stale.className = 'badge bg-warning text-dark ms-1';
116
+ stale.style.fontSize = '0.65rem';
117
+ stale.textContent = 'code changed';
118
+ li.appendChild(stale);
119
+ }
120
+
121
+ if (link.file_path) {
122
+ var loc = document.createElement('div');
123
+ loc.className = 'text-muted';
124
+ loc.style.fontSize = '0.7rem';
125
+ loc.textContent = link.file_path;
126
+ li.appendChild(loc);
127
+ }
128
+
129
+ list.appendChild(li);
130
+ });
131
+
132
+ codeWrap.appendChild(list);
133
+ body.appendChild(codeWrap);
134
+ }
135
+
75
136
  panel.appendChild(body);
76
137
  item.appendChild(panel);
77
138
  })
@@ -158,10 +158,22 @@
158
158
  '<h3>Recent memories</h3>' +
159
159
  '<span class="sub">newest first</span>' +
160
160
  '<div class="spacer"></div>' +
161
+ /* max-width + min-width:0 are load-bearing, not cosmetic.
162
+ A <select> sizes itself to its LONGEST OPTION, and the options
163
+ here are agent ids — which for daemon capabilities look like
164
+ "daemon-capability:811f335e55fac7f456aadd68938b862a...", 75+
165
+ characters. Measured on a real store: the control rendered
166
+ 1,178px wide inside a 714px pane, dragging the pane's
167
+ scrollWidth to 1,336px. That is what broke this section's
168
+ layout and pushed the table off the right edge.
169
+ min-width:0 is required because this sits in a flex row, where
170
+ the default min-width:auto refuses to shrink below the
171
+ content's intrinsic size and defeats max-width. */
161
172
  '<select id="od-agents-filter"' +
162
173
  ' style="font-size:12.5px;padding:3px 8px;border-radius:var(--r-sm);' +
163
174
  'background:var(--card-2);border:1px solid var(--border);' +
164
- 'color:var(--fg-1)">' +
175
+ 'color:var(--fg-1);max-width:220px;min-width:0;' +
176
+ 'text-overflow:ellipsis">' +
165
177
  '<option value="">All agents</option>' +
166
178
  '</select>' +
167
179
  '</div>' +
@@ -259,6 +271,30 @@
259
271
  }
260
272
 
261
273
  // ── Populate agent filter dropdown ────────────────────────────────────────
274
+ /* Turn an agent id into something a person can read in a dropdown.
275
+ Ids arrive in three shapes on a real store:
276
+ "claude", "codex" -> already readable
277
+ "daemon-capability:<64 hex>" -> kind + short fingerprint
278
+ "cli-offline-canonical:local-capability:cli:uid:501:<64 hex>:d63110"
279
+ Showing the raw string is what made the control 1,178px wide, and a
280
+ 64-character hash tells the reader nothing anyway. Keep the leading,
281
+ meaningful segments and a short fingerprint so two capabilities of the
282
+ same kind remain distinguishable. The full id stays on opt.value (used
283
+ for filtering) and on opt.title (hover). */
284
+ function shortAgentLabel(id) {
285
+ if (!id) return 'unknown';
286
+ if (id.length <= 28) return id;
287
+ var parts = id.split(':');
288
+ var head = parts[0] || id;
289
+ /* find the first long hex-looking segment and reduce it to 6 chars */
290
+ for (var i = 1; i < parts.length; i++) {
291
+ if (/^[0-9a-f]{16,}$/i.test(parts[i])) {
292
+ return head + ':' + parts[i].slice(0, 6) + '…';
293
+ }
294
+ }
295
+ return head.length <= 28 ? head + ':…' : head.slice(0, 27) + '…';
296
+ }
297
+
262
298
  function populateFilter(agents) {
263
299
  var sel = document.getElementById('od-agents-filter');
264
300
  if (!sel) return;
@@ -267,8 +303,10 @@
267
303
  if (!agents) return;
268
304
  agents.forEach(function (a) {
269
305
  var opt = document.createElement('option');
270
- opt.value = String(a.agent_id || '');
271
- opt.textContent = String(a.agent_id || 'unknown');
306
+ var id = String(a.agent_id || '');
307
+ opt.value = id; /* filtering needs the FULL id */
308
+ opt.textContent = shortAgentLabel(id); /* the human reads a short one */
309
+ opt.title = id || 'unknown'; /* full id still available on hover */
272
310
  sel.appendChild(opt);
273
311
  });
274
312
  }
@@ -293,7 +331,13 @@
293
331
  var rawSrc = String(r.source_type || '—');
294
332
  var rawSess = String(r.session_id || '—');
295
333
 
296
- var agId = escapeHtml(rawId);
334
+ // Same treatment the content and session columns already get. The agent
335
+ // id is the one field that was rendered in full, and with nowrap that
336
+ // made the Agent column 632px wide for a 75-character
337
+ // "daemon-capability:<64 hex>" — squeezing every other column off the
338
+ // right edge. Full id stays in the title attribute.
339
+ var agId = escapeHtml(shortAgentLabel(rawId));
340
+ var agIdFull = escapeHtml(rawId);
297
341
  var srcType = escapeHtml(rawSrc);
298
342
  var dt = escapeHtml(fmtDate(r.created_at));
299
343
 
@@ -317,7 +361,7 @@
317
361
  ' style="width:22px;height:22px;font-size:10px;background:' + avatarBg + '">' +
318
362
  letter +
319
363
  '</span>' +
320
- '<span class="mono" style="font-size:12px">' + agId + '</span>' +
364
+ '<span class="mono" style="font-size:12px" title="' + agIdFull + '">' + agId + '</span>' +
321
365
  '</span>' +
322
366
  '</td>' +
323
367
  '<td style="max-width:340px;font-size:13px;padding:7px 12px 7px 0"' +