superlocalmemory 4.0.10 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +357 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -0,0 +1,201 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """One row per schema version, which is what every writer already assumed.
6
+
7
+ WHAT WAS WRONG
8
+
9
+ ``schema_version`` records which schema versions have been applied. Six call
10
+ sites write to it and every one of them uses ``INSERT OR IGNORE``, which reads
11
+ as "record this version unless it is already recorded".
12
+
13
+ There was no unique constraint on ``version``, and no index at all. With nothing
14
+ to conflict against, ``OR IGNORE`` never ignores anything, so each of those call
15
+ sites appended a duplicate on every run. Measured:
16
+
17
+ store rows distinct versions
18
+ live 3,496 7
19
+ larger 234,348 7
20
+
21
+ That is the same seven facts written a third of a million times, and it grows
22
+ every time the daemon starts or a maintenance cycle re-checks the schema.
23
+
24
+ WHY THE INDEX AND NOT A CLEANUP JOB
25
+
26
+ A retention rule would delete the duplicates and leave the cause, so the table
27
+ would refill. The unique index makes ``INSERT OR IGNORE`` do what all six call
28
+ sites already believe it does, which fixes the cause and makes the cleanup a
29
+ one-off.
30
+
31
+ WHAT IS KEPT
32
+
33
+ The earliest row for each version -- the one recording when that version was
34
+ actually first applied, which is the only one of the duplicates that carries
35
+ true information. Where a duplicate set disagrees on ``description`` the first
36
+ non-empty one wins, because later writers pass ``''``.
37
+
38
+ WHY THE TABLE IS REBUILT AND NOT ALTERED
39
+
40
+ SQLite cannot add a constraint to an existing table, and a UNIQUE INDEX cannot
41
+ be created over data that already violates it -- so the duplicates come out
42
+ first, in the same transaction that adds the index. If the two were separate,
43
+ a writer between them would insert a duplicate and the index creation would
44
+ fail on a store that had just been cleaned.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import logging
50
+ import sqlite3
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ NAME = "M049_a_schema_version_marker_is_one_row"
55
+ DB_TARGET = "memory"
56
+
57
+ #: Additive: an index and fewer duplicate rows. An older build reading this
58
+ #: table asks whether a version is present, which is unchanged. Writing to it
59
+ #: with OR IGNORE now succeeds silently instead of appending, which is what the
60
+ #: older build intended anyway.
61
+ BREAKING_VERSION = 0
62
+
63
+ _TABLE = "schema_version"
64
+ _INDEX = "idx_schema_version_unique"
65
+
66
+ DDL = """
67
+ -- Deduplicate schema_version, keeping the earliest row per version, then make
68
+ -- the column unique so INSERT OR IGNORE stops appending.
69
+ """
70
+
71
+
72
+ def _has_table(conn: sqlite3.Connection) -> bool:
73
+ row = conn.execute(
74
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (_TABLE,)
75
+ ).fetchone()
76
+ return row is not None
77
+
78
+
79
+ def apply(conn: sqlite3.Connection) -> None:
80
+ """Collapse the duplicates and add the constraint, in one transaction."""
81
+ if not _has_table(conn):
82
+ logger.info("M049: no %s table; nothing to do", _TABLE)
83
+ return
84
+
85
+ columns = {row[1] for row in conn.execute(f"PRAGMA table_info({_TABLE})")}
86
+ if "version" not in columns:
87
+ logger.info("M049: %s has no version column; nothing to do", _TABLE)
88
+ return
89
+ has_description = "description" in columns
90
+ has_applied_at = "applied_at" in columns
91
+
92
+ before = conn.execute(f"SELECT COUNT(*) FROM {_TABLE}").fetchone()[0]
93
+ distinct = conn.execute(
94
+ f"SELECT COUNT(DISTINCT version) FROM {_TABLE}"
95
+ ).fetchone()[0]
96
+
97
+ order_by = "applied_at ASC, rowid ASC" if has_applied_at else "rowid ASC"
98
+ # The row to keep for each version, chosen by when the version was recorded
99
+ # as landing. ``MIN(rowid)`` will not do: an ORDER BY inside a grouped
100
+ # subquery does not decide which row MIN() picks, so on a store where the
101
+ # duplicates were written out of order it kept a later stamp and deleted the
102
+ # original. Reproduced: rows dated 2026-01-01 and 2026-08-01 for one
103
+ # version, and the January one -- the true first application -- was the one
104
+ # that went.
105
+ # A window function, not a correlated subquery. Both are correct; only one
106
+ # finishes. The correlated form re-scans the whole table for every row, and
107
+ # on the store this migration exists for -- 234,348 rows over 7 versions --
108
+ # that is 7.8 billion row comparisons. Measured: it ran for more than
109
+ # twenty-five minutes at full CPU without completing, which on a first
110
+ # upgrade is indistinguishable from a hang. The window form ranks each
111
+ # partition once.
112
+ survivor = (
113
+ f"SELECT rowid FROM (SELECT rowid, ROW_NUMBER() OVER ("
114
+ f"PARTITION BY version ORDER BY {order_by}) AS rn FROM {_TABLE}"
115
+ f") WHERE rn = 1"
116
+ )
117
+ conn.execute("BEGIN IMMEDIATE")
118
+ try:
119
+ if has_description:
120
+ # A later duplicate may carry the only real description, and after
121
+ # the delete there is nothing left to read it from. So this runs
122
+ # FIRST, moving the earliest non-empty description onto the row that
123
+ # is about to survive. Running it afterwards -- as this once did --
124
+ # could only turn NULL into an empty string, which recovers nothing
125
+ # and reads in the log as though it had.
126
+ # One pass to find the best description per version, then one
127
+ # update joined against it. Correlated here for the same reason as
128
+ # above -- one scan per blank row -- would be just as slow.
129
+ conn.execute(f"""
130
+ CREATE TEMP TABLE IF NOT EXISTS _m049_best AS
131
+ SELECT version, description FROM (
132
+ SELECT version, description, ROW_NUMBER() OVER (
133
+ PARTITION BY version ORDER BY {order_by}
134
+ ) AS rn
135
+ FROM {_TABLE}
136
+ WHERE description IS NOT NULL AND TRIM(description) <> ''
137
+ ) WHERE rn = 1
138
+ """)
139
+ conn.execute(
140
+ f"UPDATE {_TABLE} SET description = COALESCE(("
141
+ f" SELECT description FROM _m049_best"
142
+ f" WHERE _m049_best.version = {_TABLE}.version"
143
+ f"), '') "
144
+ f"WHERE description IS NULL OR TRIM(description) = ''"
145
+ )
146
+ conn.execute("DROP TABLE IF EXISTS _m049_best")
147
+ conn.execute(
148
+ f"DELETE FROM {_TABLE} WHERE rowid NOT IN ({survivor})"
149
+ )
150
+ conn.execute(
151
+ f"CREATE UNIQUE INDEX IF NOT EXISTS {_INDEX} ON {_TABLE}(version)"
152
+ )
153
+ conn.commit()
154
+ except Exception:
155
+ conn.rollback()
156
+ raise
157
+
158
+ after = conn.execute(f"SELECT COUNT(*) FROM {_TABLE}").fetchone()[0]
159
+ logger.info(
160
+ "M049: %s went from %d row(s) to %d for %d distinct version(s)",
161
+ _TABLE, before, after, distinct,
162
+ )
163
+
164
+
165
+ def repair(conn: sqlite3.Connection) -> None:
166
+ """Re-run. Idempotent: the delete and the index are both conditional."""
167
+ apply(conn)
168
+
169
+
170
+ def verify(conn: sqlite3.Connection) -> bool:
171
+ """One row per version, and a constraint that keeps it that way.
172
+
173
+ Both halves matter. Row count alone would pass on a freshly deduplicated
174
+ store that is about to refill; the index alone would pass on a store where
175
+ the index exists but was created before the duplicates were removed, which
176
+ SQLite would not allow but a hand-edited store could reach.
177
+ """
178
+ if not _has_table(conn):
179
+ return True
180
+ columns = {row[1] for row in conn.execute(f"PRAGMA table_info({_TABLE})")}
181
+ if "version" not in columns:
182
+ return True
183
+
184
+ total, distinct = conn.execute(
185
+ f"SELECT COUNT(*), COUNT(DISTINCT version) FROM {_TABLE}"
186
+ ).fetchone()
187
+ if total != distinct:
188
+ logger.error(
189
+ "M049 verify: %s holds %d rows for %d versions", _TABLE, total, distinct
190
+ )
191
+ return False
192
+
193
+ for row in conn.execute(f"PRAGMA index_list({_TABLE})"):
194
+ name, unique = row[1], row[2]
195
+ if not unique:
196
+ continue
197
+ indexed = [c[2] for c in conn.execute(f"PRAGMA index_info({name})")]
198
+ if indexed == ["version"]:
199
+ return True
200
+ logger.error("M049 verify: %s has no unique index on version", _TABLE)
201
+ return False
@@ -38,9 +38,25 @@ def get_schema_version(conn: sqlite3.Connection) -> int:
38
38
 
39
39
 
40
40
  def set_schema_version(conn: sqlite3.Connection, version: int) -> None:
41
- """Record a schema version upgrade."""
41
+ """Record a schema version upgrade. Stamping the same version twice is a
42
+ no-op rather than an error.
43
+
44
+ A version is a marker, not an event log: the store either is at a version or
45
+ is not. This table used to have no constraint saying so, and the call sites
46
+ that stamp it all believed they were idempotent -- one store had 234,348
47
+ rows describing seven distinct versions. The constraint exists now, and this
48
+ keeps the first stamp, because that is the one that says when the version
49
+ actually landed.
50
+ """
51
+ # OR IGNORE rather than ON CONFLICT(version): the constraint is added by a
52
+ # migration, so a store that has not run it yet has no unique index for a
53
+ # conflict target to name, and naming one there raises. OR IGNORE is correct
54
+ # both before and after -- a no-op once the constraint exists, and the
55
+ # previous append-only behaviour until it does, which that migration then
56
+ # collapses.
42
57
  conn.execute(
43
- "INSERT INTO schema_version (version, applied_at, description) VALUES (?, ?, ?)",
58
+ "INSERT OR IGNORE INTO schema_version (version, applied_at, description) "
59
+ "VALUES (?, ?, ?)",
44
60
  (version, datetime.now(UTC).isoformat(), f"Migration to v{version}"),
45
61
  )
46
62
 
@@ -29,7 +29,33 @@ class FactType(str, Enum):
29
29
  EPISODIC = "episodic" # Events: who did what when
30
30
  SEMANTIC = "semantic" # World knowledge: X is Y
31
31
  OPINION = "opinion" # Subjective with confidence
32
- TEMPORAL = "temporal" # Time-bounded events with intervals
32
+ # Something the user intends to do later: a deadline, an appointment, a
33
+ # plan. Named "temporal" until 4.1.0, which collided with the retrieval
34
+ # channel of the same name — that one scores every fact by date proximity
35
+ # and has nothing to do with this. EdgeType.TEMPORAL and
36
+ # SignalType.TEMPORAL below are also unrelated and deliberately unchanged.
37
+ PROSPECTIVE = "prospective"
38
+
39
+ @classmethod
40
+ def _missing_(cls, value: object) -> "FactType | None":
41
+ """Read the old name for a planned event as the current one.
42
+
43
+ Renaming the type left every existing row spelled the old way until the
44
+ migration converts it, and a migration is allowed to have not run yet:
45
+ deferred migrations are applied after engine init and a failure there is
46
+ recorded, not raised. Without this, hydrating one of those rows raised
47
+ ``ValueError`` from the constructor and took the whole recall with it —
48
+ every channel, not just the fact. A store holding planned events lost
49
+ the ability to recall anything, which is precisely the population the
50
+ rename was for.
51
+
52
+ Reading the old spelling is therefore not a courtesy, it is what makes
53
+ the conversion safe to defer. Writing it is still impossible: a write
54
+ goes through the member, whose value is the new name.
55
+ """
56
+ if isinstance(value, str) and value.strip().lower() == "temporal":
57
+ return cls.PROSPECTIVE
58
+ return None
33
59
 
34
60
 
35
61
  class EdgeType(str, Enum):
@@ -441,6 +467,10 @@ class RecallResponse:
441
467
  score_contract_version: str = "2"
442
468
  calibration_status: str = "uncalibrated"
443
469
  calibration_id: str | None = None
470
+ #: Names this particular answer, so an outcome reported later can be tied
471
+ #: back to it exactly rather than guessed at from which memories overlap.
472
+ #: Empty means the answer was produced by a path that does not record one.
473
+ query_id: str = ""
444
474
  answer_confidence: float | None = None
445
475
  abstained: bool = False
446
476
  abstention_reason: str | None = None
@@ -459,3 +489,12 @@ class RecallResponse:
459
489
  # which two runs of one query are expected to agree. Sorted, so the field
460
490
  # is itself repeatable. Additive — backward compatible.
461
491
  incomplete_channels: tuple[str, ...] = ()
492
+ # What happened to each retrieval channel on this recall: ok / empty /
493
+ # error / timeout / disabled / not_configured / no_embedding. Absence and
494
+ # failure used to look identical from here — a channel that raises on every
495
+ # query and a channel that correctly found nothing both simply had no
496
+ # candidates, and the only trace was a log line. Keyed by channel name;
497
+ # ``retrieval.channel_status.CHANNEL_NAMES`` is the full set, so a missing
498
+ # key is a reporting gap and ``empty`` is an answer. Additive — backward
499
+ # compatible.
500
+ channel_status: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,346 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """The queue that makes a cross-engine projection write impossible to lose.
6
+
7
+ The graph lives in CozoDB (RocksDB underneath) and the vectors in LanceDB. Both
8
+ are separate storage engines from SQLite, so no transaction can span them and
9
+ there is no two-phase commit to reach for. The obvious sequence — commit the
10
+ fact, then write the projection — has a window in the middle. A process killed
11
+ inside that window leaves a memory that exists in SQLite, is absent from the
12
+ graph, and is therefore unrecallable, with nothing anywhere recording that it
13
+ happened.
14
+
15
+ This module is the transactional-outbox side of the answer. A row naming the
16
+ fact is written **inside the same SQLite transaction as the fact itself**, so
17
+ the intent to project is exactly as durable as the fact. A worker later claims
18
+ the row, applies it to Cozo and Lance, and only then deletes it. Killed at any
19
+ point, the row is still there and the work is retried.
20
+
21
+ WHAT THE ROW CARRIES, AND WHY IT IS SO THIN
22
+ -------------------------------------------
23
+ Only a fact id, a profile, and an intent. Not the entities, not the edges, not
24
+ the embedding. The drain re-reads the fact's current state from SQLite at the
25
+ moment it projects.
26
+
27
+ That choice buys idempotency for free. Replaying a row can only ever write the
28
+ present truth, so a retry after a crash, a double delivery, and an intent that
29
+ was superseded three times while queued all converge on the same correct
30
+ projection. Serialising a snapshot into the row instead would mean replaying
31
+ stale state over fresh state — the classic outbox bug.
32
+
33
+ WHY THE PRIMARY KEY IS THE FACT ID
34
+ ----------------------------------
35
+ It coalesces. Ten writes to one fact are one unit of work, because the drain
36
+ re-derives everything anyway. It also puts a hard ceiling on the table:
37
+ **at most one row per fact, so the queue can never exceed the store.** That is
38
+ what makes it safe to queue unconditionally on a machine where no projection
39
+ has been built yet — the backlog is bounded by the fact count and is precisely
40
+ the set of facts a future migration needs to project.
41
+
42
+ LATEST INTENT WINS
43
+ ------------------
44
+ ``op`` is replaced on conflict rather than appended, so a delete queued after an
45
+ upsert leaves one row saying ``delete``. A queue that kept both would depend on
46
+ draining them in order to avoid resurrecting a forgotten memory; this one cannot
47
+ get that wrong because there is only ever one intent to read.
48
+
49
+ ``revision`` IS A CONCURRENCY TOKEN, NOT A COUNTER
50
+ --------------------------------------------------
51
+ The drain claims a row, spends time in Cozo, then clears it. A write landing in
52
+ that gap must not be discarded. So every enqueue bumps ``revision``, and the
53
+ clear is conditional on the revision the drain claimed. If it moved, the delete
54
+ matches nothing and the new intent stays queued. A timestamp would not do:
55
+ two enqueues inside one clock tick would be indistinguishable.
56
+
57
+ FAILURE POLICY
58
+ --------------
59
+ ``enqueue`` is allowed to raise, and the caller's transaction then rolls back
60
+ the fact along with it. That is deliberate: this is a durability mechanism, and
61
+ a durability mechanism that silently degrades to best-effort is the defect it
62
+ exists to remove. The one expected absence — a store old enough not to have the
63
+ table — is answered by :func:`is_available` rather than by an exception, so an
64
+ un-migrated store keeps working and simply queues nothing.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import sqlite3
70
+ from datetime import datetime, timezone
71
+ from typing import Any
72
+
73
+ #: The table. Deliberately un-indexed beyond its primary key: the drain reads
74
+ #: it in (attempts, revision) order, and no secondary index serves that, while
75
+ #: every index costs another B-tree write on a path that runs on every store.
76
+ #: The table is bounded by the fact count and its steady-state depth is zero, so
77
+ #: a scan of it is a scan of nothing.
78
+ #:
79
+ #: Applied by ``schema.create_all_tables``, which the engine runs on every init
80
+ #: — so an upgraded store gains the queue on first open with no migration and no
81
+ #: manual step. There is deliberately NO migration for it: a migration can be
82
+ #: skipped, deferred or fail, and if this table is absent then facts silently
83
+ #: stop being projected. The invariant must not be contingent on a pass that can
84
+ #: not run.
85
+ DDL = """
86
+ CREATE TABLE IF NOT EXISTS projection_outbox (
87
+ fact_id TEXT PRIMARY KEY,
88
+ profile_id TEXT NOT NULL,
89
+ op TEXT NOT NULL DEFAULT 'upsert',
90
+ revision INTEGER NOT NULL DEFAULT 1,
91
+ enqueued_at TEXT NOT NULL,
92
+ attempts INTEGER NOT NULL DEFAULT 0,
93
+ last_error TEXT
94
+ );
95
+ """
96
+
97
+ TABLE = "projection_outbox"
98
+
99
+ #: The depth query, as a constant, because two surfaces need it against two
100
+ #: different kinds of connection and a second copy is how two surfaces come to
101
+ #: disagree about one number.
102
+ DEPTH_SQL = "SELECT COUNT(*) FROM projection_outbox"
103
+
104
+ OP_UPSERT = "upsert"
105
+ OP_DELETE = "delete"
106
+
107
+ #: The fact columns a projection is derived from. An update touching none of
108
+ #: these changes nothing Cozo or Lance holds, so it must not queue work.
109
+ #:
110
+ #: ``access_count`` is the one that matters here: recall bumps it on every hit,
111
+ #: so queueing on any update at all would put one row per returned memory per
112
+ #: recall onto the drain worker — a self-inflicted denial of service for a
113
+ #: column neither projection stores.
114
+ PROJECTED_FACT_COLUMNS = frozenset({
115
+ "embedding",
116
+ "lifecycle",
117
+ "canonical_entities_json",
118
+ "canonical_entities",
119
+ "profile_id",
120
+ "scope",
121
+ })
122
+
123
+ _AVAILABILITY_ATTR = "_projection_outbox_available"
124
+
125
+
126
+ def _now() -> str:
127
+ return datetime.now(timezone.utc).isoformat()
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Availability
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def is_available(db: Any) -> bool:
135
+ """Whether this store has an outbox to queue into.
136
+
137
+ Cached on the manager after the first look. The table is created at engine
138
+ init before any fact can be stored, so it cannot appear or vanish part-way
139
+ through a process in a way that matters; paying a ``sqlite_master`` lookup
140
+ on every single write to prove that again would be the more expensive
141
+ mistake.
142
+ """
143
+ cached = getattr(db, _AVAILABILITY_ATTR, None)
144
+ if cached is not None:
145
+ return bool(cached)
146
+ try:
147
+ rows = db.execute(
148
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,),
149
+ )
150
+ present = bool(rows)
151
+ except sqlite3.Error:
152
+ present = False
153
+ setattr(db, _AVAILABILITY_ATTR, present)
154
+ return present
155
+
156
+
157
+ def forget_availability(db: Any) -> None:
158
+ """Drop the cached answer, so the next call looks again.
159
+
160
+ For the migration that creates the table on a manager already in use, and
161
+ for tests that remove it to exercise the un-migrated path.
162
+ """
163
+ if hasattr(db, _AVAILABILITY_ATTR):
164
+ delattr(db, _AVAILABILITY_ATTR)
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Enqueue — runs inside the caller's transaction
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def enqueue(db: Any, fact_id: str, profile_id: str, op: str = OP_UPSERT) -> None:
172
+ """Queue one fact to be projected, in the caller's transaction.
173
+
174
+ Silent no-op when there is no table (see :func:`is_available`) or when the
175
+ fact id is empty — an empty id names nothing and would give the drain a row
176
+ it can never resolve.
177
+ """
178
+ if not fact_id or not is_available(db):
179
+ return
180
+ db.execute(
181
+ """INSERT INTO projection_outbox
182
+ (fact_id, profile_id, op, revision, enqueued_at, attempts, last_error)
183
+ VALUES (?, ?, ?, 1, ?, 0, NULL)
184
+ ON CONFLICT(fact_id) DO UPDATE SET
185
+ op = excluded.op,
186
+ profile_id = excluded.profile_id,
187
+ revision = projection_outbox.revision + 1,
188
+ enqueued_at = excluded.enqueued_at,
189
+ attempts = 0,
190
+ last_error = NULL""",
191
+ (fact_id, profile_id or "default", op, _now()),
192
+ )
193
+
194
+
195
+ def enqueue_many(
196
+ db: Any, fact_ids: object, profile_id: str, op: str = OP_UPSERT,
197
+ ) -> None:
198
+ """Queue several facts. Duplicates in the input collapse to one row each."""
199
+ if not is_available(db):
200
+ return
201
+ for fact_id in dict.fromkeys(fact_ids):
202
+ enqueue(db, fact_id, profile_id, op)
203
+
204
+
205
+ def resolve_profile(db: Any, fact_id: str) -> str | None:
206
+ """Which tenant a fact belongs to, for a caller that was not told.
207
+
208
+ The drain never reads ``profile_id`` off the row — it re-reads the fact. The
209
+ column exists for one reason: erasure finds a tenant's tables by looking for
210
+ a ``profile_id`` column, so a row carrying the wrong tenant is a fact id
211
+ that survives that tenant's erasure. Anyone tempted to drop the column as
212
+ unused should read that sentence twice.
213
+
214
+ Falls back to an existing row's tenant so a re-queue by a caller with less
215
+ information cannot overwrite a correct answer with a guess.
216
+ """
217
+ for sql in (
218
+ "SELECT profile_id FROM atomic_facts WHERE fact_id = ?",
219
+ "SELECT profile_id FROM projection_outbox WHERE fact_id = ?",
220
+ ):
221
+ try:
222
+ rows = db.execute(sql, (fact_id,))
223
+ except sqlite3.Error:
224
+ continue
225
+ if rows:
226
+ found = dict(rows[0]).get("profile_id")
227
+ if found:
228
+ return str(found)
229
+ return None
230
+
231
+
232
+ def enqueue_for_fact(db: Any, fact_id: str, op: str = OP_UPSERT) -> None:
233
+ """Queue a fact whose tenant the caller does not know."""
234
+ if not fact_id or not is_available(db):
235
+ return
236
+ enqueue(db, fact_id, resolve_profile(db, fact_id) or "default", op)
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Drain
241
+ # ---------------------------------------------------------------------------
242
+
243
+ def claim_batch(db: Any, limit: int = 200) -> list[dict[str, Any]]:
244
+ """The next rows to project, oldest intent first.
245
+
246
+ Claiming does not mark or lock anything. The row stays visible and stays
247
+ queued until :func:`resolve` clears it, so a worker that dies mid-batch
248
+ costs a repeat of work that is idempotent by construction — which is
249
+ cheaper and far easier to reason about than a lease that can expire while
250
+ its holder is still alive.
251
+ """
252
+ if not is_available(db):
253
+ return []
254
+ rows = db.execute(
255
+ "SELECT fact_id, profile_id, op, revision, attempts, enqueued_at "
256
+ "FROM projection_outbox ORDER BY attempts, revision LIMIT ?",
257
+ (int(limit),),
258
+ )
259
+ return [dict(r) for r in rows]
260
+
261
+
262
+ def resolve(db: Any, fact_id: str, revision: int) -> bool:
263
+ """Clear a row the projections have accepted. Returns whether it cleared.
264
+
265
+ ``False`` means the fact was written again while this projection was in
266
+ flight, so a newer intent is queued and must not be discarded. The caller
267
+ does not need to do anything about it — the next drain picks it up.
268
+ """
269
+ if not is_available(db):
270
+ return False
271
+ db.execute(
272
+ "DELETE FROM projection_outbox WHERE fact_id = ? AND revision = ?",
273
+ (fact_id, int(revision)),
274
+ )
275
+ remaining = db.execute(
276
+ "SELECT 1 FROM projection_outbox WHERE fact_id = ? AND revision = ?",
277
+ (fact_id, int(revision)),
278
+ )
279
+ return not remaining
280
+
281
+
282
+ def record_failure(db: Any, fact_id: str, error: str) -> int:
283
+ """Count a failed attempt and keep the row. Returns the new attempt count.
284
+
285
+ The row surviving is the whole point: a projection that cannot be written
286
+ stays visible in :func:`depth` instead of disappearing into a debug log.
287
+ """
288
+ if not is_available(db):
289
+ return 0
290
+ db.execute(
291
+ "UPDATE projection_outbox SET attempts = attempts + 1, last_error = ? "
292
+ "WHERE fact_id = ?",
293
+ (str(error)[:500], fact_id),
294
+ )
295
+ rows = db.execute(
296
+ "SELECT attempts FROM projection_outbox WHERE fact_id = ?", (fact_id,),
297
+ )
298
+ return int(dict(rows[0])["attempts"]) if rows else 0
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Health
303
+ # ---------------------------------------------------------------------------
304
+
305
+ def depth(db: Any) -> int:
306
+ """How many facts are queued and not yet projected.
307
+
308
+ Zero is the healthy steady state. A number that does not fall is a
309
+ projection that has stopped keeping up, which is the failure this whole
310
+ mechanism exists to make visible rather than silent.
311
+ """
312
+ if not is_available(db):
313
+ return 0
314
+ try:
315
+ rows = db.execute(f"{DEPTH_SQL} /* depth */")
316
+ except sqlite3.Error:
317
+ return 0
318
+ return int(tuple(rows[0])[0]) if rows else 0
319
+
320
+
321
+ def stalled_count(db: Any, min_attempts: int = 3) -> int:
322
+ """Queued facts that have already failed to project ``min_attempts`` times.
323
+
324
+ Depth alone cannot tell a busy queue from a stuck one. This can: a row with
325
+ attempts on it has been tried and refused, so any non-zero answer here is a
326
+ defect with a fact id attached to it.
327
+ """
328
+ if not is_available(db):
329
+ return 0
330
+ try:
331
+ rows = db.execute(
332
+ "SELECT COUNT(*) AS n FROM projection_outbox WHERE attempts >= ?",
333
+ (int(min_attempts),),
334
+ )
335
+ except sqlite3.Error:
336
+ return 0
337
+ return int(dict(rows[0])["n"]) if rows else 0
338
+
339
+
340
+ def health(db: Any) -> dict[str, Any]:
341
+ """The outbox as a status surface reads it."""
342
+ return {
343
+ "available": is_available(db),
344
+ "depth": depth(db),
345
+ "stalled": stalled_count(db),
346
+ }