superlocalmemory 3.8.1 → 3.8.2

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 (66) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  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 +2 -2
  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 +3 -5
  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 +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +360 -2
  35. package/src/superlocalmemory/cli/main.py +62 -3
  36. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  37. package/src/superlocalmemory/core/component_healer.py +144 -0
  38. package/src/superlocalmemory/core/component_registry.py +487 -0
  39. package/src/superlocalmemory/core/config.py +21 -0
  40. package/src/superlocalmemory/core/embeddings.py +14 -1
  41. package/src/superlocalmemory/core/engine.py +9 -5
  42. package/src/superlocalmemory/core/ingestion_command.py +36 -16
  43. package/src/superlocalmemory/core/maintenance.py +43 -0
  44. package/src/superlocalmemory/core/maintenance_scheduler.py +28 -0
  45. package/src/superlocalmemory/core/recall_pipeline.py +39 -3
  46. package/src/superlocalmemory/core/store_pipeline.py +42 -0
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  48. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  49. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  50. package/src/superlocalmemory/mcp/tools_core.py +17 -2
  51. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  52. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  53. package/src/superlocalmemory/server/routes/behavioral.py +6 -2
  54. package/src/superlocalmemory/server/routes/learning.py +13 -3
  55. package/src/superlocalmemory/server/routes/memories.py +8 -3
  56. package/src/superlocalmemory/server/routes/v3_api.py +120 -0
  57. package/src/superlocalmemory/server/unified_daemon.py +266 -2
  58. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  59. package/src/superlocalmemory/ui/index.html +3 -2
  60. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  61. package/src/superlocalmemory/ui/js/od-entities.js +43 -0
  62. package/src/superlocalmemory/ui/js/od-graph.js +35 -0
  63. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  64. package/src/superlocalmemory/ui/js/od-memories.js +37 -0
  65. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  66. package/src/superlocalmemory/ui/js/od-settings.js +72 -3
@@ -26,6 +26,23 @@ if TYPE_CHECKING:
26
26
 
27
27
  logger = logging.getLogger(__name__)
28
28
 
29
+ # ---------------------------------------------------------------------------
30
+ # Backfill constants
31
+ # ---------------------------------------------------------------------------
32
+
33
+ #: Default batch size for backfill_missing_embeddings.
34
+ _BACKFILL_BATCH_SIZE = 50
35
+
36
+ #: Max characters embedded per fact during backfill. The embedding model
37
+ #: (nomic-embed-text-v1.5) truncates at ~8192 tokens anyway, but a raw
38
+ #: oversized document (observed up to 107 KB on a real DB) makes the shared
39
+ #: single-worker embedder busy for 15-20s on ONE fact — starving foreground
40
+ #: recall during a self-heal pass. Bounding the input keeps every fact's embed
41
+ #: fast and the worker responsive; the leading slice captures the fact's gist
42
+ #: for semantic recall. Facts this large are documents that were almost
43
+ #: certainly NULL because they failed to embed at ingestion for the same reason.
44
+ _MAX_EMBED_CHARS = 8000
45
+
29
46
  # Sentinel stored in config.json when no model has been set yet.
30
47
  _NO_MODEL = ""
31
48
 
@@ -44,6 +61,24 @@ def _model_signature(config: SLMConfig) -> str:
44
61
  return f"{emb.model_name}::{emb.dimension}"
45
62
 
46
63
 
64
+ def _normalize_signature(signature: str) -> str:
65
+ """Normalize a signature for equivalence comparison.
66
+
67
+ v3.8.2 self-healing: the SAME embedding model has been recorded under
68
+ different name strings across releases — notably the HuggingFace org
69
+ prefix drifted (``nomic-ai/nomic-embed-text-v1.5`` vs the bare
70
+ ``nomic-embed-text-v1.5``). A prefix-only difference does NOT change the
71
+ embedding vector space, so it must not trigger a full multi-hour re-embed
72
+ when a non-technical user upgrades. This collapses the model name to its
73
+ basename (segment after the last ``/``) while keeping the ``::dimension``
74
+ suffix — a genuine model change (different basename OR dimension) still
75
+ differs and still triggers migration.
76
+ """
77
+ model, sep, dim = signature.partition("::")
78
+ model = model.rsplit("/", 1)[-1].strip()
79
+ return f"{model}{sep}{dim}" if sep else model
80
+
81
+
47
82
  def _read_stored_signature(config_dir: Path) -> str:
48
83
  """Read the last-used embedding model signature from config.json."""
49
84
  config_path = config_dir / "config.json"
@@ -88,6 +123,19 @@ def check_embedding_migration(config: SLMConfig) -> bool:
88
123
  if stored_sig == current_sig:
89
124
  return False
90
125
 
126
+ # v3.8.2 self-healing: a prefix-only model-name drift (e.g. the nomic-ai/
127
+ # org prefix appearing/disappearing between releases) is the SAME vector
128
+ # space — absorb the transition by refreshing the stored signature to the
129
+ # current form, with NO re-embed. This spares non-technical users a
130
+ # multi-hour full re-index on a cosmetic upgrade.
131
+ if _normalize_signature(stored_sig) == _normalize_signature(current_sig):
132
+ _write_stored_signature(config.base_dir, current_sig)
133
+ logger.info(
134
+ "Embedding signature normalized (no re-embed): %s ~= %s",
135
+ stored_sig, current_sig,
136
+ )
137
+ return False
138
+
91
139
  logger.warning(
92
140
  "Embedding model changed: %s -> %s. Re-indexing required.",
93
141
  stored_sig, current_sig,
@@ -177,3 +225,190 @@ def run_embedding_migration(
177
225
  reindexed, total,
178
226
  )
179
227
  return reindexed
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Backfill: embed facts that were NEVER embedded (embedding IS NULL)
232
+ # ---------------------------------------------------------------------------
233
+
234
+ def _count_null_embeddings(
235
+ db: Any,
236
+ profile_id: str,
237
+ all_profiles: bool,
238
+ ) -> int:
239
+ """Return count of atomic_facts rows with NULL embedding."""
240
+ if all_profiles:
241
+ rows = db.execute(
242
+ "SELECT count(*) AS c FROM atomic_facts WHERE embedding IS NULL",
243
+ )
244
+ else:
245
+ rows = db.execute(
246
+ "SELECT count(*) AS c FROM atomic_facts "
247
+ "WHERE embedding IS NULL AND profile_id = ?",
248
+ (profile_id,),
249
+ )
250
+ return int(rows[0]["c"]) if rows else 0
251
+
252
+
253
+ def backfill_missing_embeddings(
254
+ config: "SLMConfig",
255
+ db: Any,
256
+ embedder: Any,
257
+ batch_size: int = _BACKFILL_BATCH_SIZE,
258
+ limit: int | None = None,
259
+ all_profiles: bool = False,
260
+ ) -> dict[str, int]:
261
+ """Embed atomic_facts rows whose ``embedding`` column is NULL.
262
+
263
+ Unlike :func:`run_embedding_migration` (which re-embeds on model-signature
264
+ change), this function handles facts that were *never* embedded — for
265
+ example facts stored while the embedder was unavailable.
266
+
267
+ Resumable and idempotent: re-running after a partial run only processes
268
+ the remaining NULLs. Fail-open per-fact: a single bad fact logs a warning
269
+ and is skipped; the batch continues.
270
+
271
+ Writes mirror :func:`run_embedding_migration` exactly:
272
+ * ``atomic_facts.embedding`` ← ``json.dumps(vector)``
273
+ * ``embedding_metadata`` ← upserted row with current model name + dimension
274
+
275
+ Args:
276
+ config: Active SLMConfig (provides profile_id, model name, dimension).
277
+ db: DatabaseManager (or duck-compatible object with ``.execute()``).
278
+ embedder: Object implementing ``embed_batch(texts) -> list[vec|None]``
279
+ and (optionally) ``embed(text) -> vec|None``. Pass ``None`` to
280
+ make this a no-op (returns zero counts).
281
+ batch_size: Facts per embed_batch() call. Defaults to 50.
282
+ limit: Maximum facts to embed in this call. ``None`` means no cap —
283
+ all NULL-embedding facts are processed. Use a bounded limit for
284
+ the maintenance self-healing path so each pass is quick.
285
+ all_profiles: When ``True``, processes facts from every profile in the
286
+ database. When ``False`` (default), scopes to
287
+ ``config.active_profile``.
288
+
289
+ Returns:
290
+ ``{"scanned": int, "embedded": int, "remaining_null": int}``
291
+
292
+ *scanned*: total NULL-embedding facts found before applying *limit*.
293
+ *embedded*: facts successfully written in this call.
294
+ *remaining_null*: NULL count after the call (includes facts not yet
295
+ reached because of *limit*).
296
+ """
297
+ profile_id = config.active_profile
298
+
299
+ if embedder is None:
300
+ logger.warning(
301
+ "backfill_missing_embeddings: no embedder available — skipping."
302
+ )
303
+ return {"scanned": 0, "embedded": 0, "remaining_null": 0}
304
+
305
+ # ------------------------------------------------------------------
306
+ # 1. Fetch all NULL-embedding facts (cheap query; only reads IDs + content)
307
+ # ------------------------------------------------------------------
308
+ if all_profiles:
309
+ rows = db.execute(
310
+ "SELECT fact_id, content, profile_id FROM atomic_facts "
311
+ "WHERE embedding IS NULL ORDER BY created_at",
312
+ )
313
+ else:
314
+ rows = db.execute(
315
+ "SELECT fact_id, content, profile_id FROM atomic_facts "
316
+ "WHERE embedding IS NULL AND profile_id = ? ORDER BY created_at",
317
+ (profile_id,),
318
+ )
319
+
320
+ facts: list[tuple[str, str, str]] = [
321
+ (dict(r)["fact_id"], dict(r)["content"], dict(r)["profile_id"])
322
+ for r in rows
323
+ ]
324
+ scanned = len(facts)
325
+
326
+ if scanned == 0:
327
+ return {"scanned": 0, "embedded": 0, "remaining_null": 0}
328
+
329
+ # Apply call-level limit (resumability: next call picks up where this left off)
330
+ if limit is not None:
331
+ facts = facts[:limit]
332
+
333
+ current_model = config.embedding.model_name
334
+ current_dim = config.embedding.dimension
335
+ embedded = 0
336
+
337
+ # ------------------------------------------------------------------
338
+ # 2. Batch embed and write back
339
+ # ------------------------------------------------------------------
340
+ for batch_start in range(0, len(facts), batch_size):
341
+ batch = facts[batch_start : batch_start + batch_size]
342
+ # Bound per-fact input so an oversized document doesn't monopolize the
343
+ # shared embedding worker (starving foreground recall during self-heal).
344
+ texts = [(content or "")[:_MAX_EMBED_CHARS] for _, content, _ in batch]
345
+ fact_ids = [fid for fid, _, _ in batch]
346
+ prof_ids = [pid for _, _, pid in batch]
347
+
348
+ # Attempt batch embed; fall back to per-fact on batch failure.
349
+ try:
350
+ vectors: list[Any] = embedder.embed_batch(texts)
351
+ except Exception as exc:
352
+ logger.warning(
353
+ "backfill: batch embed failed for facts %d-%d: %s — "
354
+ "retrying per-fact.",
355
+ batch_start,
356
+ batch_start + len(batch),
357
+ exc,
358
+ )
359
+ vectors = []
360
+ for text in texts:
361
+ try:
362
+ vec = embedder.embed(text)
363
+ vectors.append(vec)
364
+ except Exception as per_fact_exc:
365
+ logger.warning(
366
+ "backfill: per-fact embed failed for '%s...': %s",
367
+ text[:40],
368
+ per_fact_exc,
369
+ )
370
+ vectors.append(None)
371
+
372
+ # Write each successfully-embedded fact back to the DB.
373
+ for fid, vec, pid in zip(fact_ids, vectors, prof_ids):
374
+ if vec is None:
375
+ logger.warning(
376
+ "backfill: null vector for fact %s — skipping.", fid[:16]
377
+ )
378
+ continue
379
+ try:
380
+ embedding_json = json.dumps(vec)
381
+ # Mirror run_embedding_migration's write path exactly.
382
+ db.execute(
383
+ "UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
384
+ (embedding_json, fid),
385
+ )
386
+ # Upsert embedding_metadata. NULL-embedding facts have no row
387
+ # here yet, so we INSERT; if a row somehow exists, update it.
388
+ db.execute(
389
+ "INSERT INTO embedding_metadata"
390
+ " (fact_id, profile_id, model_name, dimension)"
391
+ " VALUES (?, ?, ?, ?)"
392
+ " ON CONFLICT(fact_id) DO UPDATE SET"
393
+ " model_name = excluded.model_name",
394
+ (fid, pid, current_model, current_dim),
395
+ )
396
+ embedded += 1
397
+ except Exception as exc:
398
+ logger.warning(
399
+ "backfill: failed to write fact %s: %s", fid[:16], exc
400
+ )
401
+
402
+ # ------------------------------------------------------------------
403
+ # 3. Count remaining NULLs (accounts for the limit; tells caller how
404
+ # many passes remain before full convergence).
405
+ # ------------------------------------------------------------------
406
+ remaining = _count_null_embeddings(db, profile_id, all_profiles)
407
+
408
+ logger.info(
409
+ "Embedding backfill: %d/%d facts embedded, %d remaining NULL.",
410
+ embedded,
411
+ scanned,
412
+ remaining,
413
+ )
414
+ return {"scanned": scanned, "embedded": embedded, "remaining_null": remaining}
@@ -1566,7 +1566,8 @@
1566
1566
  <!-- OD screen modules (v3.7.9): approved-design panes wired to live data.
1567
1567
  Load LAST so their window.load<Screen> overrides win over the legacy loaders. -->
1568
1568
  <script src="static/js/od-brain.js?v=379"></script>
1569
- <script src="static/js/od-health.js?v=380"></script>
1569
+ <script src="static/js/od-components.js?v=382"></script>
1570
+ <script src="static/js/od-health.js?v=382"></script>
1570
1571
  <script src="static/js/od-operations.js?v=380"></script>
1571
1572
  <script src="static/js/od-team.js?v=379"></script>
1572
1573
  <script src="static/js/od-graph.js?v=379"></script>
@@ -1577,7 +1578,7 @@
1577
1578
  <script src="static/js/od-skills.js?v=379"></script>
1578
1579
  <script src="static/js/od-mesh.js?v=379"></script>
1579
1580
  <script src="static/js/od-optimize.js?v=379"></script>
1580
- <script src="static/js/od-settings.js?v=380"></script>
1581
+ <script src="static/js/od-settings.js?v=382"></script>
1581
1582
  <script src="static/js/od-backup.js?v=379"></script>
1582
1583
  <!-- MCP & Integrations pane (v3.8.0): shows exposed MCP tool profile + counts -->
1583
1584
  <script src="static/js/od-mcp.js?v=380"></script>
@@ -0,0 +1,147 @@
1
+ // od-components.js — System Health "What's Missing" report (v3.8.2 UX-6)
2
+ //
3
+ // window.odRenderComponents(el) renders the component-registry snapshot from
4
+ // GET /api/v3/components → {components:[...], summary:{...}}
5
+ // into `el`, showing per-component status, a copy-paste fix command for the
6
+ // items SLM can't repair on its own, and a "Retry now" button that triggers
7
+ // POST /api/v3/components/heal (install token auto-attached by core.js)
8
+ // The daemon auto-heals on start; this panel is transparency + a manual
9
+ // fallback for the non-technical user.
10
+ //
11
+ // Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar — AGPL-3.0
12
+
13
+ /* global window, document, fetch, navigator, setTimeout */
14
+ (function () {
15
+ 'use strict';
16
+
17
+ function esc(s) {
18
+ return String(s == null ? '' : s)
19
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
21
+ }
22
+
23
+ // Severity/glyph/colour for one component, honouring its category so an
24
+ // absent optional backend is not shown as an error.
25
+ function view(c) {
26
+ var st = String(c.status || '').toLowerCase();
27
+ if (st === 'ok') return { g: '✓', col: 'var(--ok)', tip: 'ready' };
28
+ if (st === 'retrying') return { g: '⟳', col: 'var(--accent, #4a9)', tip: 'repairing' };
29
+ if (st === 'degraded') return { g: '⚠', col: 'var(--warn)', tip: 'degraded' };
30
+ // missing — severity depends on how much SLM needs it.
31
+ if (c.category === 'required') return { g: '✗', col: 'var(--danger)', tip: 'missing' };
32
+ if (c.category === 'recommended') return { g: '⚠', col: 'var(--warn)', tip: 'missing' };
33
+ return { g: '○', col: 'var(--fg-2)', tip: 'optional' };
34
+ }
35
+
36
+ function rowHtml(c) {
37
+ var v = view(c);
38
+ var fix = '';
39
+ if (c.status !== 'ok' && c.fix_cmd) {
40
+ if (c.auto_fixable) {
41
+ fix = '<div class="muted" style="margin-top:2px;font-size:12px">'
42
+ + 'SLM repairs this automatically.</div>';
43
+ } else {
44
+ fix = '<div style="margin-top:4px;display:flex;gap:6px;align-items:center">'
45
+ + '<code style="font-size:12px;background:var(--bg-2,#0002);'
46
+ + 'padding:2px 6px;border-radius:4px">' + esc(c.fix_cmd) + '</code>'
47
+ + '<button class="od-copy-fix" data-cmd="' + esc(c.fix_cmd) + '" '
48
+ + 'style="font-size:11px;padding:1px 6px;cursor:pointer">Copy</button>'
49
+ + '</div>';
50
+ }
51
+ }
52
+ return '<div style="padding:8px 0;border-bottom:1px solid var(--border,#8881)">'
53
+ + '<div style="display:flex;gap:8px;align-items:baseline">'
54
+ + '<span style="color:' + v.col + ';font-weight:700;width:16px;text-align:center">'
55
+ + v.g + '</span>'
56
+ + '<div style="flex:1">'
57
+ + '<span style="font-weight:600">' + esc(c.label) + '</span>'
58
+ + ' <span class="muted" style="font-size:12px">' + esc(c.detail || v.tip) + '</span>'
59
+ + fix
60
+ + '</div></div></div>';
61
+ }
62
+
63
+ function render(el, data) {
64
+ var comps = (data && data.components) || [];
65
+ var s = (data && data.summary) || {};
66
+ var healthy = !!s.healthy;
67
+ var hasAutoFix = (s.auto_fixable_missing || 0) > 0;
68
+
69
+ var head = '<div style="display:flex;justify-content:space-between;'
70
+ + 'align-items:center;margin-bottom:6px">'
71
+ + '<h3 style="margin:0">System Health — Components</h3>'
72
+ + '<span style="padding:2px 10px;border-radius:12px;font-size:12px;'
73
+ + 'background:' + (healthy ? 'var(--ok)' : 'var(--warn)') + ';color:#000">'
74
+ + (healthy ? 'All good' : 'Needs attention') + '</span></div>';
75
+
76
+ var summary = '<div class="muted" style="font-size:12px;margin-bottom:8px">'
77
+ + (s.ok || 0) + ' ready · ' + (s.missing || 0) + ' missing · '
78
+ + (s.degraded || 0) + ' degraded'
79
+ + (s.retrying ? ' · ' + s.retrying + ' repairing' : '') + '</div>';
80
+
81
+ var actions = '<div style="margin-top:10px;display:flex;gap:8px">'
82
+ + '<button id="od-comp-recheck" style="padding:4px 12px;cursor:pointer">Recheck</button>'
83
+ + (hasAutoFix
84
+ ? '<button id="od-comp-retry" style="padding:4px 12px;cursor:pointer;'
85
+ + 'background:var(--accent,#4a9);color:#000;border:none;border-radius:4px">'
86
+ + 'Retry now</button>'
87
+ : '')
88
+ + '</div>'
89
+ + '<div class="muted" style="font-size:11px;margin-top:6px">'
90
+ + 'SLM auto-repairs fixable items on start. Copy a command for anything '
91
+ + 'it can’t install for you.</div>';
92
+
93
+ el.innerHTML = head + summary
94
+ + '<div>' + comps.map(rowHtml).join('') + '</div>' + actions;
95
+
96
+ // Copy buttons.
97
+ el.querySelectorAll('.od-copy-fix').forEach(function (b) {
98
+ b.addEventListener('click', function () {
99
+ var cmd = b.getAttribute('data-cmd') || '';
100
+ if (navigator.clipboard) navigator.clipboard.writeText(cmd);
101
+ var old = b.textContent; b.textContent = 'Copied';
102
+ setTimeout(function () { b.textContent = old; }, 1200);
103
+ });
104
+ });
105
+ var recheck = el.querySelector('#od-comp-recheck');
106
+ if (recheck) recheck.addEventListener('click', function () { load(el); });
107
+ var retry = el.querySelector('#od-comp-retry');
108
+ if (retry) retry.addEventListener('click', function () { doRetry(el, retry); });
109
+ }
110
+
111
+ function load(el) {
112
+ if (!el) return;
113
+ el.innerHTML = '<p class="muted" style="padding:8px 0">Checking components…</p>';
114
+ fetch('/api/v3/components')
115
+ .then(function (r) { return r.ok ? r.json() : null; })
116
+ .then(function (data) {
117
+ if (!data) {
118
+ el.innerHTML = '<p class="muted" style="padding:8px 0">'
119
+ + 'Component health unavailable (is the daemon running?).</p>';
120
+ return;
121
+ }
122
+ render(el, data);
123
+ })
124
+ .catch(function () {
125
+ el.innerHTML = '<p class="muted" style="padding:8px 0">'
126
+ + 'Could not load component health.</p>';
127
+ });
128
+ }
129
+
130
+ // Trigger a background heal, then poll a few times so the panel reflects
131
+ // the retrying → ok transition without the user refreshing.
132
+ function doRetry(el, btn) {
133
+ btn.disabled = true; btn.textContent = 'Repairing…';
134
+ fetch('/api/v3/components/heal', { method: 'POST', credentials: 'same-origin' })
135
+ .then(function () {
136
+ var tries = 0;
137
+ (function poll() {
138
+ tries += 1;
139
+ load(el);
140
+ if (tries < 4) setTimeout(poll, 2500);
141
+ })();
142
+ })
143
+ .catch(function () { load(el); });
144
+ }
145
+
146
+ window.odRenderComponents = load;
147
+ })();
@@ -396,6 +396,17 @@
396
396
  _dateField('Last seen', listEnt.last_seen) +
397
397
  _dateField('Compiled', d.last_compiled_at) +
398
398
  '</div>' +
399
+ // Recompile action — restored: POST /api/entity/{name}/recompile
400
+ // The disabled guard was removed; data-ename carries the name to _wire() handler.
401
+ '<div style="margin-top:16px;display:flex;align-items:center;gap:10px">' +
402
+ '<button data-od-act="recompile-entity" ' +
403
+ 'data-ename="' + _esc(d.entity_name || '') + '" ' +
404
+ 'style="padding:5px 14px;border:1px solid var(--border);border-radius:5px;' +
405
+ 'font-size:12px;cursor:pointer;background:var(--bg-2);color:var(--fg)">' +
406
+ 'Recompile</button>' +
407
+ '<span id="' + id + '-recompile-status" ' +
408
+ 'style="font-size:12px;color:var(--fg-2)"></span>' +
409
+ '</div>' +
399
410
  '</div>' +
400
411
  '</div>' +
401
412
 
@@ -514,12 +525,44 @@
514
525
 
515
526
  // ── Event delegation ──────────────────────────────────────────────────────
516
527
 
528
+ // Recompile entity — POST /api/entity/{name}/recompile
529
+ // Updates button text and status span to give feedback without a page reload.
530
+ function _recompileEntity(id, name, btn) {
531
+ if (!name) return;
532
+ var statusEl = document.getElementById(id + '-recompile-status');
533
+ btn.disabled = true;
534
+ btn.textContent = 'Recompiling…';
535
+ if (statusEl) { statusEl.textContent = ''; }
536
+ fetch('/api/entity/' + encodeURIComponent(name) + '/recompile', { method: 'POST' })
537
+ .then(function (r) { return r.json(); })
538
+ .then(function (data) {
539
+ btn.disabled = false;
540
+ btn.textContent = 'Recompile';
541
+ if (statusEl) {
542
+ statusEl.style.color = 'var(--accent)';
543
+ statusEl.textContent = data.message || 'Recompiled successfully';
544
+ }
545
+ })
546
+ .catch(function (err) {
547
+ btn.disabled = false;
548
+ btn.textContent = 'Recompile';
549
+ if (statusEl) {
550
+ statusEl.style.color = 'var(--danger, #e05)';
551
+ statusEl.textContent = 'Recompile failed: ' + (err.message || 'unknown error');
552
+ }
553
+ });
554
+ }
555
+
517
556
  function _wire(container, id) {
518
557
  container.addEventListener('click', function (e) {
519
558
  var el = e.target.closest('[data-od-act]');
520
559
  if (!el) return;
521
560
  var act = el.dataset.odAct;
522
561
 
562
+ if (act === 'recompile-entity') {
563
+ _recompileEntity(id, el.dataset.ename, el);
564
+ return;
565
+ }
523
566
  if (act === 'type-filter') {
524
567
  _st = Object.assign({}, _st, { typeFilter: el.dataset.type, page: 0 });
525
568
  _refreshTypeChips(id, el.dataset.type);
@@ -93,6 +93,31 @@
93
93
  '<div class="inspector-empty"><div style="font-size:34px;margin-bottom:8px">&#9671;</div>' +
94
94
  'Loading your knowledge graph…</div>' +
95
95
  '</div>' +
96
+ // Quick Insight Actions — restored: 5 data-insight-action buttons + #insight-results.
97
+ // quick-actions.js (global, non-IIFE) defines fetchInsight(action) which calls
98
+ // GET /api/v3/insights/{action} and writes into #insight-results.
99
+ // Delegation wired in wireControls() so buttons survive re-renders.
100
+ '<div id="odg-insights-panel" style="border-top:1px solid var(--border);' +
101
+ 'padding:10px 12px 8px">' +
102
+ '<div style="font-size:10.5px;font-weight:600;color:var(--fg-3);' +
103
+ 'text-transform:uppercase;letter-spacing:0.06em;margin-bottom:7px">' +
104
+ 'Quick Insights</div>' +
105
+ '<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:7px">' +
106
+ '<button class="chip" style="font-size:11px;padding:3px 9px;cursor:pointer" ' +
107
+ 'data-insight-action="changed_this_week">Changed This Week</button>' +
108
+ '<button class="chip" style="font-size:11px;padding:3px 9px;cursor:pointer" ' +
109
+ 'data-insight-action="opinions">Opinions</button>' +
110
+ '<button class="chip" style="font-size:11px;padding:3px 9px;cursor:pointer" ' +
111
+ 'data-insight-action="contradictions">Contradictions</button>' +
112
+ '<button class="chip" style="font-size:11px;padding:3px 9px;cursor:pointer" ' +
113
+ 'data-insight-action="health">Memory Health</button>' +
114
+ '<button class="chip" style="font-size:11px;padding:3px 9px;cursor:pointer" ' +
115
+ 'data-insight-action="cross_project">Cross-Project</button>' +
116
+ '</div>' +
117
+ // #insight-results — quick-actions.js writes here via document.getElementById
118
+ '<div id="insight-results" ' +
119
+ 'style="max-height:200px;overflow-y:auto;font-size:12px"></div>' +
120
+ '</div>' +
96
121
  '<div class="ask">' +
97
122
  '<div class="ask-head"><span data-ic="brain"></span> Ask your memory</div>' +
98
123
  '<div class="ask-log" id="odg-log">' +
@@ -502,6 +527,16 @@
502
527
  q('#odg-send').onclick = sendAsk;
503
528
  q('#odg-ask').addEventListener('keydown', function (e) { if (e.key === 'Enter') sendAsk(); });
504
529
 
530
+ // Quick Insight Actions — delegated on mount so buttons survive od-graph.js re-renders.
531
+ // quick-actions.js:fetchInsight() is global (non-IIFE); it writes into #insight-results
532
+ // which is mounted inside odg-insights-panel above.
533
+ mount.addEventListener('click', function (e) {
534
+ var btn = e.target.closest('[data-insight-action]');
535
+ if (btn && typeof fetchInsight === 'function') {
536
+ fetchInsight(btn.getAttribute('data-insight-action'));
537
+ }
538
+ });
539
+
505
540
  cv.addEventListener('mousedown', function (e) {
506
541
  var r = cv.getBoundingClientRect(), n = nodeAt(e.clientX - r.left, e.clientY - r.top);
507
542
  if (n) { dragNode = n; selected = n; renderInsp(n); wake(); } else { panning = true; }
@@ -183,6 +183,20 @@
183
183
  '</tbody>' +
184
184
  '</table>' +
185
185
  '</div>' +
186
+ '</div>' +
187
+
188
+ // System Health — Components (v3.8.2 UX-6): what's installed vs missing,
189
+ // with copy-paste fix commands + a Retry-now button. The daemon
190
+ // auto-heals fixable items on start; this is transparency + fallback.
191
+ // Rendered by od-components.js → window.odRenderComponents.
192
+ '<div class="card" style="margin-top:16px">' +
193
+ '<div class="card-head">' +
194
+ '<h3>System Health — Components</h3>' +
195
+ '<span class="sub">models &amp; dependencies · self-healing</span>' +
196
+ '</div>' +
197
+ '<div class="card-pad" id="od-h-components">' +
198
+ '<div class="dim" style="text-align:center;padding:20px">Loading…</div>' +
199
+ '</div>' +
186
200
  '</div>'
187
201
  );
188
202
  }
@@ -515,6 +529,10 @@
515
529
  populateTrustSignals(trustStats);
516
530
  populateAgentSummary(agentsData, eventsStats);
517
531
  populateAgentTable(agentsData);
532
+ // v3.8.2 UX-6: component "what's missing" report (od-components.js).
533
+ if (typeof window.odRenderComponents === 'function') {
534
+ window.odRenderComponents(document.getElementById('od-h-components'));
535
+ }
518
536
 
519
537
  }).catch(function (err) {
520
538
  container.innerHTML =
@@ -147,6 +147,9 @@
147
147
  '<button class="tab" data-od-act="tab" data-tab="timeline">Creation timeline</button>' +
148
148
  '<button class="tab" data-od-act="tab" data-tab="clusters">' +
149
149
  'Knowledge clusters <span class="cnt" id="' + id + '-cnt-clusters">…</span></button>' +
150
+ // Recall Lab tab — restored: recall-lab.js is already loaded in index.html and uses
151
+ // document-level click/keydown delegation keyed on IDs below, so it survives re-renders.
152
+ '<button class="tab" data-od-act="tab" data-tab="recall">Recall Lab</button>' +
150
153
  '</div>' +
151
154
  '<div class="tabpane active" id="' + id + '-pane-all">' + _allScaffold(id) + '</div>' +
152
155
  '<div class="tabpane" id="' + id + '-pane-timeline">' + _tlScaffold(id) + '</div>' +
@@ -155,6 +158,40 @@
155
158
  _loading('Loading clusters…') +
156
159
  '</div>' +
157
160
  '</div>' +
161
+ // Recall Lab pane — exact IDs required by recall-lab.js:
162
+ // #recall-lab-query (input), #recall-lab-search (button — click check),
163
+ // #recall-lab-per-page (select, optional), #recall-lab-meta, #recall-lab-results.
164
+ // Backend: POST /api/v3/recall/trace
165
+ '<div class="tabpane" id="' + id + '-pane-recall" style="padding-top:12px">' +
166
+ '<div style="margin-bottom:14px">' +
167
+ '<p style="font-size:13px;color:var(--fg-2);margin-bottom:10px">' +
168
+ 'Trace how a recall query is resolved — matching algorithm, scoring, and final result set.' +
169
+ '</p>' +
170
+ '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
171
+ '<input id="recall-lab-query" placeholder="Enter recall query…" autocomplete="off" ' +
172
+ 'style="flex:1;min-width:200px;padding:8px 12px;' +
173
+ 'border:1px solid var(--border);border-radius:6px;font-size:13px;' +
174
+ 'background:var(--bg-2);color:var(--fg)">' +
175
+ '<select id="recall-lab-per-page" ' +
176
+ 'style="padding:7px 10px;border:1px solid var(--border);border-radius:6px;' +
177
+ 'font-size:13px;background:var(--bg-2);color:var(--fg)">' +
178
+ '<option value="5">5</option>' +
179
+ '<option value="10" selected>10</option>' +
180
+ '<option value="20">20</option>' +
181
+ '<option value="50">50</option>' +
182
+ '</select>' +
183
+ '<button id="recall-lab-search" ' +
184
+ 'style="padding:8px 18px;background:var(--accent);color:#fff;' +
185
+ 'border:none;border-radius:6px;font-size:13px;cursor:pointer;' +
186
+ 'white-space:nowrap">Run Trace</button>' +
187
+ '</div>' +
188
+ '</div>' +
189
+ // #recall-lab-meta — written by recall-lab.js before results (timing, count, etc.)
190
+ '<div id="recall-lab-meta" ' +
191
+ 'style="font-size:12px;color:var(--fg-2);margin-bottom:8px"></div>' +
192
+ // #recall-lab-results — written by recall-lab.js (result cards, pagination)
193
+ '<div id="recall-lab-results"></div>' +
194
+ '</div>' +
158
195
  '</div>'
159
196
  );
160
197
  }