superlocalmemory 4.0.8 → 4.0.10

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 (147) hide show
  1. package/CHANGELOG.md +221 -1
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/scripts/ensure-venv.sh +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 +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +3 -3
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-status/SKILL.md +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/postinstall.js +4 -0
  28. package/src/superlocalmemory/__init__.py +1 -1
  29. package/src/superlocalmemory/cli/_lazy_init.py +1 -1
  30. package/src/superlocalmemory/cli/commands.py +164 -11
  31. package/src/superlocalmemory/cli/db_migrate.py +0 -2
  32. package/src/superlocalmemory/cli/gdpr_io.py +1 -1
  33. package/src/superlocalmemory/cli/main.py +7 -7
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  37. package/src/superlocalmemory/core/config.py +41 -7
  38. package/src/superlocalmemory/core/context_cache.py +0 -2
  39. package/src/superlocalmemory/core/engine.py +371 -63
  40. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  41. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  42. package/src/superlocalmemory/core/install_detector.py +131 -0
  43. package/src/superlocalmemory/core/maintenance.py +44 -6
  44. package/src/superlocalmemory/core/memory_health.py +266 -0
  45. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  46. package/src/superlocalmemory/core/operation_request.py +1 -1
  47. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  48. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  49. package/src/superlocalmemory/core/security_primitives.py +3 -6
  50. package/src/superlocalmemory/core/store_pipeline.py +172 -29
  51. package/src/superlocalmemory/core/topic_signature.py +0 -2
  52. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  53. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  54. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  55. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  56. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  57. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  58. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  59. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  60. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  61. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  63. package/src/superlocalmemory/infra/backup.py +44 -8
  64. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  65. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  66. package/src/superlocalmemory/learning/bandit.py +0 -2
  67. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  68. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  69. package/src/superlocalmemory/learning/ensemble.py +0 -2
  70. package/src/superlocalmemory/learning/labeler.py +0 -2
  71. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  72. package/src/superlocalmemory/learning/model_cache.py +0 -2
  73. package/src/superlocalmemory/learning/ranker.py +0 -2
  74. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  75. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  76. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  77. package/src/superlocalmemory/math/fisher.py +1 -1
  78. package/src/superlocalmemory/math/hopfield.py +4 -1
  79. package/src/superlocalmemory/math/langevin.py +1 -1
  80. package/src/superlocalmemory/math/sheaf.py +7 -3
  81. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  82. package/src/superlocalmemory/mcp/profiles.py +1 -1
  83. package/src/superlocalmemory/mcp/server.py +2 -2
  84. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  85. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  86. package/src/superlocalmemory/mcp/tools_core.py +41 -40
  87. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  88. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  89. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  90. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  92. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  93. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  94. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  95. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  96. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  97. package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
  98. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  99. package/src/superlocalmemory/retrieval/engine.py +272 -43
  100. package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
  101. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  102. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  103. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  104. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  105. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  106. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  107. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  108. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  109. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  110. package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
  111. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  112. package/src/superlocalmemory/retrieval/vector_store.py +151 -10
  113. package/src/superlocalmemory/server/api.py +6 -1
  114. package/src/superlocalmemory/server/asset_versions.py +171 -0
  115. package/src/superlocalmemory/server/recall_serializer.py +10 -0
  116. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  117. package/src/superlocalmemory/server/routes/brain.py +2 -2
  118. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  119. package/src/superlocalmemory/server/routes/entity.py +13 -1
  120. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  121. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  122. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  123. package/src/superlocalmemory/server/ui.py +8 -1
  124. package/src/superlocalmemory/server/unified_daemon.py +570 -16
  125. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  126. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  127. package/src/superlocalmemory/storage/backup.py +531 -0
  128. package/src/superlocalmemory/storage/database.py +139 -34
  129. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  130. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  131. package/src/superlocalmemory/storage/migration_runner.py +153 -2
  132. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  133. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  134. package/src/superlocalmemory/storage/migrations.py +15 -1
  135. package/src/superlocalmemory/storage/models.py +7 -0
  136. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  137. package/src/superlocalmemory/storage/schema.py +98 -0
  138. package/src/superlocalmemory/summaries/base.py +1 -1
  139. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  140. package/src/superlocalmemory/summaries/session_summary.py +1 -1
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  142. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  143. package/src/superlocalmemory/ui/index.html +1 -1
  144. package/src/superlocalmemory/ui/js/core.js +1 -1
  145. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  146. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  147. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -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,6 +60,7 @@ _TABLES: Final[tuple[str, ...]] = (
57
60
  "config",
58
61
  "entity_communities",
59
62
  "community_summaries",
63
+ "consolidated_summaries",
60
64
  "persona_summary",
61
65
  )
62
66
 
@@ -190,6 +194,11 @@ CREATE TABLE IF NOT EXISTS atomic_facts (
190
194
  CHECK (lifecycle IN (
191
195
  'active', 'warm', 'cold', 'archived'
192
196
  )),
197
+ -- Withheld from retrieval without being destroyed. Set by repair, never
198
+ -- by a normal write; enforced in exactly one place,
199
+ -- DatabaseManager.get_facts_by_ids, which every channel's candidates are
200
+ -- re-authorised through and which the engine hydrates from.
201
+ quarantined INTEGER NOT NULL DEFAULT 0,
193
202
  langevin_position TEXT,
194
203
 
195
204
  -- Emotional
@@ -842,6 +851,51 @@ CREATE INDEX IF NOT EXISTS idx_comm_summ_profile
842
851
  ON community_summaries(profile_id);
843
852
  """
844
853
 
854
+ # Display-only consolidated summaries.
855
+ #
856
+ # A summary of a cluster of facts is a VIEW of memory, not a memory. Between
857
+ # v3.6.4 and 4.0.9 the fact consolidator wrote its summaries straight into
858
+ # atomic_facts with a raw INSERT, which put model-authored prose into the
859
+ # retrieval corpus alongside the user's own words — where it out-ranked them,
860
+ # because those rows carried every entity in their cluster and so had more
861
+ # entity links than any real fact.
862
+ #
863
+ # This table restores the boundary. community_summaries is the precedent to
864
+ # read it by: written by one owner, read only after retrieval has finished, and
865
+ # named by no channel. tests/test_retrieval/test_summaries_stay_out_of_recall.py
866
+ # fails if a retrieval module so much as mentions it.
867
+ #
868
+ # source_earliest / source_latest are the honest dates for a derived row. A
869
+ # summary has no observation_date of its own — it was never observed — but the
870
+ # span of what it summarises is real, and it is what lets the dashboard say
871
+ # which stretch of work a summary covers.
872
+ CONSOLIDATED_SUMMARIES_DDL: Final[str] = """
873
+ CREATE TABLE IF NOT EXISTS consolidated_summaries (
874
+ summary_id TEXT PRIMARY KEY,
875
+ profile_id TEXT NOT NULL,
876
+ entity_id TEXT NOT NULL DEFAULT '',
877
+ entity_name TEXT NOT NULL DEFAULT '',
878
+ content TEXT NOT NULL,
879
+ source_fact_ids TEXT NOT NULL DEFAULT '[]',
880
+ source_count INTEGER NOT NULL DEFAULT 0,
881
+ char_count INTEGER NOT NULL DEFAULT 0,
882
+ generated_by TEXT NOT NULL DEFAULT 'extractive'
883
+ CHECK (generated_by IN (
884
+ 'extractive', 'ollama', 'cloud', 'migrated'
885
+ )),
886
+ scope TEXT NOT NULL DEFAULT 'personal',
887
+ shared_with TEXT,
888
+ source_earliest TEXT,
889
+ source_latest TEXT,
890
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
891
+ UNIQUE (profile_id, entity_id, content)
892
+ );
893
+ CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_profile
894
+ ON consolidated_summaries(profile_id, created_at DESC);
895
+ CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_entity
896
+ ON consolidated_summaries(profile_id, entity_id);
897
+ """
898
+
845
899
  # Wave Q3: progressive-abstraction top tier — one persona roll-up per profile
846
900
  # consuming the top community summaries (additive; safe on existing DBs).
847
901
  # Recall-gated (never auto-injected into hot recall) and size-bounded to avoid
@@ -893,6 +947,7 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
893
947
  _SQL_ENTITY_COMMUNITIES,
894
948
  # Wave Q2: community summaries (additive; safe on existing DBs)
895
949
  _SQL_COMMUNITY_SUMMARIES,
950
+ CONSOLIDATED_SUMMARIES_DDL,
896
951
  # Wave Q3: persona roll-up tier (additive; safe on existing DBs)
897
952
  _SQL_PERSONA_SUMMARY,
898
953
  )
@@ -902,6 +957,38 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
902
957
  # Public API
903
958
  # ---------------------------------------------------------------------------
904
959
 
960
+ #: (table, column, column definition). Applied with ALTER TABLE ADD COLUMN,
961
+ #: which SQLite offers no IF NOT EXISTS form of, so presence is checked first.
962
+ _ADDITIVE_COLUMNS: Final[tuple[tuple[str, str, str], ...]] = (
963
+ ("atomic_facts", "quarantined", "INTEGER NOT NULL DEFAULT 0"),
964
+ )
965
+
966
+
967
+ def _add_missing_columns(conn: sqlite3.Connection) -> None:
968
+ """Add columns that upgraded databases predate. Idempotent.
969
+
970
+ A missing table is not an error: this runs inside create_all_tables, so the
971
+ table is created moments earlier in the same call, and a database old enough
972
+ to lack it entirely has nothing to alter.
973
+ """
974
+ for table, column, definition in _ADDITIVE_COLUMNS:
975
+ try:
976
+ present = any(
977
+ row[1] == column
978
+ for row in conn.execute(f"PRAGMA table_info({table})")
979
+ )
980
+ if not present:
981
+ conn.execute(
982
+ f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
983
+ )
984
+ except sqlite3.Error as exc:
985
+ # Never fatal. A store that cannot take the column keeps working;
986
+ # get_facts_by_ids checks for the column before filtering on it.
987
+ logger.warning(
988
+ "additive column %s.%s not applied: %s", table, column, exc,
989
+ )
990
+
991
+
905
992
  def create_all_tables(conn: sqlite3.Connection) -> None:
906
993
  """Create every table, index, trigger, and FTS virtual table.
907
994
 
@@ -921,6 +1008,17 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
921
1008
  for ddl in V32_DDL:
922
1009
  conn.executescript(ddl)
923
1010
 
1011
+ # Additive columns on tables that predate them.
1012
+ #
1013
+ # CREATE TABLE IF NOT EXISTS cannot add a column to a table that already
1014
+ # exists, so an upgraded database gets the column here rather than only from
1015
+ # a migration. Doing it at every engine init makes the invariant "if
1016
+ # atomic_facts exists then quarantined exists" hold even when the migration
1017
+ # pass failed or was never reached — which matters because withholding a
1018
+ # poisoned row from retrieval must not be contingent on a migration having
1019
+ # succeeded.
1020
+ _add_missing_columns(conn)
1021
+
924
1022
  # Seed schema version on first run.
925
1023
  existing = conn.execute(
926
1024
  "SELECT COUNT(*) AS n FROM schema_version"
@@ -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, ""
@@ -8,7 +8,7 @@ CRITICAL DATA HONESTY NOTE
8
8
  ---------------------------
9
9
  On a real 3,294-fact store, only 127 facts (3.9%) carry a session_id.
10
10
  A Session Summary that presents itself as "everything you did this session"
11
- while silently covering 4% of the facts is the same overclaiming Wave 4
11
+ while silently covering 4% of the facts is overclaiming — the same defect
12
12
  removed from brain/truth.py.
13
13
 
14
14
  Coverage is ALWAYS disclosed. If the session has too few facts to summarise
@@ -202,7 +202,7 @@
202
202
  margin-bottom: 12px;
203
203
  }
204
204
 
205
- /* Pane error state — WP-12 */
205
+ /* Pane error state */
206
206
  .pane-error {
207
207
  text-align: center;
208
208
  padding: 40px;
@@ -1593,7 +1593,7 @@ body.ng-privacy-blur::before {
1593
1593
  }
1594
1594
  }
1595
1595
 
1596
- /* WP-12 — pane-error dark-mode override */
1596
+ /* pane-error dark-mode override */
1597
1597
  .ng-dark .pane-error {
1598
1598
  color: var(--ng-status-error);
1599
1599
  }
@@ -1604,7 +1604,7 @@
1604
1604
  <script src="static/js/od-ops-health.js?v=400"></script>
1605
1605
  <script src="static/js/od-team.js?v=379"></script>
1606
1606
  <script src="static/js/od-graph.js?v=6812bf6c"></script>
1607
- <script src="static/js/od-memories.js?v=022ff653"></script>
1607
+ <script src="static/js/od-memories.js?v=997f1674"></script>
1608
1608
  <script src="static/js/od-entities.js?v=379"></script>
1609
1609
  <!-- Multi-Agent Memory pane (v3.8.0): visualises memory written by multiple agents -->
1610
1610
  <script src="static/js/od-agents.js?v=c75ff3c2"></script>
@@ -292,7 +292,7 @@ function showEmpty(containerId, icon, message) {
292
292
  }
293
293
 
294
294
  // ============================================================================
295
- // Pane error state — WP-12
295
+ // Pane error state
296
296
  // showPaneError / clearPaneError / paneErrorMessage
297
297
  // ============================================================================
298
298
 
@@ -774,7 +774,7 @@
774
774
  var evb = EL('div', { className: 'card-pad' });
775
775
 
776
776
  if (registryStatus === 'error' || registryStatus === 'unknown') {
777
- // Wave 4 honesty rule: failure and emptiness must not return the same value.
777
+ // Honesty constraint: failure and emptiness must not return the same value.
778
778
  // 'unknown' belongs HERE, not in the healthy-empty branch below. The
779
779
  // registry reader is fail-soft — a corrupt or unreadable file makes
780
780
  // _load() return {} and active_client_summary() return [], so the read
@@ -189,6 +189,18 @@
189
189
  '</select>' +
190
190
  '</div>' +
191
191
  '<div id="' + id + '-sum-out" style="font-size:13px;color:var(--fg-2)">Pick a summary above.</div>' +
192
+ // Knowledge Overview.
193
+ //
194
+ // The Today/Yesterday buttons above are a date filter over
195
+ // atomic_facts and have never had any link to a cluster, so the
196
+ // Summaries tab could not show what the store actually knows ABOUT
197
+ // anything. This card fills that: cluster summaries, read from the
198
+ // display-only table, which is where they belong now that they are
199
+ // out of the retrieval corpus.
200
+ '<div id="' + id + '-kover" style="margin-top:26px;' +
201
+ 'border-top:1px solid var(--border);padding-top:20px">' +
202
+ _loading('Loading what your memory knows…') +
203
+ '</div>' +
192
204
  '</div>' +
193
205
  // Recall Lab pane — exact IDs required by recall-lab.js:
194
206
  // #recall-lab-query (input), #recall-lab-search (button — click check),
@@ -363,7 +375,7 @@
363
375
  if (pane) pane.classList.add('active');
364
376
  if (tab === 'timeline') _loadTimeline(id);
365
377
  if (tab === 'clusters') _loadClusters(id);
366
- if (tab === 'summary') _loadProjectOptions(id);
378
+ if (tab === 'summary') { _loadProjectOptions(id); _loadKnowledgeOverview(id); }
367
379
  }
368
380
 
369
381
  /* Populate the project picker from projects SLM has actually recorded.
@@ -815,6 +827,183 @@
815
827
 
816
828
  // ── Clusters fetch & render ──────────────────────────────────────────────────
817
829
 
830
+ /* Knowledge Overview — cluster summaries, plus an honest health line.
831
+ *
832
+ * Two rules this card exists to obey.
833
+ *
834
+ * IT READS ONLY THE DISPLAY TABLE. These summaries were in the retrieval
835
+ * corpus until 4.0.10, where they out-ranked the user's own words. Moving
836
+ * them out is only worth anything if exactly one surface shows them, and
837
+ * this is that surface.
838
+ *
839
+ * IT DOES NOT DRESS UP JUNK AS INSIGHT. Some stored summaries are a model's
840
+ * non-answer -- "Unfortunately, there is no information available about
841
+ * 'State'..." -- and rendering those as a heading called Knowledge Overview
842
+ * makes the product look broken. The endpoint labels each row's quality; the
843
+ * usable ones are shown and the rest are counted in one plain sentence. Not
844
+ * hidden: hiding them would hide the very problem the release fixes, and a
845
+ * reader who is told "3 could not be generated" trusts the other 47 more.
846
+ */
847
+ function _loadKnowledgeOverview(id) {
848
+ var box = document.getElementById(id + '-kover');
849
+ if (!box) return;
850
+ // Guarded on the element, not a module flag — same reason as
851
+ // _loadProjectOptions: a stale "already loaded" boolean outlives the DOM
852
+ // it described and leaves a permanent spinner after a re-render.
853
+ if (box.dataset.loaded === '1') return;
854
+
855
+ Promise.all([
856
+ fetch('/api/v3/abstraction/consolidated?limit=24')
857
+ .then(function (r) { return r.ok ? r.json() : { summaries: [], unusable: 0 }; })
858
+ .catch(function () { return { summaries: [], unusable: 0 }; }),
859
+ fetch('/api/v3/abstraction/health')
860
+ .then(function (r) { return r.ok ? r.json() : null; })
861
+ .catch(function () { return null; })
862
+ ]).then(function (res) {
863
+ box.dataset.loaded = '1';
864
+ box.innerHTML = _koverHtml(res[0] || {}, res[1]);
865
+ });
866
+ }
867
+
868
+ function _koverHtml(data, health) {
869
+ var all = data.summaries || [];
870
+ var usable = all.filter(function (s) { return s.quality === 'ok'; });
871
+ var unusable = typeof data.unusable === 'number' ? data.unusable
872
+ : (all.length - usable.length);
873
+
874
+ var html = '<h3 style="font-size:15px;margin:0 0 4px">What your memory knows</h3>' +
875
+ '<p style="font-size:12px;color:var(--fg-2);margin:0 0 14px">' +
876
+ 'Written by the summarizer from groups of your memories. These are a ' +
877
+ 'view of your memory, not memories themselves — they are never returned ' +
878
+ 'as answers.' +
879
+ '</p>';
880
+
881
+ if (health) html += _koverHealth(health);
882
+
883
+ if (usable.length === 0) {
884
+ html += '<div style="padding:20px;color:var(--fg-2);font-size:13px">' +
885
+ (all.length === 0
886
+ ? 'No summaries yet. They are written in the background as related ' +
887
+ 'memories accumulate.'
888
+ : 'None of the ' + all.length + ' stored summaries came back usable. ' +
889
+ 'That usually means the summarizer had nothing in common to merge.') +
890
+ '</div>';
891
+ return html;
892
+ }
893
+
894
+ html += '<div style="display:grid;gap:12px;' +
895
+ 'grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">';
896
+ usable.forEach(function (s) { html += _koverCard(s); });
897
+ html += '</div>';
898
+
899
+ var notes = [];
900
+ if (unusable > 0) {
901
+ notes.push(unusable + (unusable === 1 ? ' summary' : ' summaries') +
902
+ ' could not be generated and are not shown. The memories they were ' +
903
+ 'built from are unaffected.');
904
+ }
905
+ if (data.near_duplicates > 0) {
906
+ notes.push(data.near_duplicates + ' near-identical ' +
907
+ (data.near_duplicates === 1 ? 'summary was' : 'summaries were') +
908
+ ' collapsed into the ones above.');
909
+ }
910
+ if (notes.length) {
911
+ html += '<p style="font-size:12px;color:var(--fg-2);margin-top:12px">' +
912
+ notes.map(_esc).join(' ') + '</p>';
913
+ }
914
+ return html;
915
+ }
916
+
917
+ function _koverCard(s) {
918
+ // Every field is escaped. Anything in a memory can reach this screen: the
919
+ // summarizer merges fact content verbatim, so a memory containing markup
920
+ // would otherwise render as markup.
921
+ var body = _koverPlain(s.content);
922
+ var clipped = body.length > 320;
923
+ var shown = clipped ? body.slice(0, 320).replace(/\s+\S*$/, '') + '…' : body;
924
+
925
+ var meta = [];
926
+ if (s.source_count) {
927
+ meta.push(s.source_count + (s.source_count === 1 ? ' memory' : ' memories'));
928
+ }
929
+ var span = _koverSpan(s.source_earliest, s.source_latest);
930
+ if (span) meta.push(span);
931
+
932
+ return '<div style="background:var(--card-2);border:1px solid var(--border);' +
933
+ 'border-radius:var(--r-md);padding:14px">' +
934
+ (s.entity_name
935
+ ? '<div style="font-size:13px;font-weight:600;margin-bottom:6px">' +
936
+ _esc(s.entity_name) + '</div>'
937
+ : '') +
938
+ '<div style="font-size:13px;line-height:1.5">' + _esc(shown) + '</div>' +
939
+ (meta.length
940
+ ? '<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">' +
941
+ meta.map(_koverChip).join('') +
942
+ '</div>'
943
+ : '') +
944
+ '</div>';
945
+ }
946
+
947
+ /* Flatten a summary to plain prose for display.
948
+ *
949
+ * Mode B/C summaries come back with markdown in them — "**Audit Round**",
950
+ * "**Findings**:" — because a chat-tuned model formats for a chat window.
951
+ * Rendered as text those asterisks read as broken output to exactly the
952
+ * reader this card is for. Escaping is still what protects the page; this
953
+ * only removes the emphasis marks that carry no meaning here.
954
+ */
955
+ function _koverPlain(text) {
956
+ return String(text || '')
957
+ .replace(/```[\s\S]*?```/g, ' ') // fenced blocks: no room for them
958
+ .replace(/`([^`]*)`/g, '$1') // inline code marks
959
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
960
+ .replace(/(^|\s)\*([^*\s][^*]*)\*/g, '$1$2') // italic, not bullets
961
+ .replace(/^#{1,6}\s+/gm, '') // headings
962
+ .replace(/^\s*[-*+]\s+/gm, '') // list bullets
963
+ .replace(/\s+/g, ' ') // collapse whitespace + newlines
964
+ .trim();
965
+ }
966
+
967
+ /* A chip, not a keyword dump.
968
+ *
969
+ * The neighbouring community-summary API returns its `summary` field as
970
+ * "Topics: CCQ, Resume, P07…", which printed verbatim reads as a broken
971
+ * page. Counts and date spans are rendered as discrete labels instead.
972
+ */
973
+ function _koverChip(text) {
974
+ return '<span style="font-size:11px;padding:2px 8px;border-radius:999px;' +
975
+ 'background:var(--card);border:1px solid var(--border);color:var(--fg-2)">' +
976
+ _esc(text) + '</span>';
977
+ }
978
+
979
+ function _koverSpan(earliest, latest) {
980
+ var a = String(earliest || '').slice(0, 10);
981
+ var b = String(latest || '').slice(0, 10);
982
+ if (!a && !b) return '';
983
+ if (!a || !b || a === b) return a || b;
984
+ return a + ' to ' + b;
985
+ }
986
+
987
+ /* Whether the memories behind all this can actually be found.
988
+ *
989
+ * The one question no screen answered before. A machine ran for months with
990
+ * 43.7% of its store unreachable by meaning while every status line it showed
991
+ * said healthy, because nothing counted the reachable share.
992
+ */
993
+ function _koverHealth(h) {
994
+ var lines = h.summary || [];
995
+ if (!lines.length) return '';
996
+ var bad = h.healthy === false;
997
+ return '<div style="margin:0 0 16px;padding:12px 14px;border-radius:var(--r-md);' +
998
+ 'background:var(--card-2);border-left:3px solid ' +
999
+ (bad ? 'var(--warn)' : 'var(--ok)') + '">' +
1000
+ lines.map(function (l) {
1001
+ return '<div style="font-size:12px;color:var(--fg-2);line-height:1.5">' +
1002
+ _esc(l) + '</div>';
1003
+ }).join('') +
1004
+ '</div>';
1005
+ }
1006
+
818
1007
  function _loadClusters(id) {
819
1008
  if (_st.cluLoaded) return;
820
1009
  var grid = document.getElementById(id + '-clu-grid');
@@ -1,4 +1,4 @@
1
- /* od-ops-health.js — Wave-3 Admin Operations & Health panel
1
+ /* od-ops-health.js — Admin Operations & Health panel
2
2
  * Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar — AGPL-3.0-or-later
3
3
  *
4
4
  * Injects an "Admin: Ops Health" tab into the Governance pane tab bar