superlocalmemory 4.0.9 → 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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -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 +308 -20
  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 +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -21,9 +21,12 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import logging
24
25
  import sqlite3
25
26
  from typing import Final
26
27
 
28
+ logger = logging.getLogger(__name__)
29
+
27
30
  # ---------------------------------------------------------------------------
28
31
  # Constants
29
32
  # ---------------------------------------------------------------------------
@@ -57,7 +60,9 @@ _TABLES: Final[tuple[str, ...]] = (
57
60
  "config",
58
61
  "entity_communities",
59
62
  "community_summaries",
63
+ "consolidated_summaries",
60
64
  "persona_summary",
65
+ "projection_outbox",
61
66
  )
62
67
 
63
68
  _FTS_TABLES: Final[tuple[str, ...]] = (
@@ -153,7 +158,7 @@ CREATE TABLE IF NOT EXISTS atomic_facts (
153
158
  content TEXT NOT NULL,
154
159
  fact_type TEXT NOT NULL DEFAULT 'semantic'
155
160
  CHECK (fact_type IN (
156
- 'episodic', 'semantic', 'opinion', 'temporal'
161
+ 'episodic', 'semantic', 'opinion', 'prospective'
157
162
  )),
158
163
 
159
164
  -- Entities (JSON arrays)
@@ -190,6 +195,11 @@ CREATE TABLE IF NOT EXISTS atomic_facts (
190
195
  CHECK (lifecycle IN (
191
196
  'active', 'warm', 'cold', 'archived'
192
197
  )),
198
+ -- Withheld from retrieval without being destroyed. Set by repair, never
199
+ -- by a normal write; enforced in exactly one place,
200
+ -- DatabaseManager.get_facts_by_ids, which every channel's candidates are
201
+ -- re-authorised through and which the engine hydrates from.
202
+ quarantined INTEGER NOT NULL DEFAULT 0,
193
203
  langevin_position TEXT,
194
204
 
195
205
  -- Emotional
@@ -842,6 +852,51 @@ CREATE INDEX IF NOT EXISTS idx_comm_summ_profile
842
852
  ON community_summaries(profile_id);
843
853
  """
844
854
 
855
+ # Display-only consolidated summaries.
856
+ #
857
+ # A summary of a cluster of facts is a VIEW of memory, not a memory. Between
858
+ # v3.6.4 and 4.0.9 the fact consolidator wrote its summaries straight into
859
+ # atomic_facts with a raw INSERT, which put model-authored prose into the
860
+ # retrieval corpus alongside the user's own words — where it out-ranked them,
861
+ # because those rows carried every entity in their cluster and so had more
862
+ # entity links than any real fact.
863
+ #
864
+ # This table restores the boundary. community_summaries is the precedent to
865
+ # read it by: written by one owner, read only after retrieval has finished, and
866
+ # named by no channel. tests/test_retrieval/test_summaries_stay_out_of_recall.py
867
+ # fails if a retrieval module so much as mentions it.
868
+ #
869
+ # source_earliest / source_latest are the honest dates for a derived row. A
870
+ # summary has no observation_date of its own — it was never observed — but the
871
+ # span of what it summarises is real, and it is what lets the dashboard say
872
+ # which stretch of work a summary covers.
873
+ CONSOLIDATED_SUMMARIES_DDL: Final[str] = """
874
+ CREATE TABLE IF NOT EXISTS consolidated_summaries (
875
+ summary_id TEXT PRIMARY KEY,
876
+ profile_id TEXT NOT NULL,
877
+ entity_id TEXT NOT NULL DEFAULT '',
878
+ entity_name TEXT NOT NULL DEFAULT '',
879
+ content TEXT NOT NULL,
880
+ source_fact_ids TEXT NOT NULL DEFAULT '[]',
881
+ source_count INTEGER NOT NULL DEFAULT 0,
882
+ char_count INTEGER NOT NULL DEFAULT 0,
883
+ generated_by TEXT NOT NULL DEFAULT 'extractive'
884
+ CHECK (generated_by IN (
885
+ 'extractive', 'ollama', 'cloud', 'migrated'
886
+ )),
887
+ scope TEXT NOT NULL DEFAULT 'personal',
888
+ shared_with TEXT,
889
+ source_earliest TEXT,
890
+ source_latest TEXT,
891
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
892
+ UNIQUE (profile_id, entity_id, content)
893
+ );
894
+ CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_profile
895
+ ON consolidated_summaries(profile_id, created_at DESC);
896
+ CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_entity
897
+ ON consolidated_summaries(profile_id, entity_id);
898
+ """
899
+
845
900
  # Wave Q3: progressive-abstraction top tier — one persona roll-up per profile
846
901
  # consuming the top community summaries (additive; safe on existing DBs).
847
902
  # Recall-gated (never auto-injected into hot recall) and size-bounded to avoid
@@ -861,6 +916,10 @@ CREATE TABLE IF NOT EXISTS persona_summary (
861
916
  # Ordered DDL list (tables before FTS, respects FK order)
862
917
  # ---------------------------------------------------------------------------
863
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
+
864
923
  _DDL_ORDERED: Final[tuple[str, ...]] = (
865
924
  _SQL_SCHEMA_VERSION,
866
925
  _SQL_PROFILES,
@@ -893,8 +952,15 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
893
952
  _SQL_ENTITY_COMMUNITIES,
894
953
  # Wave Q2: community summaries (additive; safe on existing DBs)
895
954
  _SQL_COMMUNITY_SUMMARIES,
955
+ CONSOLIDATED_SUMMARIES_DDL,
896
956
  # Wave Q3: persona roll-up tier (additive; safe on existing DBs)
897
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,
898
964
  )
899
965
 
900
966
 
@@ -902,6 +968,38 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
902
968
  # Public API
903
969
  # ---------------------------------------------------------------------------
904
970
 
971
+ #: (table, column, column definition). Applied with ALTER TABLE ADD COLUMN,
972
+ #: which SQLite offers no IF NOT EXISTS form of, so presence is checked first.
973
+ _ADDITIVE_COLUMNS: Final[tuple[tuple[str, str, str], ...]] = (
974
+ ("atomic_facts", "quarantined", "INTEGER NOT NULL DEFAULT 0"),
975
+ )
976
+
977
+
978
+ def _add_missing_columns(conn: sqlite3.Connection) -> None:
979
+ """Add columns that upgraded databases predate. Idempotent.
980
+
981
+ A missing table is not an error: this runs inside create_all_tables, so the
982
+ table is created moments earlier in the same call, and a database old enough
983
+ to lack it entirely has nothing to alter.
984
+ """
985
+ for table, column, definition in _ADDITIVE_COLUMNS:
986
+ try:
987
+ present = any(
988
+ row[1] == column
989
+ for row in conn.execute(f"PRAGMA table_info({table})")
990
+ )
991
+ if not present:
992
+ conn.execute(
993
+ f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
994
+ )
995
+ except sqlite3.Error as exc:
996
+ # Never fatal. A store that cannot take the column keeps working;
997
+ # get_facts_by_ids checks for the column before filtering on it.
998
+ logger.warning(
999
+ "additive column %s.%s not applied: %s", table, column, exc,
1000
+ )
1001
+
1002
+
905
1003
  def create_all_tables(conn: sqlite3.Connection) -> None:
906
1004
  """Create every table, index, trigger, and FTS virtual table.
907
1005
 
@@ -921,6 +1019,17 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
921
1019
  for ddl in V32_DDL:
922
1020
  conn.executescript(ddl)
923
1021
 
1022
+ # Additive columns on tables that predate them.
1023
+ #
1024
+ # CREATE TABLE IF NOT EXISTS cannot add a column to a table that already
1025
+ # exists, so an upgraded database gets the column here rather than only from
1026
+ # a migration. Doing it at every engine init makes the invariant "if
1027
+ # atomic_facts exists then quarantined exists" hold even when the migration
1028
+ # pass failed or was never reached — which matters because withholding a
1029
+ # poisoned row from retrieval must not be contingent on a migration having
1030
+ # succeeded.
1031
+ _add_missing_columns(conn)
1032
+
924
1033
  # Seed schema version on first run.
925
1034
  existing = conn.execute(
926
1035
  "SELECT COUNT(*) AS n FROM schema_version"
@@ -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
 
@@ -62,7 +62,7 @@ class SummaryResult:
62
62
 
63
63
  # ── coverage constants ──────────────────────────────────────────────────────
64
64
  #
65
- # Use these strings; the acceptance gate checks for their presence
65
+ # Use these strings; the tests check for their presence
66
66
  # and the values must be human-interpretable without this file.
67
67
 
68
68
  COVERAGE_FULL = "full"
@@ -0,0 +1,223 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Refuse to store a model's non-answer as if it were a memory.
5
+
6
+ A summarizer is handed a cluster of facts and asked to merge them. When the
7
+ cluster has nothing in common the model does not fail — it answers the question
8
+ it was asked, in prose, and that prose is indistinguishable from a summary to
9
+ any caller that only checks ``if summary:``. On the author's own store the
10
+ result was rows reading
11
+
12
+ "Unfortunately, there is no information available about 'Gateway', 'State',
13
+ 'Bounded', or 'Claude' in the provided text."
14
+
15
+ sitting at ranks 1, 2 and 3 for "what am I working on".
16
+
17
+ WHAT THIS IS AND IS NOT
18
+ -----------------------
19
+ This is a **forward guard**: it stops the next such row from being written. It
20
+ is deliberately NOT the repair for rows already stored, because it cannot be.
21
+ Measured across the 307 retrieval-eligible consolidated rows on the author's
22
+ store, this predicate rejects **34** and lets **273** through — because those
23
+ 273 are fluent, plausible, entirely generic prose ("The Pro and
24
+ SuperLocalMemory (SLM) projects have made significant progress...") that no
25
+ honest content predicate can separate from a real summary. Repairing by content
26
+ would have cleared a ninth of the problem and declared victory. Existing rows
27
+ are handled by provenance instead.
28
+
29
+ Measured the other way, on 3,894 genuine facts, it rejects 70 — and all 70 are
30
+ real defects, not false positives: 68 memories carry raw tool-call markup
31
+ scraped in from a transcript, and 2 are a model's refusal that was stored as
32
+ though it were a memory ("I cannot verify when ... ended a session. Can I help
33
+ you with something else?"). Zero legitimate memories are rejected.
34
+
35
+ So the bar here is: catch text that is *addressed to the prompt* rather than
36
+ *about the facts*, and nothing else. Everything is anchored to the start of the
37
+ text or to a whole leading sentence, because a genuine memory may legitimately
38
+ contain "there is no" in the middle of a sentence.
39
+
40
+ Companion to ``clean_llm_summary`` in :mod:`superlocalmemory.summaries.base`,
41
+ which strips scaffolding *around* an answer. This one rejects text that is
42
+ scaffolding *all the way through*. Run the stripper first: "Here is a concise
43
+ summary paragraph: <real content>" is salvageable and must not be discarded.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import re
49
+
50
+ __all__ = ["is_non_answer", "NON_ANSWER_PATTERNS", "MIN_USEFUL_CHARS"]
51
+
52
+
53
+ #: A *merged summary* this short did not merge anything. The summarizers already
54
+ #: refuse model output under 50 characters; this is the same floor applied to
55
+ #: text that arrived by another route (extractive mode, or a pre-computed summary
56
+ #: handed in by a caller).
57
+ #:
58
+ #: It is NOT a floor for memories in general, and ``is_non_answer`` therefore
59
+ #: does not apply it unless a caller asks. Measured on the author's store, 730
60
+ #: of 3,894 genuine facts are under 50 characters and every sampled one is a
61
+ #: real memory — "2026-05-02 is the date when the session ended",
62
+ #: "This is the case for keeping AMS." Baking this floor into the default would
63
+ #: have made the guard reject a fifth of a user's memory as junk. The floor is
64
+ #: the *caller's* policy about its own output, not a fact about text.
65
+ MIN_USEFUL_CHARS = 50
66
+
67
+
68
+ #: Each entry is (regex, why-it-is-not-a-memory). The reason travels with the
69
+ #: pattern so a future reader can tell whether a new false positive means the
70
+ #: pattern is wrong or the input genuinely is a non-answer.
71
+ _PATTERN_SOURCES: tuple[tuple[str, str], ...] = (
72
+ (
73
+ r"^\W*(?:unfortunately|regrettably|sadly)\b[^.!?]*\bno\b",
74
+ "opens by apologising for having nothing to say",
75
+ ),
76
+ (
77
+ r"^\W*there\s+(?:is|are)\s+no\s+"
78
+ r"(?:information|mention|reference|facts?|details?|data|content)\b",
79
+ "states the absence of input rather than summarising input",
80
+ ),
81
+ (
82
+ r"\bno\s+(?:information|facts?|details?|data)\s+"
83
+ r"(?:is|are|was|were)?\s*(?:available|provided|given|present)\b",
84
+ "reports an empty input set",
85
+ ),
86
+ (
87
+ r"^\W*(?:i\s+(?:cannot|can't|can\s+not|am\s+unable\s+to)|"
88
+ r"it\s+is\s+not\s+possible\s+to)\b",
89
+ "declines the task",
90
+ ),
91
+ (
92
+ r"^\W*(?:i\s+don'?t|i\s+do\s+not)\s+(?:have|see|find)\b",
93
+ "declines the task in the first person",
94
+ ),
95
+ (
96
+ r"\bthe\s+(?:provided|given|above|following)\s+"
97
+ r"(?:text|facts?|context|input|information)\b",
98
+ "refers to the prompt, so it is talking to the asker, not about the memory",
99
+ ),
100
+ (
101
+ r"^\W*(?:as\s+an?\s+(?:ai|language\s+model)|i'?m\s+an?\s+ai)\b",
102
+ "identifies itself as a model",
103
+ ),
104
+ (
105
+ r"^\W*(?:please\s+)?(?:provide|share|give)\s+(?:me\s+)?"
106
+ r"(?:more|the|some|additional)\b",
107
+ "asks the user for input instead of answering",
108
+ ),
109
+ # The five below were added after running the first four against all 1,035
110
+ # summaries stored on the author's machine. They catch 14 rows the original
111
+ # set let through -- every one a measured string from that store, not a
112
+ # guess about what a model might say.
113
+ (
114
+ r"^\W*i\s+(?:did\s*n[o']?t|didn'?t)\s+receive\b",
115
+ "says it was given nothing to summarise",
116
+ ),
117
+ (
118
+ r"\bthere\s+(?:is|are|was|were)\s+n(?:o|ot)\s+\d+\s+facts?\b",
119
+ "argues with the number of facts it was asked to merge",
120
+ ),
121
+ (
122
+ r"\bthe\s+text\s+(?:snippet\s+)?"
123
+ r"(?:appears|seems|does\s+not|doesn'?t|is\s+not)\b",
124
+ "describes the prompt instead of summarising it",
125
+ ),
126
+ (
127
+ r"\bin\s+the\s+text\s+(?:provided|given|above|supplied)\b"
128
+ r"|\bthe\s+text\s+(?:provided|supplied)\b",
129
+ "refers to the prompt (word order the earlier rule missed)",
130
+ ),
131
+ (
132
+ r"^\W*i\s+must\s+point\s+out\b",
133
+ "opens with meta-commentary about the request",
134
+ ),
135
+ (
136
+ # "This appears to be a detailed log of progress in writing..." — the
137
+ # model describing the shape of what it was shown rather than saying
138
+ # what it says. Six of the first twelve rows the dashboard rendered
139
+ # opened this way. Deliberately narrower than it could be: "This is a
140
+ # summary of an audit session" is clumsy but it does summarise, so it
141
+ # is left alone.
142
+ r"^\W*this\s+(?:appears|seems)\s+to\s+be\b"
143
+ r"|^\W*this\s+text\s+(?:is|appears|seems)\b",
144
+ "describes the shape of the input instead of its content",
145
+ ),
146
+ )
147
+
148
+ #: Compiled once. ``re.IGNORECASE`` throughout — the casing of a refusal is not
149
+ #: information. ``re.DOTALL`` is deliberately NOT set: ``[^.!?]*`` and the
150
+ #: leading anchors are meant to stay within the opening sentence.
151
+ NON_ANSWER_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = tuple(
152
+ (re.compile(src, re.IGNORECASE), why) for src, why in _PATTERN_SOURCES
153
+ )
154
+
155
+ #: Tool-call and markup fragments. A memory whose content carries these was
156
+ #: assembled from a transcript that still had its plumbing attached; the user
157
+ #: sees raw XML in their own memory list. Matched anywhere, not anchored,
158
+ #: because the fragment can appear at any offset in a spliced transcript.
159
+ _MARKUP = re.compile(
160
+ r"</(?:content|antml:parameter|parameter|invoke|function_calls|thinking)>"
161
+ r"|<(?:antml:)?(?:parameter|invoke|function_calls)\b"
162
+ r"|<\|(?:im_start|im_end|endoftext)\|>",
163
+ re.IGNORECASE,
164
+ )
165
+
166
+
167
+ def is_non_answer(
168
+ text: str | None,
169
+ *,
170
+ min_chars: int = 0,
171
+ model_written: bool = True,
172
+ ) -> tuple[bool, str]:
173
+ """Whether ``text`` is a model talking about the prompt, not a memory.
174
+
175
+ Returns ``(rejected, reason)``. ``reason`` is empty when the text is
176
+ acceptable, and otherwise names which rule fired — callers log it, so a
177
+ silent rejection never happens and a false positive is diagnosable from
178
+ normal logs rather than needing a repro.
179
+
180
+ ``min_chars`` defaults to **no floor**. A caller that knows its own output
181
+ should be long — a summarizer merging three or more facts — passes
182
+ :data:`MIN_USEFUL_CHARS`. See that constant for why this is not the default.
183
+
184
+ ``model_written=False`` applies only the rules that hold for text no model
185
+ composed: the length floor and the markup check. Every other rule here
186
+ describes a MODEL's behaviour — refusing, apologising, discussing the
187
+ prompt — and an extractive summary is the owner's own sentences reassembled,
188
+ so a memory that happens to say "the facts provided" would be rejected for
189
+ having phrased itself like a chatbot. Nothing is lost by exempting it: an
190
+ extractive summary cannot refuse, because there is nobody there to refuse.
191
+
192
+ Cheap and side-effect free: safe to call on every candidate write.
193
+
194
+ >>> is_non_answer("Unfortunately, there is no information available.")[0]
195
+ True
196
+ >>> is_non_answer("Varun ships SLM 4.0.10 with an auto-repair migration.")
197
+ (False, '')
198
+ >>> is_non_answer("The team has no information silo left to dismantle.")[0]
199
+ False
200
+ >>> is_non_answer("2026-05-02 is the date the session ended.")
201
+ (False, '')
202
+ >>> is_non_answer("Too short.", min_chars=MIN_USEFUL_CHARS)[0]
203
+ True
204
+ """
205
+ if text is None:
206
+ return True, "empty"
207
+ stripped = text.strip()
208
+ if not stripped:
209
+ return True, "empty"
210
+ if min_chars and len(stripped) < min_chars:
211
+ return True, f"shorter than {min_chars} characters"
212
+
213
+ if _MARKUP.search(stripped):
214
+ return True, "contains tool-call or chat-template markup"
215
+
216
+ if not model_written:
217
+ return False, ""
218
+
219
+ for pattern, why in NON_ANSWER_PATTERNS:
220
+ if pattern.search(stripped):
221
+ return True, why
222
+
223
+ return False, ""
@@ -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 -->
@@ -1604,7 +1595,7 @@
1604
1595
  <script src="static/js/od-ops-health.js?v=400"></script>
1605
1596
  <script src="static/js/od-team.js?v=379"></script>
1606
1597
  <script src="static/js/od-graph.js?v=6812bf6c"></script>
1607
- <script src="static/js/od-memories.js?v=022ff653"></script>
1598
+ <script src="static/js/od-memories.js?v=997f1674"></script>
1608
1599
  <script src="static/js/od-entities.js?v=379"></script>
1609
1600
  <!-- Multi-Agent Memory pane (v3.8.0): visualises memory written by multiple agents -->
1610
1601
  <script src="static/js/od-agents.js?v=c75ff3c2"></script>
@@ -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 },