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.
- package/.claude-plugin/marketplace.json +12 -2
- package/CHANGELOG.md +244 -0
- package/README.md +40 -75
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +357 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +241 -7
- package/src/superlocalmemory/storage/_migration_internals.py +54 -2
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +35 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- 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)
|
|
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
|
-
|
|
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
|
+
}
|