superlocalmemory 4.0.10 → 4.1.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 (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  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 +357 -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_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -62,6 +62,7 @@ _TABLES: Final[tuple[str, ...]] = (
62
62
  "community_summaries",
63
63
  "consolidated_summaries",
64
64
  "persona_summary",
65
+ "projection_outbox",
65
66
  )
66
67
 
67
68
  _FTS_TABLES: Final[tuple[str, ...]] = (
@@ -157,7 +158,7 @@ CREATE TABLE IF NOT EXISTS atomic_facts (
157
158
  content TEXT NOT NULL,
158
159
  fact_type TEXT NOT NULL DEFAULT 'semantic'
159
160
  CHECK (fact_type IN (
160
- 'episodic', 'semantic', 'opinion', 'temporal'
161
+ 'episodic', 'semantic', 'opinion', 'prospective'
161
162
  )),
162
163
 
163
164
  -- Entities (JSON arrays)
@@ -915,6 +916,10 @@ CREATE TABLE IF NOT EXISTS persona_summary (
915
916
  # Ordered DDL list (tables before FTS, respects FK order)
916
917
  # ---------------------------------------------------------------------------
917
918
 
919
+ #: Imported rather than restated so the table has exactly one definition; the
920
+ #: module that owns the queue owns its shape.
921
+ from superlocalmemory.storage.projection_outbox import DDL as _PROJECTION_OUTBOX_DDL
922
+
918
923
  _DDL_ORDERED: Final[tuple[str, ...]] = (
919
924
  _SQL_SCHEMA_VERSION,
920
925
  _SQL_PROFILES,
@@ -950,6 +955,12 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
950
955
  CONSOLIDATED_SUMMARIES_DDL,
951
956
  # Wave Q3: persona roll-up tier (additive; safe on existing DBs)
952
957
  _SQL_PERSONA_SUMMARY,
958
+ # The queue that makes a graph/vector projection write survive a crash.
959
+ # Created here, at every engine init, rather than only by its migration:
960
+ # if this table is missing, facts stop being projected and the only symptom
961
+ # is a memory that cannot be recalled. That invariant must not be
962
+ # contingent on a migration pass having succeeded.
963
+ _PROJECTION_OUTBOX_DDL,
953
964
  )
954
965
 
955
966
 
@@ -961,6 +972,18 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
961
972
  #: which SQLite offers no IF NOT EXISTS form of, so presence is checked first.
962
973
  _ADDITIVE_COLUMNS: Final[tuple[tuple[str, str, str], ...]] = (
963
974
  ("atomic_facts", "quarantined", "INTEGER NOT NULL DEFAULT 0"),
975
+ # ``pinned`` arrives with M015, which is a DEFERRED migration -- it runs
976
+ # after the engine is up. But the DDL below indexes it
977
+ # (``idx_facts_pinned``), and that DDL runs during engine start. On a store
978
+ # old enough to predate M015 the index therefore raised "no such column:
979
+ # pinned" before anything could add it, and every start failed the same way:
980
+ # the deferred pass could not run because the engine could not start, and
981
+ # the engine could not start because the deferred pass had not run.
982
+ # ``slm db migrate`` did not break the loop either -- it reports Failed=0
983
+ # and skips deferred migrations by definition.
984
+ # The column has to exist before its own index either way, so it belongs
985
+ # here, where a store gets it at start regardless of migration state.
986
+ ("atomic_facts", "pinned", "INTEGER NOT NULL DEFAULT 0"),
964
987
  )
965
988
 
966
989
 
@@ -1000,6 +1023,17 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
1000
1023
  """
1001
1024
  _set_pragmas(conn)
1002
1025
 
1026
+ # Before the DDL, not only after it. The DDL below indexes columns that an
1027
+ # upgraded store may not have yet, and an index on a column that does not
1028
+ # exist is a hard error, not a skipped statement -- so the whole of
1029
+ # create_all_tables would raise and the engine would never start.
1030
+ #
1031
+ # On a fresh database this pass does nothing: the tables do not exist yet,
1032
+ # which the helper treats as "nothing to alter". On an upgraded one the
1033
+ # tables are already there and this is exactly where the columns are owed.
1034
+ # It runs again at the end for tables created during this call.
1035
+ _add_missing_columns(conn)
1036
+
1003
1037
  for ddl in _DDL_ORDERED:
1004
1038
  conn.executescript(ddl)
1005
1039
 
@@ -631,10 +631,27 @@ class WriteCoordinator:
631
631
  conn.close()
632
632
 
633
633
  def _open_connection(self) -> sqlite3.Connection:
634
- conn = sqlite3.connect(str(self._db_path), timeout=1.0)
634
+ """The coordinator's own connection, on the same waiting policy as the rest.
635
+
636
+ It waited one second while every other writer waited ten. A caller
637
+ legitimately holding the write lock for longer than a second -- a large
638
+ batch, a store during maintenance -- made the coordinator's next write
639
+ raise "database is locked" and lose the item, while an ordinary write
640
+ issued at the same moment simply waited and succeeded.
641
+
642
+ Reproduced: a 3,010 ms hold blocked the coordinator for 1,004 ms and then
643
+ dropped its write. There was no reason for the two to disagree, and the
644
+ shorter one belonged to the path whose failure loses data rather than
645
+ retries.
646
+ """
647
+ from superlocalmemory.storage.database import _BUSY_TIMEOUT_MS
648
+
649
+ conn = sqlite3.connect(
650
+ str(self._db_path), timeout=_BUSY_TIMEOUT_MS / 1000.0,
651
+ )
635
652
  conn.row_factory = sqlite3.Row
636
653
  conn.execute("PRAGMA foreign_keys=ON")
637
- conn.execute("PRAGMA busy_timeout=1000")
654
+ conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
638
655
  conn.execute("PRAGMA journal_mode=WAL")
639
656
  return conn
640
657
 
@@ -34,6 +34,31 @@ _DEFAULT_ALPHA = 1.0
34
34
  _DEFAULT_BETA = 1.0
35
35
  _DEFAULT_TRUST = 0.5
36
36
 
37
+ #: Identities that are the absence of an identity. A caller that does not say
38
+ #: who it is lands in one of these, and on a real store the ``unknown`` bucket
39
+ #: had accumulated 1,708 pieces of evidence and a trust of 1.000 — the highest
40
+ #: of any agent in the store, higher than the named agents doing the actual
41
+ #: work. That number is the sum of everybody's behaviour, and it was being
42
+ #: handed to whoever arrived next without a name.
43
+ #:
44
+ #: It matters twice over. The trust gate lets a write through at 0.3 and a
45
+ #: delete at 0.5, so a caller with no identity cleared both by the widest
46
+ #: margin available. And a fact with no trust record of its own inherits the
47
+ #: trust of whoever created it, so 39 facts on that store were being ranked at
48
+ #: the maximum promotion for having no author.
49
+ #:
50
+ #: A catch-all is not an identity. It reads as the neutral prior every time and
51
+ #: accumulates nothing, which leaves the gate exactly where it was for an
52
+ #: anonymous caller (0.5 clears both thresholds) while removing the escalation.
53
+ ANONYMOUS_IDENTITIES: frozenset[str] = frozenset({
54
+ "", "unknown", "unspecified", "anonymous", "none", "null",
55
+ })
56
+
57
+
58
+ def is_anonymous(agent_id: str) -> bool:
59
+ """Whether this identifier names nobody in particular."""
60
+ return (agent_id or "").strip().lower() in ANONYMOUS_IDENTITIES
61
+
37
62
  # Signal strengths (how much each event shifts alpha/beta)
38
63
  _SIGNAL_WEIGHTS: dict[str, tuple[float, float]] = {
39
64
  # (delta_alpha, delta_beta)
@@ -68,7 +93,14 @@ class TrustScorer:
68
93
  # ------------------------------------------------------------------
69
94
 
70
95
  def get_agent_trust(self, agent_id: str, profile_id: str) -> float:
71
- """Get trust score for an agent. Returns 0.5 if unknown."""
96
+ """Get trust score for an agent. Returns the neutral prior if unknown.
97
+
98
+ An identifier that names nobody in particular always reads as the
99
+ prior, whatever has been written against that bucket. See
100
+ ``ANONYMOUS_IDENTITIES``.
101
+ """
102
+ if is_anonymous(agent_id):
103
+ return _DEFAULT_TRUST
72
104
  alpha, beta = self._get_beta_params("agent", agent_id, profile_id)
73
105
  return self._compute_trust(alpha, beta)
74
106
 
@@ -131,6 +163,16 @@ class TrustScorer:
131
163
  Returns:
132
164
  Updated trust score for the agent.
133
165
  """
166
+ if is_anonymous(agent_id):
167
+ # Reputation belongs to somebody. Accumulating it against the
168
+ # catch-all is how that bucket reached the highest trust in the
169
+ # store while naming no one.
170
+ logger.debug(
171
+ "trust signal ignored: %r names nobody (signal=%s)",
172
+ agent_id, signal_type,
173
+ )
174
+ return _DEFAULT_TRUST
175
+
134
176
  weights = _SIGNAL_WEIGHTS.get(signal_type, (0.0, 0.0))
135
177
  delta_alpha, delta_beta = weights
136
178
 
@@ -113,27 +113,19 @@
113
113
  </div>
114
114
  </div>
115
115
 
116
- <!-- Main content tabs Bootstrap nav-tabs row removed (v3.4.21,
117
- Domain 2). Sidebar (built by ng-shell.js) is the sole
118
- navigation. Hidden tab buttons below keep the Bootstrap
119
- ``shown.bs.tab`` event contract aliveng-shell's
120
- ``activateTab`` dispatches on them, and brain.js / other
121
- listeners fire normally. No visual output on initial paint. -->
116
+ <!-- The visible navigation is the sidebar, built by od-shell.js.
117
+ These hidden buttons exist only so od-shell's ``activateTab`` can
118
+ dispatch Bootstrap's ``shown.bs.tab`` on a real element for panes
119
+ it does not render itself today that is dashboard-pane alone,
120
+ whose data reload hangs off that event. Every button here must
121
+ target a pane that exists: nine did not, and clicking one did
122
+ nothing at all. -->
122
123
  <ul class="nav nav-tabs d-none" id="mainTabs" role="tablist" aria-hidden="true">
123
124
  <li class="nav-item"><button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard-pane" type="button"></button></li>
124
125
  <li class="nav-item"><button class="nav-link" id="graph-tab" data-bs-toggle="tab" data-bs-target="#graph-pane" type="button"></button></li>
125
126
  <li class="nav-item"><button class="nav-link" id="memories-tab" data-bs-toggle="tab" data-bs-target="#memories-pane" type="button"></button></li>
126
- <li class="nav-item"><button class="nav-link" id="recall-lab-tab" data-bs-toggle="tab" data-bs-target="#recall-lab-pane" type="button"></button></li>
127
- <li class="nav-item"><button class="nav-link" id="clusters-tab" data-bs-toggle="tab" data-bs-target="#clusters-pane" type="button"></button></li>
128
- <li class="nav-item"><button class="nav-link" id="timeline-tab" data-bs-toggle="tab" data-bs-target="#timeline-pane" type="button"></button></li>
129
127
  <li class="nav-item"><button class="nav-link" id="brain-tab" data-bs-toggle="tab" data-bs-target="#brain-pane" type="button" role="tab" aria-controls="brain-pane"></button></li>
130
- <li class="nav-item"><button class="nav-link" id="events-tab" data-bs-toggle="tab" data-bs-target="#events-pane" type="button"></button></li>
131
128
  <li class="nav-item"><button class="nav-link" id="agents-tab" data-bs-toggle="tab" data-bs-target="#agents-pane" type="button"></button></li>
132
- <li class="nav-item"><button class="nav-link" id="trust-tab" data-bs-toggle="tab" data-bs-target="#trust-pane" type="button"></button></li>
133
- <li class="nav-item"><button class="nav-link" id="lifecycle-tab" data-bs-toggle="tab" data-bs-target="#lifecycle-pane" type="button"></button></li>
134
- <li class="nav-item"><button class="nav-link" id="compliance-tab" data-bs-toggle="tab" data-bs-target="#compliance-pane" type="button"></button></li>
135
- <li class="nav-item"><button class="nav-link" id="math-health-tab" data-bs-toggle="tab" data-bs-target="#math-health-pane" type="button"></button></li>
136
- <li class="nav-item"><button class="nav-link" id="ide-tab" data-bs-toggle="tab" data-bs-target="#ide-pane" type="button"></button></li>
137
129
  <li class="nav-item"><button class="nav-link" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings-pane" type="button"></button></li>
138
130
  </ul>
139
131
 
@@ -956,7 +948,7 @@
956
948
  <h5 class="mb-0"><i class="bi bi-shield-lock text-success"></i> Compliance &amp; audit</h5>
957
949
  <span class="badge bg-secondary" id="compliance-profile-badge">default</span>
958
950
  </div>
959
- <p class="text-muted small mb-3">Set retention policies and access controls. In Mode A, all data stays on your device &mdash; EU AI Act compliant by default.</p>
951
+ <p class="text-muted small mb-3">Set retention policies and access controls. In Mode A nothing leaves your device, which is a control your compliance programme can rely on &mdash; it is not a compliance certification, and this product does not issue one.</p>
960
952
  <div class="row g-3 mb-3">
961
953
  <div class="col-md-4"><div class="border rounded p-2 text-center"><div class="fw-bold fs-4" id="cp-audit-count">-</div><small class="text-muted">Audit events</small></div></div>
962
954
  <div class="col-md-4"><div class="border rounded p-2 text-center"><div class="fw-bold fs-4" id="cp-retention-count">-</div><small class="text-muted">Retention policies</small></div></div>
@@ -1084,7 +1076,7 @@
1084
1076
  <div class="btn-group w-100" role="group">
1085
1077
  <input type="radio" class="btn-check" name="settings-mode-radio" id="mode-a-radio" value="a" checked>
1086
1078
  <label class="btn btn-outline-success" for="mode-a-radio">
1087
- <strong>Mode A</strong><br><small>Zero Cloud — EU AI Act</small>
1079
+ <strong>Mode A</strong><br><small>Zero Cloud</small>
1088
1080
  </label>
1089
1081
  <input type="radio" class="btn-check" name="settings-mode-radio" id="mode-b-radio" value="b">
1090
1082
  <label class="btn btn-outline-info" for="mode-b-radio">
@@ -1585,7 +1577,6 @@
1585
1577
  <!-- Neural Glass shell (v3.4.21 restructured) -->
1586
1578
  <script src="static/js/ng-health.js?v=345"></script>
1587
1579
  <script src="static/js/ng-ingestion.js?v=345"></script>
1588
- <script src="static/js/ng-entities.js?v=3410"></script>
1589
1580
  <script src="static/js/ng-skills.js?v=3411"></script>
1590
1581
  <script src="static/js/ng-mesh.js?v=345"></script>
1591
1582
  <!-- OD Shell v1.0 — replaces ng-shell.js; CSP-safe single-page tab switching -->
@@ -38,7 +38,18 @@
38
38
  'load-agents': () => loadAgents(),
39
39
  'load-clusters': () => loadClusters(),
40
40
  'load-compliance': () => loadCompliance(),
41
- 'load-entity-explorer': () => loadEntityExplorer(),
41
+ // Re-renders through the current renderer. The button used to call a
42
+ // function from a superseded implementation of this pane, which was
43
+ // removed; naming it directly would have made Refresh throw.
44
+ 'load-entity-explorer': () => {
45
+ const pane = document.getElementById('entities-pane');
46
+ if (pane && typeof window.odRenderEntities === 'function') {
47
+ pane.innerHTML = '';
48
+ window.odRenderEntities(pane);
49
+ return;
50
+ }
51
+ if (typeof window.loadEntityExplorer === 'function') window.loadEntityExplorer();
52
+ },
42
53
  'load-graph': () => loadGraph(),
43
54
  'load-health-monitor': () => loadHealthMonitor(),
44
55
  'load-ingestion-status': () => loadIngestionStatus(),
@@ -216,12 +216,33 @@
216
216
  var strip = document.getElementById('od-h-status');
217
217
  if (!strip) return;
218
218
 
219
- // Card 1: Daemon — from /health
220
- var daemonCls = (healthData && healthData.status === 'ok') ? 'ok' : 'warn';
221
- var daemonVal = daemonCls === 'ok' ? 'Healthy' : 'Degraded';
219
+ // Card 1: Daemon — from /health.
220
+ // `status` is the literal string "ok" on every reply the daemon is alive
221
+ // enough to send, so reading it said "Healthy" for a daemon that had not
222
+ // finished starting, or whose search was not answering. `runtime_state` is
223
+ // the field that actually varies.
224
+ var runtime = healthData ? String(healthData.runtime_state || '') : '';
225
+ var RUNTIME_LABELS = {
226
+ serving_full: 'Healthy',
227
+ serving_degraded: 'Degraded',
228
+ warming: 'Starting up',
229
+ not_ready: 'Not ready'
230
+ };
231
+ var daemonCls = !healthData ? 'warn'
232
+ : (runtime === 'serving_full' ? 'ok'
233
+ : (runtime === 'warming' ? 'neutral' : 'warn'));
234
+ var daemonVal = !healthData ? 'Unreachable'
235
+ : (RUNTIME_LABELS[runtime] || (healthData.status === 'ok' ? 'Healthy' : 'Degraded'));
222
236
  var daemonDetail = healthData
223
237
  ? 'port 8765 · v' + esc(String(healthData.version || '?'))
224
238
  : 'daemon unreachable';
239
+ if (healthData && healthData.projection && healthData.projection.behind) {
240
+ // The graph copy being behind is not a daemon fault, and it does change
241
+ // what a search returns, so it belongs on the card the operator reads.
242
+ daemonDetail += ' · graph copy ' + esc(String(healthData.projection.depth || 0))
243
+ + ' behind';
244
+ if (daemonCls === 'ok') daemonCls = 'neutral';
245
+ }
225
246
 
226
247
  // Card 2: Memory DB — from /api/stats
227
248
  var dbMb = (statsData && statsData.overview)
@@ -239,10 +260,11 @@
239
260
  var mathVal = mathCls === 'ok' ? 'Active' : (mathOverall ? esc(mathOverall) : 'Unknown');
240
261
  var mathDetail = esc(String(mathLayerCount)) + ' / 3 layers online';
241
262
 
242
- // Card 4: Mesh broker TODO: no /api/mesh/* endpoint confirmed
263
+ // Card 4: Mesh broker. There is no endpoint for this yet, so the card says
264
+ // so rather than implying something was measured and came back unknown.
243
265
  var meshCls = 'neutral';
244
- var meshVal = 'Unknown';
245
- var meshDetail = 'no endpoint configure mesh to enable'; // TODO: wire to /api/mesh/status
266
+ var meshVal = 'Not set up';
267
+ var meshDetail = 'coordinating several machines is not configured here';
246
268
 
247
269
  var CARDS = [
248
270
  { label: 'Daemon', cls: daemonCls, value: daemonVal, detail: daemonDetail },
@@ -458,6 +458,25 @@
458
458
  meta.style.cssText = 'margin-top:10px;font-size:12px;color:var(--fg-3);line-height:1.6';
459
459
  meta.textContent = _summaryProvenance(d);
460
460
  out.appendChild(meta);
461
+
462
+ /* Why it came out this way, and what to do about it.
463
+ *
464
+ * The provenance line above says a summary was assembled from the
465
+ * user's own notes rather than written. It never said why. Someone in
466
+ * Mode A — which has no language model by design — saw a plainer
467
+ * result than they expected with no way to learn that a written one
468
+ * needs a model, and would reasonably read the feature as broken.
469
+ */
470
+ var cap = d.capability;
471
+ if (cap && cap.message) {
472
+ var hint = document.createElement('div');
473
+ hint.style.cssText = 'margin-top:10px;font-size:12px;line-height:1.6;' +
474
+ 'color:var(--fg-2);background:var(--card-2);border:1px solid var(--border);' +
475
+ 'border-left:3px solid var(--accent, var(--border));' +
476
+ 'border-radius:var(--r-md);padding:10px 12px';
477
+ hint.textContent = cap.message;
478
+ out.appendChild(hint);
479
+ }
461
480
  })
462
481
  .catch(function (e) {
463
482
  out.textContent = 'Could not build summary: ' + (e && e.message ? e.message : 'error');
@@ -222,6 +222,71 @@
222
222
  }
223
223
 
224
224
  /* ── Layout ───────────────────────────────────────────────── */
225
+ /* Decay preview — three curves, one per source-trust level.
226
+ Shows what the kappa setting actually does before it is saved: the same
227
+ memory strength fading at three different rates depending on where the
228
+ memory came from. At kappa 0 the three curves coincide, which is the
229
+ honest picture of trust-modulation being off. */
230
+ var DECAY_TRUSTS = [
231
+ { trust: 0.0, color: '#e0245e', label: 'low trust' },
232
+ { trust: 0.5, color: '#f59e0b', label: 'medium' },
233
+ { trust: 1.0, color: '#10b981', label: 'trusted' }
234
+ ];
235
+
236
+ function buildDecayPreview() {
237
+ var wrap = el('div', null, { display:'flex', flexDirection:'column', gap:'6px' });
238
+ var cvs = el('canvas', { id: P + '-forg-decay' });
239
+ cvs.width = 300; cvs.height = 96;
240
+ Object.assign(cvs.style, {
241
+ border:'1px solid var(--border)', borderRadius:'4px', display:'block'
242
+ });
243
+ wrap.appendChild(cvs);
244
+ var key = el('div', null, { display:'flex', gap:'10px' });
245
+ DECAY_TRUSTS.forEach(function (t) {
246
+ key.appendChild(el('span', { text: t.label },
247
+ { fontSize:'10.5px', color: t.color, fontFamily:'var(--font-mono)' }));
248
+ });
249
+ wrap.appendChild(key);
250
+ return wrap;
251
+ }
252
+
253
+ /* The decay rate is per HOUR, and the window is chosen so the three curves
254
+ are actually distinguishable. At a strong memory's strength (100, the
255
+ configured maximum) retention over sixty days is indistinguishable from
256
+ zero for every trust level, so a two-month preview would draw three flat
257
+ lines along the bottom and say nothing. Three days separates them. */
258
+ var DECAY_HOURS = 72;
259
+ var DECAY_STRENGTH = 100.0;
260
+
261
+ function retentionAt(hours, kappa, trust) {
262
+ // Mirrors math/ebbinghaus.trust_modulated_retention exactly:
263
+ // lambda_eff = (1 / S) * (1 + kappa * (1 - trust))
264
+ // retention = exp(-lambda_eff * hours)
265
+ var lambdaEff = (1.0 / DECAY_STRENGTH) * (1.0 + kappa * (1.0 - trust));
266
+ return Math.exp(-lambdaEff * hours);
267
+ }
268
+
269
+ function drawDecayCurve(kappa) {
270
+ var cvs = q('forg-decay');
271
+ if (!cvs || !cvs.getContext) return;
272
+ var ctx = cvs.getContext('2d');
273
+ var W = cvs.width, H = cvs.height, STEPS = 72;
274
+ ctx.clearRect(0, 0, W, H);
275
+ DECAY_TRUSTS.forEach(function (t) {
276
+ ctx.beginPath();
277
+ for (var i = 0; i <= STEPS; i++) {
278
+ var hours = (i / STEPS) * DECAY_HOURS;
279
+ var retention = retentionAt(hours, kappa, t.trust);
280
+ var x = (i / STEPS) * (W - 2) + 1;
281
+ var y = H - 2 - retention * (H - 4);
282
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
283
+ }
284
+ ctx.strokeStyle = t.color;
285
+ ctx.lineWidth = 1.6;
286
+ ctx.stroke();
287
+ });
288
+ }
289
+
225
290
  function makeRow(lbl, desc, key, ctrl) {
226
291
  var d = el('div', { 'data-set':'', 'data-txt':(lbl + ' ' + key).toLowerCase() });
227
292
  Object.assign(d.style, { display:'flex', alignItems:'center',
@@ -719,10 +784,16 @@
719
784
  intInp.style.minWidth = '80px'; intInp.style.maxWidth = '100px';
720
785
  var statsEl = el('span', { id: P + '-forg-stats', text:'Loading…' },
721
786
  { fontSize:'12px', color:'var(--fg-2)', fontFamily:'var(--font-mono)' });
787
+ var kapR = makeRange('forg-kap', '0', '5', '0.1', '2.0');
788
+ var decayEl = buildDecayPreview();
722
789
  var saveBtn = makeBtn('forg-save', 'Save Forgetting', true);
723
790
  var st = makeSt('forg');
724
791
  var restNote = makeRestartNote('forg');
725
792
  saveBtn.addEventListener('click', saveForgetting);
793
+ kapR.querySelector('input').addEventListener('input', function () {
794
+ drawDecayCurve(parseFloat(this.value));
795
+ });
796
+ kapR.querySelector('input').addEventListener('change', saveForgetting);
726
797
  onSw.addEventListener('od-toggle', saveForgetting);
727
798
  immSw.addEventListener('od-toggle', saveForgetting);
728
799
  arcR.querySelector('input').addEventListener('change', saveForgetting);
@@ -734,6 +805,11 @@
734
805
  makeRow('Archive threshold', 'Memories below this strength move to cold archive', 'forgetting.archive_threshold', arcR),
735
806
  makeRow('Forget threshold', 'Memories below this strength are permanently deleted', 'forgetting.forget_threshold', fgtR),
736
807
  makeRow('Run every (minutes)', 'How often the forgetting engine runs in the background', 'forgetting.scheduler_interval_minutes', intInp),
808
+ makeRow('Fade untrusted sources faster (\u03ba)',
809
+ 'Memories from sources you trust less decay quicker. 0 = every memory fades at the same rate.',
810
+ 'forgetting.trust_kappa', kapR),
811
+ makeRow('Decay preview', 'Retention over 3 days at three source-trust levels',
812
+ '', decayEl),
737
813
  makeRow('Memory zone stats', '', 'forgetting.stats', statsEl),
738
814
  makeRow('Actions', '', '', bRow(saveBtn, st, restNote))
739
815
  ]);
@@ -751,6 +827,15 @@
751
827
  var fgt = q('forg-fgt');
752
828
  if (fgt) { fgt.value = d.forget_threshold || 0.05; var dF = q('forg-fgt-disp'); if (dF) dF.textContent = Number(d.forget_threshold || 0.05).toFixed(2); }
753
829
  var it = q('forg-int'); if (it) it.value = d.scheduler_interval_minutes || 30;
830
+ // == null, not ||: kappa 0 is a real setting (trust-modulation off) and
831
+ // a falsy check would silently redraw it as the 2.0 default.
832
+ var kap = q('forg-kap');
833
+ var kv = (d.trust_kappa == null) ? 2.0 : Number(d.trust_kappa);
834
+ if (kap) {
835
+ kap.value = kv;
836
+ var dK = q('forg-kap-disp'); if (dK) dK.textContent = kv.toFixed(2);
837
+ }
838
+ drawDecayCurve(kv);
754
839
  }).catch(function () {});
755
840
  fetch('/api/v3/forgetting/stats').then(function (r) { return r.ok ? r.json() : null; })
756
841
  .then(function (d) {
@@ -773,7 +858,8 @@
773
858
  core_memory_immune: q('forg-imm') ? q('forg-imm').classList.contains('on') : true,
774
859
  archive_threshold: parseFloat(q('forg-arc') ? q('forg-arc').value : '0.20'),
775
860
  forget_threshold: parseFloat(q('forg-fgt') ? q('forg-fgt').value : '0.05'),
776
- scheduler_interval_minutes: parseInt(q('forg-int') ? q('forg-int').value : '30') || 30
861
+ scheduler_interval_minutes: parseInt(q('forg-int') ? q('forg-int').value : '30') || 30,
862
+ trust_kappa: q('forg-kap') ? parseFloat(q('forg-kap').value) : 2.0
777
863
  }).then(function (r) { return r.json(); })
778
864
  .then(function (d) {
779
865
  setSt('forg', 'Saved', true);
@@ -7,8 +7,63 @@ var recallLabState = {
7
7
  perPage: 10,
8
8
  query: '',
9
9
  synthesis: '',
10
+ channelStatus: {},
10
11
  };
11
12
 
13
+ // How each channel status reads to someone looking at a thin result set.
14
+ // 'empty', 'disabled' and 'not_configured' are muted on purpose: the first is a
15
+ // valid finding and the other two are the operator's own configuration. Only a
16
+ // genuine degradation is coloured as a problem.
17
+ var CHANNEL_STATUS_STYLE = {
18
+ ok: {cls: 'bg-success', label: 'ok'},
19
+ empty: {cls: 'bg-secondary', label: 'no hits'},
20
+ disabled: {cls: 'bg-secondary', label: 'off'},
21
+ not_configured: {cls: 'bg-secondary', label: 'n/a'},
22
+ error: {cls: 'bg-danger', label: 'failed'},
23
+ timeout: {cls: 'bg-warning', label: 'timed out'},
24
+ no_embedding: {cls: 'bg-danger', label: 'no embedding'},
25
+ };
26
+
27
+ var CHANNEL_FAULTS = ['error', 'timeout', 'no_embedding'];
28
+
29
+ // Built with createElement and textContent throughout — the status strings come
30
+ // from the server, and this pane has no reason to be an injection sink.
31
+ function buildChannelHealth(statuses) {
32
+ var names = Object.keys(statuses || {});
33
+ if (names.length === 0) return null;
34
+ names.sort();
35
+
36
+ var wrap = document.createElement('div');
37
+ wrap.className = 'mt-2 d-flex flex-wrap align-items-center gap-1';
38
+
39
+ var degraded = names.filter(function(n) {
40
+ return CHANNEL_FAULTS.indexOf(statuses[n]) !== -1;
41
+ });
42
+
43
+ var lead = document.createElement('span');
44
+ lead.className = degraded.length ? 'me-1 text-danger fw-semibold' : 'me-1 text-muted';
45
+ lead.style.fontSize = '0.75rem';
46
+ // Said plainly, because the reason this strip exists is that a partly-broken
47
+ // retrieval path and a store with nothing to say produced the same answer.
48
+ lead.textContent = degraded.length
49
+ ? 'Retrieval degraded — this answer is missing what ' +
50
+ degraded.length + ' channel' + (degraded.length === 1 ? '' : 's') + ' would have found:'
51
+ : 'Channels:';
52
+ wrap.appendChild(lead);
53
+
54
+ names.forEach(function(name) {
55
+ var status = statuses[name];
56
+ var style = CHANNEL_STATUS_STYLE[status] || {cls: 'bg-dark', label: String(status)};
57
+ var badge = document.createElement('span');
58
+ badge.className = 'badge ' + style.cls;
59
+ badge.style.fontSize = '0.68rem';
60
+ badge.textContent = name + ' \u00b7 ' + style.label;
61
+ wrap.appendChild(badge);
62
+ });
63
+
64
+ return wrap;
65
+ }
66
+
12
67
  // Delegation handles the case where the recall-lab tab pane is injected after parse time.
13
68
  document.addEventListener('click', function(e) {
14
69
  if (!e.target || e.target.id !== 'recall-lab-search') return;
@@ -54,6 +109,14 @@ document.addEventListener('click', function(e) {
54
109
 
55
110
  recallLabState.allResults = data.results || [];
56
111
  recallLabState.synthesis = data.synthesis || '';
112
+ recallLabState.channelStatus = data.channel_status || {};
113
+
114
+ // Rendered BEFORE the no-results branch below, and that ordering is the
115
+ // whole point: every channel failing produces exactly zero results, so
116
+ // the case most in need of an explanation is the one that would
117
+ // otherwise show a bare 'No results found'.
118
+ var health = buildChannelHealth(recallLabState.channelStatus);
119
+ if (health) metaDiv.appendChild(health);
57
120
 
58
121
  if (recallLabState.allResults.length === 0) {
59
122
  resultsDiv.textContent = '';
@@ -118,9 +181,21 @@ function renderRecallPage() {
118
181
  numLabel.textContent = '#' + (globalIndex + 1);
119
182
  scoreRow.appendChild(numLabel);
120
183
  var scoreBadges = document.createElement('div');
121
- scoreBadges.innerHTML = '<span class="badge bg-primary me-1">Score: ' + r.score + '</span>' +
122
- '<span class="badge bg-secondary me-1">Trust: ' + r.trust_score + '</span>' +
123
- '<span class="badge bg-outline-info" style="border:1px solid #0dcaf0;color:#0dcaf0;">Conf: ' + r.confidence + '</span>';
184
+ // Built element by element rather than as an HTML string. The values are
185
+ // server-supplied, and this file's sibling panes are held to the same
186
+ // rule: this pane renders DOM, it does not parse markup.
187
+ [
188
+ ['badge bg-primary me-1', 'Score: ' + r.score, ''],
189
+ ['badge bg-secondary me-1', 'Trust: ' + r.trust_score, ''],
190
+ ['badge bg-outline-info', 'Conf: ' + r.confidence,
191
+ 'border:1px solid #0dcaf0;color:#0dcaf0;'],
192
+ ].forEach(function(spec) {
193
+ var badge = document.createElement('span');
194
+ badge.className = spec[0];
195
+ badge.textContent = spec[1];
196
+ if (spec[2]) badge.style.cssText = spec[2];
197
+ scoreBadges.appendChild(badge);
198
+ });
124
199
  scoreRow.appendChild(scoreBadges);
125
200
  item.appendChild(scoreRow);
126
201