superlocalmemory 4.0.9 → 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 (64) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +3 -3
  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/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/cli/commands.py +45 -2
  27. package/src/superlocalmemory/cli/main.py +2 -2
  28. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  29. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  30. package/src/superlocalmemory/core/maintenance.py +44 -6
  31. package/src/superlocalmemory/core/memory_health.py +266 -0
  32. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  33. package/src/superlocalmemory/core/operation_request.py +1 -1
  34. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  35. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  36. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  37. package/src/superlocalmemory/mcp/server.py +1 -1
  38. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  39. package/src/superlocalmemory/mcp/tools_core.py +40 -39
  40. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  41. package/src/superlocalmemory/retrieval/bm25_channel.py +4 -8
  42. package/src/superlocalmemory/retrieval/entity_channel.py +7 -1
  43. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  44. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  45. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  46. package/src/superlocalmemory/server/api.py +6 -1
  47. package/src/superlocalmemory/server/asset_versions.py +171 -0
  48. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  49. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  50. package/src/superlocalmemory/server/routes/entity.py +13 -1
  51. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  52. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  53. package/src/superlocalmemory/server/ui.py +8 -1
  54. package/src/superlocalmemory/server/unified_daemon.py +111 -9
  55. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  56. package/src/superlocalmemory/storage/database.py +128 -30
  57. package/src/superlocalmemory/storage/migration_runner.py +11 -0
  58. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  59. package/src/superlocalmemory/storage/schema.py +98 -0
  60. package/src/superlocalmemory/summaries/base.py +1 -1
  61. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  62. package/src/superlocalmemory/ui/index.html +1 -1
  63. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  64. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -686,8 +686,12 @@ class DatabaseManager:
686
686
  include_shared=include_shared,
687
687
  prefix="f",
688
688
  )
689
+ # Pins are injected straight into an agent's context, which makes this
690
+ # the most consequential display path in the class: a withheld row here
691
+ # is not merely shown, it is asserted as background truth.
689
692
  rows = self.execute(
690
693
  f"SELECT f.* FROM atomic_facts f WHERE {where} AND f.pinned = 1 "
694
+ f"{self.visible_fact_clause('f')} "
691
695
  "AND NOT EXISTS ("
692
696
  " SELECT 1 FROM fact_temporal_validity tv "
693
697
  " WHERE tv.fact_id = f.fact_id "
@@ -733,6 +737,65 @@ class DatabaseManager:
733
737
  self._archive_col_present = True
734
738
  return present
735
739
 
740
+ def _has_quarantine_column(self) -> bool:
741
+ """Whether atomic_facts carries the ``quarantined`` column.
742
+
743
+ Same shape as ``_has_archive_status``: cached once True (a column never
744
+ disappears), re-checked while absent so a later schema pass is picked
745
+ up. ``storage.schema.create_all_tables`` adds the column at every engine
746
+ init, so on any store the daemon has opened this is True — the guard
747
+ exists for a bare DatabaseManager pointed at a store that engine init
748
+ never touched, where filtering on the column would raise instead of
749
+ returning results.
750
+ """
751
+ if getattr(self, "_quarantine_col_present", False):
752
+ return True
753
+ present = any(
754
+ dict(row).get("name") == "quarantined"
755
+ for row in self.execute("PRAGMA table_info(atomic_facts)")
756
+ )
757
+ if present:
758
+ self._quarantine_col_present = True
759
+ return present
760
+
761
+ def visible_fact_clause(
762
+ self, prefix: str = "", *, include_quarantined: bool = False,
763
+ ) -> str:
764
+ """AND-clause excluding rows no caller should be shown as a memory.
765
+
766
+ Two exclusions, one definition: soft-deleted (``archive_status``) and
767
+ withheld (``quarantined``). Both are presence-guarded, because each
768
+ column arrives with a migration and may be absent on a store the engine
769
+ has not opened.
770
+
771
+ WHY THIS EXISTS AS A FUNCTION. 4.0.10 first put the quarantine filter in
772
+ ``get_facts_by_ids`` alone, reasoning that every retrieval channel
773
+ re-authorises through it and the engine drops what it cannot hydrate.
774
+ That reasoning was correct and the conclusion was wrong: it covered the
775
+ RECALL pipeline, and ``search``, ``list_recent``, ``fetch``, the MCP
776
+ resources and the dashboard's own search are not the recall pipeline.
777
+ Measured on a copy of the author's store, ``search_facts_fts`` returned
778
+ 20 withheld rows out of 50 and ``get_all_facts`` 66 out of 400 — the
779
+ exact defect the design was meant to prevent, in the paths the design
780
+ never looked at.
781
+
782
+ There is no single SQL choke point in this codebase; ``_scope_where`` is
783
+ spliced against six other tables and cannot carry a fact column. So the
784
+ honest form of "one place" is one CLAUSE with an enumerable set of call
785
+ sites, and a test that fails when a read path does not use it:
786
+ tests/test_storage/test_no_read_path_shows_a_withheld_row.py
787
+
788
+ ``include_quarantined=True`` is for repair, erasure and export — paths
789
+ that must reach a withheld row to act on it.
790
+ """
791
+ table = f"{prefix}." if prefix else ""
792
+ clause = ""
793
+ if self._has_archive_status():
794
+ clause += f" AND COALESCE({table}archive_status, 'live') != 'archived'"
795
+ if not include_quarantined and self._has_quarantine_column():
796
+ clause += f" AND COALESCE({table}quarantined, 0) = 0"
797
+ return clause
798
+
736
799
  def get_all_facts(
737
800
  self, profile_id: str, limit: int | None = None,
738
801
  *,
@@ -755,14 +818,10 @@ class DatabaseManager:
755
818
  # hard, env-tunable ceiling even when the caller passes limit=None.
756
819
  if limit is None:
757
820
  limit = _unbounded_facts_ceiling()
758
- # Archived facts are not live; never surface them in direct reads.
759
- archive_clause = (
760
- " AND COALESCE(archive_status, 'live') != 'archived'"
761
- if self._has_archive_status()
762
- else ""
763
- )
821
+ # Soft-deleted and withheld rows are not memories a caller may see.
764
822
  rows = self.execute(
765
- f"SELECT * FROM atomic_facts WHERE {where}{archive_clause} "
823
+ f"SELECT * FROM atomic_facts WHERE {where}"
824
+ f"{self.visible_fact_clause()} "
766
825
  "ORDER BY created_at DESC LIMIT ?",
767
826
  (*params, int(limit)),
768
827
  )
@@ -790,14 +849,12 @@ class DatabaseManager:
790
849
  include_global=include_global,
791
850
  include_shared=include_shared,
792
851
  )
793
- archive_clause = (
794
- " AND COALESCE(archive_status, 'live') != 'archived'"
795
- if self._has_archive_status()
796
- else ""
797
- )
852
+ # Crossing a profile boundary is the last place a withheld row should
853
+ # appear: it would be a model's non-answer presented to somebody else
854
+ # as one of this profile's shared memories.
798
855
  rows = self.execute(
799
856
  f"SELECT * FROM atomic_facts WHERE {where} AND profile_id != ?"
800
- f"{archive_clause} ORDER BY created_at DESC",
857
+ f"{self.visible_fact_clause()} ORDER BY created_at DESC",
801
858
  (*params, profile_id),
802
859
  )
803
860
  return [self._row_to_fact(r) for r in rows]
@@ -953,14 +1010,21 @@ class DatabaseManager:
953
1010
  include_global: bool = False,
954
1011
  include_shared: bool = False,
955
1012
  ) -> int:
956
- """Total fact count for a profile."""
1013
+ """Memories this profile has, as the owner would count them.
1014
+
1015
+ Counts what a caller can be shown, which is why it applies
1016
+ ``visible_fact_clause``. It fed the dashboard's "All memories 5,093" and
1017
+ was counting 1,195 withheld summaries and every soft-deleted row into
1018
+ that figure -- a number the owner reads as "how much do I remember".
1019
+ """
957
1020
  where, params = _scope_where(
958
1021
  profile_id,
959
1022
  include_global=include_global,
960
1023
  include_shared=include_shared,
961
1024
  )
962
1025
  rows = self.execute(
963
- f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}", (*params,),
1026
+ f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}"
1027
+ f"{self.visible_fact_clause()}", (*params,),
964
1028
  )
965
1029
  return int(rows[0]["c"]) if rows else 0
966
1030
 
@@ -1230,16 +1294,15 @@ class DatabaseManager:
1230
1294
  include_shared=include_shared,
1231
1295
  prefix="f",
1232
1296
  )
1233
- # Archived facts must not surface via full-text search either.
1234
- archive_clause = (
1235
- " AND COALESCE(f.archive_status, 'live') != 'archived'"
1236
- if self._has_archive_status()
1237
- else ""
1238
- )
1297
+ # Full-text search is a display path: the dashboard search box, the
1298
+ # `search` tool and `fetch` all land here, and none of them go through
1299
+ # the recall engine. Before 4.0.10 put the clause here it returned 20
1300
+ # withheld rows out of 50 on the author's store.
1239
1301
  rows = self.execute(
1240
1302
  f"""SELECT f.* FROM atomic_facts_fts AS fts
1241
1303
  JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
1242
- WHERE fts.atomic_facts_fts MATCH ? AND {where}{archive_clause}
1304
+ WHERE fts.atomic_facts_fts MATCH ? AND {where}
1305
+ {self.visible_fact_clause('f')}
1243
1306
  ORDER BY fts.rank LIMIT ?""",
1244
1307
  (match_expr, *params, limit),
1245
1308
  )
@@ -1270,12 +1333,22 @@ class DatabaseManager:
1270
1333
  # ------------------------------------------------------------------
1271
1334
 
1272
1335
  def get_fact(self, fact_id: str, profile_id: str | None = None) -> AtomicFact | None:
1273
- """Get a single fact by ID.
1336
+ """Get a single row by ID, exactly as stored. NOT a display path.
1274
1337
 
1275
1338
  C4 defense-in-depth: when ``profile_id`` is provided the lookup is
1276
1339
  tenant-scoped so a fact_id from another profile cannot resolve. Left
1277
1340
  optional (fact_id is a random UUID sourced from already-scoped queries)
1278
1341
  to avoid destabilizing the core store/consolidation write path.
1342
+
1343
+ DELIBERATELY UNFILTERED, and this is load-bearing. It applies neither
1344
+ ``archive_status`` nor ``quarantined`` because it is the primitive that
1345
+ write paths, correction handling and the 4.0.10 repair use to read a row
1346
+ they already hold the id of — including a withheld one, which they must
1347
+ be able to see in order to act on it. ``visible_fact_clause`` is for the
1348
+ paths that answer a question; this one answers "what is in that row".
1349
+
1350
+ A caller taking a fact_id from user input and rendering the result wants
1351
+ ``get_facts_by_ids`` instead.
1279
1352
  """
1280
1353
  if profile_id is not None:
1281
1354
  rows = self.execute(
@@ -1292,8 +1365,36 @@ class DatabaseManager:
1292
1365
  self, fact_ids: list[str], profile_id: str,
1293
1366
  include_global: bool = False,
1294
1367
  include_shared: bool = False,
1368
+ *,
1369
+ include_quarantined: bool = False,
1295
1370
  ) -> list[AtomicFact]:
1296
- """Get multiple facts by their IDs, scoped to a profile."""
1371
+ """Get multiple facts by their IDs, scoped to a profile.
1372
+
1373
+ THIS IS THE PLACE QUARANTINE IS ENFORCED, and the only one.
1374
+
1375
+ Every retrieval channel re-authorises its candidates through here
1376
+ (``retrieval/scope_policy.py`` — "candidate generators may use caches,
1377
+ approximate indexes, or graph stores that are not the authorization
1378
+ source of truth"), and the engine hydrates the fused set from here too.
1379
+ A fact this method does not return has no content to show, and
1380
+ ``retrieval/engine.py`` drops it: ``if fact is None: continue``. So one
1381
+ clause here covers bm25, semantic, temporal, entity, hopfield and
1382
+ spreading activation, in normal and deep recall alike, whether or not
1383
+ the forgetting filter is registered.
1384
+
1385
+ The alternatives were checked and rejected. ``_scope_where`` looks like
1386
+ the natural home but is spliced against ``graph_edges``,
1387
+ ``temporal_events``, ``memories``, ``bm25_tokens``,
1388
+ ``fact_temporal_validity`` and ``correction_cases`` as well as
1389
+ ``atomic_facts``, so a column reference there breaks eight call sites.
1390
+ ``ForgettingFilter`` is optional (it no-ops when forgetting is
1391
+ disabled) and excludes nothing in deep recall.
1392
+
1393
+ ``include_quarantined=True`` is for repair, export and erasure — paths
1394
+ that must be able to see a withheld row in order to act on it. It is
1395
+ keyword-only and greppable on purpose: every caller that opts in is
1396
+ meant to be found in one search.
1397
+ """
1297
1398
  if not fact_ids:
1298
1399
  return []
1299
1400
  where, params = _scope_where(
@@ -1301,15 +1402,12 @@ class DatabaseManager:
1301
1402
  include_global=include_global,
1302
1403
  include_shared=include_shared,
1303
1404
  )
1304
- archive_clause = (
1305
- " AND COALESCE(archive_status, 'live') != 'archived'"
1306
- if self._has_archive_status()
1307
- else ""
1308
- )
1309
1405
  placeholders = ",".join("?" for _ in fact_ids)
1310
1406
  rows = self.execute(
1311
1407
  f"SELECT * FROM atomic_facts WHERE fact_id IN ({placeholders}) "
1312
- f"AND {where}{archive_clause} ORDER BY created_at DESC",
1408
+ f"AND {where}"
1409
+ f"{self.visible_fact_clause(include_quarantined=include_quarantined)} "
1410
+ "ORDER BY created_at DESC",
1313
1411
  (*fact_ids, *params),
1314
1412
  )
1315
1413
  return [self._row_to_fact(r) for r in rows]
@@ -159,6 +159,9 @@ from superlocalmemory.storage.migrations import (
159
159
  from superlocalmemory.storage.migrations import (
160
160
  M042_correction_case_ledger as _M042,
161
161
  )
162
+ from superlocalmemory.storage.migrations import (
163
+ M043_quarantine_display_summaries as _M043,
164
+ )
162
165
  from superlocalmemory.storage._schema_version import (
163
166
  SUPPORTED_SCHEMA_VERSION,
164
167
  SchemaVersionError,
@@ -312,6 +315,14 @@ DEFERRED_MIGRATIONS: list[Migration] = [
312
315
  # Main-line M034 is renumbered in V4. It must remain deferred because its
313
316
  # backfill joins engine-bootstrapped memory_scenes and atomic_facts.
314
317
  Migration(name=_M039.NAME, db_target="memory", ddl=_M039.DDL),
318
+ # M043 withholds model-written summaries from the retrieval corpus and
319
+ # un-hides the memories they displaced. Deferred because it reads and
320
+ # writes atomic_facts + fact_retention, both bootstrapped at engine init —
321
+ # the same reason M011/M013/M015/M016 are deferred. apply_deferred takes a
322
+ # verified snapshot before the first migration it actually applies, so the
323
+ # store is recoverable.
324
+ Migration(name=_M043.NAME, db_target="memory", ddl=_M043.DDL,
325
+ dependencies=(_M011.NAME,)),
315
326
  ]
316
327
 
317
328