superlocalmemory 4.0.10 → 4.1.0

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 (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +170 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +263 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  107. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  108. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  109. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  110. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  111. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  112. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  113. package/src/superlocalmemory/server/routes/memories.py +49 -7
  114. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  115. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  116. package/src/superlocalmemory/server/ui.py +20 -4
  117. package/src/superlocalmemory/server/unified_daemon.py +186 -5
  118. package/src/superlocalmemory/storage/_migration_internals.py +31 -0
  119. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  120. package/src/superlocalmemory/storage/database.py +477 -59
  121. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  122. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  123. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  124. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  125. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  126. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  127. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  128. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  129. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  130. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  131. package/src/superlocalmemory/storage/migrations.py +18 -2
  132. package/src/superlocalmemory/storage/models.py +40 -1
  133. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  134. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  135. package/src/superlocalmemory/storage/schema.py +12 -1
  136. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  137. package/src/superlocalmemory/trust/scorer.py +43 -1
  138. package/src/superlocalmemory/ui/index.html +9 -18
  139. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  140. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  141. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  142. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  143. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -330,3 +330,181 @@ def evolution_lineage(request: Request, skill_name: str = ""):
330
330
  finally:
331
331
  if conn is not None:
332
332
  conn.close()
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Approving a quarantined skill
337
+ #
338
+ # A mutation that passes blind verification stops at VERIFIED_QUARANTINED and
339
+ # waits. Nothing moved it from there: auto-approval is off by default, correctly
340
+ # — this system rewrites the instructions an AI follows, and doing that without
341
+ # a person saying yes is not a default anyone should ship. But there was no way
342
+ # for the person to say yes either, so every verified improvement sat in a
343
+ # quarantine directory permanently.
344
+ #
345
+ # This is that path. It does NOT change the default: approval remains explicit,
346
+ # per-candidate, and recorded in the transition chain with who did it.
347
+ # ---------------------------------------------------------------------------
348
+
349
+
350
+ class ApproveSkillRequest(BaseModel):
351
+ """Which candidate to approve. Named by record id, not by skill name.
352
+
353
+ A skill can have several candidates over time and only one of them is the
354
+ one being looked at. Approving "the latest candidate for skill X" would make
355
+ the outcome depend on when the call happened to arrive.
356
+ """
357
+
358
+ record_id: str
359
+
360
+
361
+ _APPROVABLE = ("verified_quarantined", "promoted")
362
+
363
+
364
+ @router.post("/api/evolution/approve")
365
+ def evolution_approve(request: Request, body: ApproveSkillRequest):
366
+ """Activate a quarantined skill mutation after human approval."""
367
+ _require_manage(request)
368
+ try:
369
+ from superlocalmemory.evolution.evolution_store import EvolutionStore
370
+ from superlocalmemory.evolution.skill_activator import SkillActivator
371
+ from superlocalmemory.evolution.types import EvolutionStatus
372
+
373
+ profile_id = get_active_profile()
374
+ store = EvolutionStore(str(MEMORY_DIR / "memory.db"))
375
+
376
+ record = store.get_record(body.record_id, profile_id)
377
+ if record is None:
378
+ return {"success": False, "error": "No such evolution candidate"}
379
+
380
+ # Prefer the transition chain over the record's own column: the chain is
381
+ # the audited history and the column is a cache of its last entry.
382
+ latest = store.get_latest_status(body.record_id, profile_id)
383
+ current = (latest.value if latest is not None
384
+ else getattr(record.status, "value", ""))
385
+
386
+ if current not in _APPROVABLE:
387
+ # Refusing an already-active candidate matters: activating twice
388
+ # would overwrite the backup taken the first time with the mutation
389
+ # itself, and the rollback target would become the thing being
390
+ # rolled back.
391
+ return {
392
+ "success": False,
393
+ "error": (
394
+ f"Candidate is {current!r}; only a verified candidate "
395
+ "awaiting approval can be activated"
396
+ ),
397
+ "status": current,
398
+ }
399
+
400
+ if not record.quarantine_dir_name:
401
+ return {
402
+ "success": False,
403
+ "error": "Candidate has no quarantined artifact to activate",
404
+ }
405
+
406
+ actor = "dashboard"
407
+
408
+ # Approval is recorded BEFORE the file moves, activation after. The
409
+ # order matters in both directions:
410
+ #
411
+ # * recording both afterwards means a successful activation whose log
412
+ # write fails leaves the skill live and the record saying it is not —
413
+ # so the next approval attempt is allowed, and activating twice
414
+ # overwrites the backup that rollback restores from.
415
+ # * recording both beforehand would claim a mutation is live when the
416
+ # file move failed.
417
+ #
418
+ # Approved-then-nothing is a state a person can act on. Live-but-unknown
419
+ # is not.
420
+ store.append_transition(
421
+ body.record_id, profile_id,
422
+ EvolutionStatus(current), EvolutionStatus.APPROVED,
423
+ actor_id=actor, reason="approved by request",
424
+ )
425
+ try:
426
+ activation = SkillActivator().activate(
427
+ record.skill_name, record.quarantine_dir_name, actor_id=actor,
428
+ )
429
+ except Exception as exc:
430
+ # The approval stands and the mutation did not go live. Recorded as
431
+ # such rather than left dangling.
432
+ store.append_transition(
433
+ body.record_id, profile_id,
434
+ EvolutionStatus.APPROVED, EvolutionStatus.REJECTED,
435
+ actor_id=actor, reason=f"activation failed: {exc}",
436
+ )
437
+ raise
438
+ store.append_transition(
439
+ body.record_id, profile_id,
440
+ EvolutionStatus.APPROVED, EvolutionStatus.ACTIVE,
441
+ actor_id=actor, reason="activated from quarantine",
442
+ metadata={"content_hash": activation.get("content_hash", "")},
443
+ )
444
+
445
+ return {
446
+ "success": True,
447
+ "record_id": body.record_id,
448
+ "skill_name": record.skill_name,
449
+ "status": EvolutionStatus.ACTIVE.value,
450
+ "live_path": activation.get("live_path"),
451
+ "backup_path": activation.get("backup_path"),
452
+ "content_hash": activation.get("content_hash"),
453
+ # So the caller knows how to undo it without reading the source.
454
+ "rollback": "POST /api/evolution/rollback",
455
+ }
456
+ except FileNotFoundError as exc:
457
+ return {"success": False, "error": f"Quarantined artifact missing: {exc}"}
458
+ except Exception:
459
+ logger.exception("evolution_approve error")
460
+ return {"success": False, "error": "Internal server error"}
461
+
462
+
463
+ class RollbackSkillRequest(BaseModel):
464
+ skill_name: str
465
+ #: Optional, so the reversal can be recorded against the candidate it
466
+ #: reverses. Without it the file is restored and the log still says active,
467
+ #: which reads as "this mutation is live" forever.
468
+ record_id: str = ""
469
+
470
+
471
+ @router.post("/api/evolution/rollback")
472
+ def evolution_rollback(request: Request, body: RollbackSkillRequest):
473
+ """Restore a skill's previous instructions after an approval goes wrong.
474
+
475
+ Approval is reversible, and it has to be: the reason a person is in this
476
+ loop is that a verified mutation can still be a bad one, and finding that
477
+ out happens after it is live.
478
+ """
479
+ _require_manage(request)
480
+ try:
481
+ from superlocalmemory.evolution.evolution_store import EvolutionStore
482
+ from superlocalmemory.evolution.skill_activator import SkillActivator
483
+ from superlocalmemory.evolution.types import EvolutionStatus
484
+
485
+ result = SkillActivator().rollback(body.skill_name)
486
+
487
+ # Record the reversal. A restored file with the log still reading
488
+ # "active" says the mutation is live when it is not, and that is the
489
+ # record an audit would read.
490
+ if body.record_id:
491
+ try:
492
+ store = EvolutionStore(str(MEMORY_DIR / "memory.db"))
493
+ pid = get_active_profile()
494
+ latest = store.get_latest_status(body.record_id, pid)
495
+ store.append_transition(
496
+ body.record_id, pid,
497
+ latest or EvolutionStatus.ACTIVE,
498
+ EvolutionStatus.ROLLED_BACK,
499
+ actor_id="dashboard", reason="rolled back by request",
500
+ )
501
+ except Exception as exc: # pragma: no cover — the file is restored
502
+ logger.warning("rollback recorded no transition: %s", exc)
503
+ result["transition_recorded"] = False
504
+
505
+ return {"success": True, **result}
506
+ except FileNotFoundError as exc:
507
+ return {"success": False, "error": f"No backup to restore: {exc}"}
508
+ except Exception:
509
+ logger.exception("evolution_rollback error")
510
+ return {"success": False, "error": "Internal server error"}
@@ -81,6 +81,14 @@ async def ingest(req: IngestRequest, request: Request):
81
81
  request,
82
82
  actor_kind="http-ingest",
83
83
  )
84
+ # Machine authentication says which process is calling. It does not say
85
+ # whether that caller's user may write to this workspace. Every other
86
+ # write route asks that question; this one did not, so a viewer could
87
+ # write through it in a workspace that requires a login.
88
+ from superlocalmemory.access.rbac import Permission
89
+ from superlocalmemory.server.rbac_enforce import require_permission
90
+
91
+ require_permission(request, Permission.WRITE, profile=engine._profile_id)
84
92
  command = build_engine_ingestion_command(engine)
85
93
  receipt, created = command.submit_with_status(IngestionRequest(
86
94
  content=req.content,
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import logging
8
8
  import sqlite3
9
9
  from pathlib import Path
10
+ from superlocalmemory.learning.signal_kinds import FEEDBACK_ONLY_SQL
10
11
 
11
12
  logger = logging.getLogger("superlocalmemory.routes.learning")
12
13
 
@@ -24,7 +25,7 @@ class ReadOnlyRankerStore:
24
25
  try:
25
26
  row = connection.execute(
26
27
  "SELECT COUNT(*) AS count FROM learning_signals "
27
- "WHERE profile_id = ?",
28
+ f"WHERE profile_id = ?{FEEDBACK_ONLY_SQL}",
28
29
  (profile_id,),
29
30
  ).fetchone()
30
31
  return int(row["count"] if row else 0)
@@ -13,6 +13,11 @@ from typing import Optional
13
13
 
14
14
  from fastapi import APIRouter, HTTPException, Query, Request
15
15
 
16
+ from superlocalmemory.core.config import BROWSE_PAGE_SIZE
17
+ from superlocalmemory.storage.database import (
18
+ visible_fact_clause_for_connection,
19
+ )
20
+
16
21
  from .helpers import (
17
22
  SearchRequest,
18
23
  dict_factory,
@@ -131,10 +136,20 @@ def _admit_http_mutation(request: Request, operation: str) -> None:
131
136
  resolve_principal,
132
137
  )
133
138
 
139
+ from superlocalmemory.core.admission import _company_mode_active
140
+
141
+ # Both switches, the same rule the other entry points use. Reading
142
+ # config.toml alone left this path treating a workspace as personal after
143
+ # the dashboard toggle had turned per-user access on -- so a write refused
144
+ # everywhere else was admitted here.
134
145
  deployment = getattr(request.app.state, "deployment", None)
135
- is_enterprise = bool(deployment and deployment.is_enterprise)
136
- tier = "enterprise" if is_enterprise else "personal"
137
- mode = "company" if is_enterprise else "local"
146
+ if deployment is None:
147
+ from superlocalmemory.core.admission import _resolve_deployment
148
+
149
+ deployment = _resolve_deployment()
150
+ company = _company_mode_active(deployment)
151
+ tier = "enterprise" if company else "personal"
152
+ mode = "company" if company else "local"
138
153
 
139
154
  principal_info = resolve_principal(request)
140
155
  principal = str(principal_info.get("user_id") or "")
@@ -388,7 +403,7 @@ async def get_memories(
388
403
  cluster_id: Optional[int] = None,
389
404
  min_importance: Optional[int] = None,
390
405
  tags: Optional[str] = None,
391
- limit: int = Query(50, ge=1, le=200),
406
+ limit: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200),
392
407
  offset: int = Query(0, ge=0),
393
408
  filter: Optional[str] = Query(
394
409
  None,
@@ -444,15 +459,24 @@ async def get_memories(
444
459
  scope_where = "profile_id = ?"
445
460
  scope_params = [active_profile]
446
461
 
462
+ # Withheld rows are excluded from BOTH the page and the total. On
463
+ # the author's store this list served 24 model-authored summaries
464
+ # in its first 50 rows and reported 5,218 memories against 3,919
465
+ # real ones -- the count a user reads, inflated by exactly the
466
+ # 1,299 rows 4.0.10 withheld.
467
+ visible = visible_fact_clause_for_connection(conn)
447
468
  query = (
448
469
  "SELECT fact_id as id, memory_id, content, fact_type as category, "
449
470
  "confidence as importance, access_count, "
450
471
  "created_at, created_at as updated_at, "
451
472
  "session_id as project_name, scope, shared_with "
452
- f"FROM atomic_facts WHERE {scope_where}"
473
+ f"FROM atomic_facts WHERE {scope_where}{visible}"
453
474
  )
454
475
  params = list(scope_params)
455
- count_base = f"SELECT COUNT(*) as total FROM atomic_facts WHERE {scope_where}"
476
+ count_base = (
477
+ "SELECT COUNT(*) as total FROM atomic_facts "
478
+ f"WHERE {scope_where}{visible}"
479
+ )
456
480
  else:
457
481
  query = """
458
482
  SELECT id, content, summary, category, project_name, project_path,
@@ -656,6 +680,11 @@ async def search_memories(request: Request, body: SearchRequest):
656
680
  lambda: engine.recall(
657
681
  body.query, limit=body.limit, fast=True,
658
682
  window=_window or None,
683
+ # Name the surface. A recall with no name leaves no record
684
+ # an outcome can be matched to, and a search typed into the
685
+ # dashboard is one continuous thread of use, not a
686
+ # conversation turn.
687
+ session_id=f"dashboard:{get_active_profile()}",
659
688
  ),
660
689
  )
661
690
  # A run_in_executor thread cannot be cancelled, and wait_for() on it
@@ -803,6 +832,13 @@ async def get_summary(request: Request, kind: str = "day", target: str = ""):
803
832
  except Exception:
804
833
  raise _internal_error("Summary generation error")
805
834
 
835
+ # Say why, not just what. The response already reported that a summary was
836
+ # assembled rather than written; someone seeing a plainer result than they
837
+ # expected still had no way to learn that a written one needs a model, and
838
+ # would reasonably read the feature as broken.
839
+ from superlocalmemory.core.mode_capability import llm_capability
840
+
841
+ generated = str(getattr(result, "generated_by", "") or "")
806
842
  return {
807
843
  "kind": result.kind,
808
844
  "profile_id": result.profile_id,
@@ -812,6 +848,12 @@ async def get_summary(request: Request, kind: str = "day", target: str = ""):
812
848
  "source_fact_ids": result.source_fact_ids,
813
849
  "source_count": len(result.source_fact_ids),
814
850
  "metadata": result.metadata,
851
+ "capability": llm_capability(
852
+ cfg,
853
+ # This call has just been made, so its outcome is the honest answer
854
+ # about availability -- better than asking the configuration again.
855
+ llm_reachable=generated.startswith("llm"),
856
+ ),
815
857
  }
816
858
 
817
859
 
@@ -992,7 +1034,7 @@ async def get_clusters(request: Request):
992
1034
  async def get_cluster_detail(
993
1035
  request: Request,
994
1036
  cluster_id: str,
995
- limit: int = Query(50, ge=1, le=200),
1037
+ limit: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200),
996
1038
  ):
997
1039
  """Get detailed view of a specific cluster (scene)."""
998
1040
  try:
@@ -17,6 +17,9 @@ import sqlite3
17
17
  from fastapi import APIRouter, Query
18
18
  from fastapi.responses import JSONResponse
19
19
  from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
20
+ from superlocalmemory.storage.database import (
21
+ visible_fact_clause_for_connection,
22
+ )
20
23
 
21
24
  logger = logging.getLogger(__name__)
22
25
 
@@ -82,6 +85,7 @@ async def get_timeline(
82
85
  "confidence, session_id "
83
86
  "FROM atomic_facts "
84
87
  "WHERE profile_id = ? AND created_at >= ? AND lifecycle = 'active' "
88
+ f"{visible_fact_clause_for_connection(conn)} "
85
89
  "ORDER BY created_at DESC LIMIT ?",
86
90
  (pid, start_date, INTERNAL_CEILING),
87
91
  ).fetchall()
@@ -8,14 +8,45 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+ from pathlib import Path
11
12
  import os
12
13
  from fastapi import APIRouter, HTTPException, Request
13
14
  from fastapi.responses import JSONResponse
15
+ from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
16
+ from superlocalmemory.core.status_contract import (
17
+ COUNT_QUERIES,
18
+ counts_from_sqlite,
19
+ projection_queue_depth,
20
+ store_size_mb,
21
+ )
14
22
  from superlocalmemory.server.routes.helpers import SLM_VERSION, get_read_connection
15
23
  from superlocalmemory.server.route_mutations import authorize_route_mutation
16
24
 
17
25
  logger = logging.getLogger(__name__)
18
26
 
27
+ def _signal_session_id() -> str:
28
+ """A name for the caller, for matching an outcome back to this recall.
29
+
30
+ The agent id the request arrived under, when it arrived under one. Falls
31
+ back to the workspace, which keeps dashboard and scripted traffic separable
32
+ from an agent's. Never empty: an unnamed recall leaves no record.
33
+ """
34
+ try:
35
+ from superlocalmemory.mcp.agent_context import get_current_agent_id
36
+
37
+ agent = str(get_current_agent_id() or "").strip()
38
+ if agent:
39
+ return f"agent:{agent}"
40
+ except Exception: # noqa: BLE001 -- naming the caller must never fail a read
41
+ pass
42
+ try:
43
+ from superlocalmemory.server.routes.helpers import get_active_profile
44
+
45
+ return f"api:{get_active_profile()}"
46
+ except Exception: # noqa: BLE001
47
+ return "api:default"
48
+
49
+
19
50
  router = APIRouter(prefix="/api/v3", tags=["v3"])
20
51
 
21
52
 
@@ -102,20 +133,15 @@ async def dashboard(request: Request):
102
133
 
103
134
  # Read stats directly from SQLite (dashboard doesn't load engine)
104
135
  memory_count = 0
105
- fact_count = 0
136
+ counts = dict.fromkeys(COUNT_QUERIES, 0)
137
+ queue_depth = 0
106
138
  db_path = config.base_dir / "memory.db"
107
139
  if db_path.exists():
108
140
  try:
109
141
  conn = get_read_connection(db_path)
142
+ counts = counts_from_sqlite(conn, active_profile)
143
+ queue_depth = projection_queue_depth(conn)
110
144
  cursor = conn.cursor()
111
- try:
112
- cursor.execute(
113
- "SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
114
- (active_profile,),
115
- )
116
- fact_count = cursor.fetchone()[0]
117
- except Exception:
118
- pass
119
145
  try:
120
146
  try:
121
147
  cursor.execute(
@@ -143,10 +169,23 @@ async def dashboard(request: Request):
143
169
  "provider": config.llm.provider or "none",
144
170
  "model": config.llm.model or "",
145
171
  "memory_count": memory_count,
146
- "fact_count": fact_count,
147
172
  "profile": active_profile,
148
173
  "base_dir": str(config.base_dir),
149
174
  "version": SLM_VERSION,
175
+ # The counts and the store's own address were missing here while
176
+ # every other status surface carried them, so the one surface a
177
+ # person actually looks at could not answer "is the graph healthy".
178
+ "db_path": str(db_path),
179
+ "db_size_mb": store_size_mb(db_path),
180
+ "profile_generation": get_profile_runtime(
181
+ request.app.state,
182
+ ).snapshot.generation,
183
+ # Facts stored but not yet in the graph and vector projections. A
184
+ # number that does not fall is a projection that has stopped
185
+ # keeping up, which is otherwise invisible: nothing errors, the
186
+ # memory is safely in SQLite, and recall just quietly gets worse.
187
+ "projection_queue_depth": queue_depth,
188
+ **counts,
150
189
  }
151
190
  payload.update(dashboard_mode_fields(config.mode))
152
191
  return payload
@@ -495,12 +534,45 @@ async def set_full_config(request: Request):
495
534
  _emb_fields = ("embedding_provider", "embedding_endpoint", "embedding_key",
496
535
  "embedding_model", "embedding_dimension")
497
536
  if any(k in body for k in _emb_fields):
537
+ _old_emb = config.embedding
538
+ _new_provider = body.get("embedding_provider", "")
539
+ _new_model = body.get("embedding_model", "")
540
+ _new_dim = int(body.get("embedding_dimension", 0) or 0)
541
+ # The same range the other save route enforces. Without it a
542
+ # dashboard save with no dimension field stored a width of zero.
543
+ if _new_dim and not (64 <= _new_dim <= 8192):
544
+ return JSONResponse(
545
+ {"error": f"Dimension must be 64-8192, got {_new_dim}"},
546
+ status_code=400,
547
+ )
548
+ # The SECOND way to change the embedding model, and it was
549
+ # unguarded. Switching mode from the dashboard carries the embedding
550
+ # fields, so a width that the store cannot hold arrived here
551
+ # untouched while the other route refused it — one door bolted, the
552
+ # other open.
553
+ if not bool(body.get("force")):
554
+ _refusal = _refuse_incompatible_embedding(
555
+ config, config.embedding, _new_model, _new_dim,
556
+ new_provider=_new_provider,
557
+ )
558
+ if _refusal is not None:
559
+ return _refusal
498
560
  config.embedding = EmbeddingConfig(
499
- provider=body.get("embedding_provider", ""),
561
+ provider=_new_provider,
500
562
  api_endpoint=body.get("embedding_endpoint", ""),
501
563
  api_key=body.get("embedding_key", ""),
502
- model_name=body.get("embedding_model", ""),
503
- dimension=int(body.get("embedding_dimension", 0) or 0),
564
+ model_name=_new_model,
565
+ dimension=_new_dim or _old_emb.dimension,
566
+ # Not naming these reset them to defaults, so every embedding
567
+ # save from the dashboard silently put the local model back to
568
+ # whatever ships — the exact defect the other route had.
569
+ ollama_model=(
570
+ _new_model if _new_provider == "ollama" and _new_model
571
+ else _old_emb.ollama_model
572
+ ),
573
+ ollama_base_url=_old_emb.ollama_base_url,
574
+ api_version=_old_emb.api_version,
575
+ deployment_name=_old_emb.deployment_name,
504
576
  )
505
577
 
506
578
  # When the mode actually changed, apply the new mode's structural presets
@@ -571,6 +643,85 @@ async def get_embedding_config(request: Request):
571
643
  return _internal_error()
572
644
 
573
645
 
646
+ def _refuse_incompatible_embedding(
647
+ config, old_emb, new_model, new_dim, new_provider=None,
648
+ ):
649
+ """None when the change is safe, otherwise the 409 to return instead.
650
+
651
+ Fail-open on anything it cannot determine: a store with no vectors yet, a
652
+ model server that is not running, an unreadable database. Refusing on
653
+ "I could not tell" would block a legitimate first-time setup, and the
654
+ dimension a caller declares is still checked against the store either way.
655
+ """
656
+ from fastapi.responses import JSONResponse as _JSON
657
+
658
+ try:
659
+ from superlocalmemory.core.ollama_validator import (
660
+ EMBEDDING,
661
+ stored_embedding_dimension,
662
+ validate_ollama_model,
663
+ )
664
+
665
+ db_path = Path(config.base_dir) / "memory.db"
666
+ stored = stored_embedding_dimension(db_path)
667
+ if stored is None:
668
+ return None
669
+
670
+ # The provider being SAVED, falling back to the current one when the
671
+ # caller is not changing it. Reading only the current provider meant
672
+ # that SWITCHING to a local model never probed at all — and switching
673
+ # is exactly when the width changes.
674
+ effective_provider = (
675
+ new_provider
676
+ if new_provider is not None
677
+ else getattr(old_emb, "provider", "")
678
+ )
679
+ measured = None
680
+ if effective_provider == "ollama" or getattr(old_emb, "provider", "") == "ollama":
681
+ # The model being SAVED, not the one already configured. Probing the
682
+ # old one always matched the stored width and therefore always
683
+ # allowed the change — the guard measured the thing it was not
684
+ # protecting against.
685
+ probe = validate_ollama_model(
686
+ new_model or getattr(old_emb, "ollama_model", ""),
687
+ EMBEDDING,
688
+ base_url=getattr(old_emb, "ollama_base_url", "")
689
+ or "http://localhost:11434",
690
+ )
691
+ measured = probe.dimension if probe.ok else None
692
+
693
+ declared = int(new_dim or 0)
694
+ if measured is None and declared <= 0:
695
+ # Nothing to compare: the server could not be asked and the caller
696
+ # named no width. Allowing is the fail-open the first-time setup
697
+ # needs; the other route's probe still guards the common path.
698
+ return None
699
+ effective = measured if measured is not None else declared
700
+ if effective == stored:
701
+ return None
702
+
703
+ return _JSON(
704
+ {
705
+ "error": "embedding_width_mismatch",
706
+ "stored_dimension": stored,
707
+ "requested_dimension": effective,
708
+ "model_name": new_model,
709
+ "detail": (
710
+ f"{new_model} produces {effective}-dimensional vectors and "
711
+ f"this store holds {stored}-dimensional ones. Vectors of "
712
+ f"different widths cannot be compared, so every memory "
713
+ f"already stored would become unfindable by meaning. "
714
+ f"Rebuild them first with: slm db migrate — or resend with "
715
+ f"force=true if they have already been rebuilt."
716
+ ),
717
+ },
718
+ status_code=409,
719
+ )
720
+ except Exception: # noqa: BLE001 - never block a save on the check failing
721
+ logger.exception("embedding width pre-check failed; allowing the save")
722
+ return None
723
+
724
+
574
725
  @router.put("/embedding/config")
575
726
  async def set_embedding_config(request: Request):
576
727
  """Update embedding configuration independently of mode switch."""
@@ -591,13 +742,34 @@ async def set_embedding_config(request: Request):
591
742
  new_key = body.get("api_key", config.embedding.api_key)
592
743
 
593
744
  old_emb = config.embedding
745
+
746
+ # A width that disagrees with what the store already holds is refused
747
+ # here, at the moment of writing, not merely offered as a check the
748
+ # caller may or may not have run. Vectors of different widths cannot be
749
+ # compared, so the store would keep answering similarity questions and
750
+ # every answer would be noise. ``force=true`` is the escape hatch for
751
+ # somebody who has already re-embedded.
752
+ if not bool(body.get("force")):
753
+ refusal = _refuse_incompatible_embedding(
754
+ config, old_emb, new_model, new_dim, new_provider=new_provider,
755
+ )
756
+ if refusal is not None:
757
+ return refusal
758
+
594
759
  config.embedding = EmbeddingConfig(
595
760
  model_name=new_model,
596
761
  dimension=new_dim,
597
762
  provider=new_provider,
598
763
  api_endpoint=new_endpoint,
599
764
  api_key=new_key,
600
- ollama_model=old_emb.ollama_model,
765
+ # In Ollama mode the embedder resolves its model from
766
+ # ``ollama_model``, so keeping the old value here made a rename a
767
+ # no-op that still answered "success". A caller naming a model gets
768
+ # that model.
769
+ ollama_model=(
770
+ new_model if new_provider == "ollama" and new_model
771
+ else old_emb.ollama_model
772
+ ),
601
773
  ollama_base_url=old_emb.ollama_base_url,
602
774
  api_version=old_emb.api_version,
603
775
  deployment_name=old_emb.deployment_name,
@@ -1018,7 +1190,7 @@ async def recall_trace(request: Request):
1018
1190
  try:
1019
1191
  body = await request.json()
1020
1192
  query = body.get("query", "")
1021
- limit = body.get("limit", 10)
1193
+ limit = body.get("limit", CANONICAL_RECALL_LIMIT)
1022
1194
  window = body.get("window", "") or ""
1023
1195
  as_of_raw = (body.get("as_of", "") or "").strip()
1024
1196
  raw_known_as_of = body.get("known_as_of", "")
@@ -1073,6 +1245,10 @@ async def recall_trace(request: Request):
1073
1245
  window=window or None, as_of=_as_of,
1074
1246
  known_as_of=_known_as_of, valid_at=_valid_at,
1075
1247
  include_unknown=include_unknown,
1248
+ # Whoever asked, by the name they arrived under. Without a name
1249
+ # the record of this recall is discarded and no outcome
1250
+ # reported afterwards can be matched back to it.
1251
+ session_id=_signal_session_id(),
1076
1252
  ),
1077
1253
  )
1078
1254
  elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
@@ -231,14 +231,30 @@ def create_app() -> FastAPI:
231
231
  "<p><a href='/api/docs'>API Documentation</a></p>"
232
232
  "</body></html>"
233
233
  )
234
- from superlocalmemory.server.asset_versions import render_index
235
234
  from superlocalmemory import __version__ as _v
236
235
 
237
236
  # __SLM_VERSION__ was substituted only by the unified daemon, so the
238
237
  # dashboard's upgrade detector did nothing when served from here.
239
- return render_index(
240
- index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
241
- )
238
+ # Asset versioning is cosmetic. It must never be why this page 500s.
239
+ #
240
+ # The import is deferred (house style, keeps startup lean), which means
241
+ # it resolves at REQUEST time — so when `pip install -e .` replaced the
242
+ # installed package underneath a running daemon, this route began
243
+ # answering "Internal Server Error" on the dashboard while every other
244
+ # endpoint was fine. A stale hand-written version string is a trifle; a
245
+ # blank page is not. Fall back to the file as written.
246
+ try:
247
+ from superlocalmemory.server.asset_versions import render_index
248
+
249
+ return render_index(
250
+ index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
251
+ )
252
+ except Exception as exc: # noqa: BLE001 — serve the page regardless
253
+ logger.warning(
254
+ "asset version rewrite unavailable, serving index.html as "
255
+ "written: %s: %s", type(exc).__name__, exc,
256
+ )
257
+ return index_path.read_text().replace("__SLM_VERSION__", _v)
242
258
 
243
259
  @application.get("/favicon.ico", include_in_schema=False)
244
260
  async def favicon():