superlocalmemory 4.0.9 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/package.json +4 -2
- 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 +308 -20
- 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 +26 -4
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- 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/fact_consolidator.py +316 -125
- 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.py +44 -6
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- 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/store_pipeline.py +78 -3
- 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 +51 -7
- 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/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +71 -42
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- 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 +68 -11
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +26 -2
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -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/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- 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/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +193 -17
- package/src/superlocalmemory/server/ui.py +24 -1
- package/src/superlocalmemory/server/unified_daemon.py +292 -9
- package/src/superlocalmemory/storage/_migration_internals.py +35 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +598 -82
- 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 +130 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- 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 +110 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +10 -19
- 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 +209 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -22,7 +22,7 @@ import time
|
|
|
22
22
|
from contextlib import contextmanager
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from types import ModuleType
|
|
25
|
-
from typing import Any, Generator
|
|
25
|
+
from typing import Any, Generator, NoReturn
|
|
26
26
|
|
|
27
27
|
from superlocalmemory.storage.models import (
|
|
28
28
|
AtomicFact,
|
|
@@ -40,13 +40,33 @@ from superlocalmemory.storage.models import (
|
|
|
40
40
|
TemporalEvent,
|
|
41
41
|
TrustScore,
|
|
42
42
|
)
|
|
43
|
-
from superlocalmemory.storage.embedding_codec import
|
|
43
|
+
from superlocalmemory.storage.embedding_codec import (
|
|
44
|
+
decode_embedding,
|
|
45
|
+
decode_float_vector,
|
|
46
|
+
encode_embedding,
|
|
47
|
+
encode_float_vector,
|
|
48
|
+
)
|
|
44
49
|
from superlocalmemory.storage.write_lock import get_write_lock
|
|
50
|
+
from superlocalmemory.storage import projection_outbox
|
|
45
51
|
|
|
46
52
|
logger = logging.getLogger(__name__)
|
|
47
53
|
|
|
48
54
|
_MISSING = object()
|
|
49
55
|
|
|
56
|
+
|
|
57
|
+
class ProfileOwnershipConflict(ValueError):
|
|
58
|
+
"""A store tried to hand one profile's existing row to another profile.
|
|
59
|
+
|
|
60
|
+
``memories.memory_id`` and ``atomic_facts.fact_id`` are bare
|
|
61
|
+
``TEXT PRIMARY KEY`` — global, not per-profile — while every row carries a
|
|
62
|
+
``profile_id``. So two profiles on one store can be handed the same
|
|
63
|
+
caller-chosen id (an importer keyed on an external record id syncing one
|
|
64
|
+
source into two workspaces does exactly this), and the upsert would
|
|
65
|
+
otherwise rewrite the owner and the content: the first profile's memory
|
|
66
|
+
silently became the second's.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
50
70
|
def _jl(raw: Any, default: Any = _MISSING) -> Any:
|
|
51
71
|
"""JSON-load a value, returning *default* on None/empty.
|
|
52
72
|
|
|
@@ -146,6 +166,92 @@ def _scope_where(
|
|
|
146
166
|
return where, params
|
|
147
167
|
|
|
148
168
|
|
|
169
|
+
def _compose_visible_clause(
|
|
170
|
+
prefix: str,
|
|
171
|
+
*,
|
|
172
|
+
has_archive: bool,
|
|
173
|
+
has_quarantine: bool,
|
|
174
|
+
include_quarantined: bool = False,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Build the AND-clause from what the store actually has.
|
|
177
|
+
|
|
178
|
+
Split out from ``DatabaseManager.visible_fact_clause`` so that callers
|
|
179
|
+
holding a bare ``sqlite3.Connection`` produce a byte-identical predicate
|
|
180
|
+
rather than a second, drifting copy.
|
|
181
|
+
"""
|
|
182
|
+
table = f"{prefix}." if prefix else ""
|
|
183
|
+
clause = ""
|
|
184
|
+
if has_archive:
|
|
185
|
+
clause += f" AND COALESCE({table}archive_status, 'live') != 'archived'"
|
|
186
|
+
if not include_quarantined and has_quarantine:
|
|
187
|
+
clause += f" AND COALESCE({table}quarantined, 0) = 0"
|
|
188
|
+
return clause
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def visible_fact_clause_for_connection(
|
|
192
|
+
conn: sqlite3.Connection,
|
|
193
|
+
prefix: str = "",
|
|
194
|
+
*,
|
|
195
|
+
include_quarantined: bool = False,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""``visible_fact_clause`` for a caller that hand-rolls its SQL.
|
|
198
|
+
|
|
199
|
+
WHY THIS EXISTS. 4.0.10 put the withheld-row filter on every
|
|
200
|
+
``DatabaseManager`` read path and every retrieval channel, and enumerated
|
|
201
|
+
them in a test. The enumeration was of *methods*, and two HTTP routes never
|
|
202
|
+
call one: ``/api/memories`` and ``/api/v3/timeline/`` build their own SQL
|
|
203
|
+
against ``atomic_facts``. Measured against the author's store on 4.0.10,
|
|
204
|
+
``/api/memories?limit=50`` served **24 withheld rows on page one** and
|
|
205
|
+
reported a total of 5,218 against 3,919 real memories -- the release's
|
|
206
|
+
central claim, false on the dashboard's main list.
|
|
207
|
+
|
|
208
|
+
So the rule is not "read paths go through DatabaseManager"; plenty of
|
|
209
|
+
reasonable code will not. The rule is that anything answering "what does my
|
|
210
|
+
memory contain" resolves its predicate from here. Presence-guarded on the
|
|
211
|
+
passed connection, because a route may be pointed at a store the engine has
|
|
212
|
+
never opened, and filtering on an absent column would turn a cosmetic gap
|
|
213
|
+
into a 500 on every request.
|
|
214
|
+
|
|
215
|
+
Grep is not a sufficient test for this: ``mcp/tools_active.py`` also selects
|
|
216
|
+
from ``atomic_facts`` directly and was already clean, while these two routes
|
|
217
|
+
were not. The test that matters calls the surface and inspects what it
|
|
218
|
+
served -- tests/test_server/test_a_listed_memory_belongs_to_its_profile.py
|
|
219
|
+
"""
|
|
220
|
+
def _column_name(row: object) -> str:
|
|
221
|
+
"""PRAGMA row -> column name, whatever row_factory the caller set.
|
|
222
|
+
|
|
223
|
+
Not paranoia: ``server/routes/memories.py`` -- the first caller and the
|
|
224
|
+
route that was leaking -- sets ``row_factory = dict_factory``, so a
|
|
225
|
+
positional ``row[1]`` raises KeyError there, and the except below would
|
|
226
|
+
have swallowed it into "no such column" and silently dropped the filter.
|
|
227
|
+
That is the same shape of failure this function exists to close.
|
|
228
|
+
"""
|
|
229
|
+
if isinstance(row, dict):
|
|
230
|
+
return str(row.get("name", ""))
|
|
231
|
+
try:
|
|
232
|
+
return str(row["name"]) # sqlite3.Row
|
|
233
|
+
except (TypeError, IndexError, KeyError):
|
|
234
|
+
pass
|
|
235
|
+
try:
|
|
236
|
+
return str(row[1]) # plain tuple
|
|
237
|
+
except (TypeError, IndexError, KeyError):
|
|
238
|
+
return ""
|
|
239
|
+
|
|
240
|
+
def _has(column: str) -> bool:
|
|
241
|
+
try:
|
|
242
|
+
rows = list(conn.execute("PRAGMA table_info(atomic_facts)"))
|
|
243
|
+
except sqlite3.Error:
|
|
244
|
+
return False
|
|
245
|
+
return any(_column_name(r) == column for r in rows)
|
|
246
|
+
|
|
247
|
+
return _compose_visible_clause(
|
|
248
|
+
prefix,
|
|
249
|
+
has_archive=_has("archive_status"),
|
|
250
|
+
has_quarantine=_has("quarantined"),
|
|
251
|
+
include_quarantined=include_quarantined,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
149
255
|
class DatabaseManager:
|
|
150
256
|
"""Concurrent-safe SQLite manager with WAL, profile isolation, and FTS5.
|
|
151
257
|
|
|
@@ -442,21 +548,101 @@ class DatabaseManager:
|
|
|
442
548
|
# Read-only path: concurrent reads are safe in WAL mode.
|
|
443
549
|
return self._execute_one(sql, params)
|
|
444
550
|
|
|
551
|
+
# The two tables whose primary key is global but whose rows are owned by a
|
|
552
|
+
# profile. Literal, internal, and closed — never built from caller input.
|
|
553
|
+
_OWNED_ROWS: dict[str, str] = {"memories": "memory_id", "atomic_facts": "fact_id"}
|
|
554
|
+
|
|
555
|
+
def _refuse_cross_profile_reown(
|
|
556
|
+
self, table: str, row_id: str, profile_id: str,
|
|
557
|
+
) -> NoReturn:
|
|
558
|
+
"""Raise ``ProfileOwnershipConflict``, naming the profile that owns it.
|
|
559
|
+
|
|
560
|
+
Called only when an upsert below returned no row. Both statements carry
|
|
561
|
+
``WHERE <table>.profile_id = excluded.profile_id`` on their
|
|
562
|
+
``DO UPDATE``, so SQLite skips a conflicting row owned by a different
|
|
563
|
+
profile and ``RETURNING`` yields nothing — which makes the ownership
|
|
564
|
+
check part of the write instead of a read in front of it. That costs no
|
|
565
|
+
extra query on the ordinary path and leaves no window between deciding
|
|
566
|
+
and writing. The lookup here runs only on the refusal, to say whose row
|
|
567
|
+
it is; a refusal nobody can read gets worked around.
|
|
568
|
+
|
|
569
|
+
Taking a row away from the profile that owns it is not last-write-wins,
|
|
570
|
+
it is a different tenant's write, so it is refused rather than merged.
|
|
571
|
+
Before this the outcome depended on something unrelated:
|
|
572
|
+
``scene_fact_members`` carries a composite
|
|
573
|
+
``(profile_id, fact_id) -> atomic_facts (profile_id, fact_id)`` foreign
|
|
574
|
+
key, so where a scene referenced the fact the ownership change orphaned
|
|
575
|
+
it and SQLite raised a bare ``FOREIGN KEY constraint failed`` — and
|
|
576
|
+
where no scene did, the identical write succeeded in silence and the
|
|
577
|
+
second profile kept the fact. Isolation cannot rest on whether a
|
|
578
|
+
projection happens to exist.
|
|
579
|
+
"""
|
|
580
|
+
# Interpolated, not parameterised: SQLite takes no parameter in a table
|
|
581
|
+
# or column position. Both come from _OWNED_ROWS, a closed literal map,
|
|
582
|
+
# and the row id stays bound.
|
|
583
|
+
id_column = self._OWNED_ROWS[table]
|
|
584
|
+
rows = self.execute(
|
|
585
|
+
f"SELECT profile_id FROM {table} WHERE {id_column} = ?",
|
|
586
|
+
(row_id,),
|
|
587
|
+
)
|
|
588
|
+
owner = dict(rows[0])["profile_id"] if rows else "<unknown>"
|
|
589
|
+
raise ProfileOwnershipConflict(
|
|
590
|
+
f"{id_column} {row_id!r} in {table} belongs to profile {owner!r}; "
|
|
591
|
+
f"refusing to re-own it as {profile_id!r}"
|
|
592
|
+
)
|
|
593
|
+
|
|
445
594
|
def store_memory(self, record: MemoryRecord) -> str:
|
|
446
|
-
"""Persist a raw memory record. Returns memory_id.
|
|
595
|
+
"""Persist a raw memory record. Returns memory_id.
|
|
596
|
+
|
|
597
|
+
Upserts in place rather than replacing. ``INSERT OR REPLACE`` is a
|
|
598
|
+
DELETE followed by an INSERT, and ``atomic_facts.memory_id`` is a
|
|
599
|
+
foreign key with ``ON DELETE CASCADE`` — so storing a record whose
|
|
600
|
+
``memory_id`` already existed silently deleted every fact extracted from
|
|
601
|
+
it. Reproduced in isolation: three facts stored, one re-store of the same
|
|
602
|
+
memory_id, zero facts left, no error raised.
|
|
603
|
+
|
|
604
|
+
Most callers pass a freshly generated id, which is why this never fired.
|
|
605
|
+
But ``cognitive_consolidator`` supplies its own ``block_id``, and the
|
|
606
|
+
queryable-promotion path in ``run_store`` deliberately avoids calling
|
|
607
|
+
this at all for an existing memory — a rule that has to be remembered
|
|
608
|
+
rather than enforced. ``ON CONFLICT DO UPDATE`` keeps the same
|
|
609
|
+
last-write-wins semantics and takes the loaded gun out of the room.
|
|
610
|
+
|
|
611
|
+
``created_at`` is deliberately not overwritten: the row's first
|
|
612
|
+
observation is a historical fact, and a re-store is not a new one.
|
|
613
|
+
``profile_id`` is not in the update list either, and a store that would
|
|
614
|
+
change it is refused outright — the ``DO UPDATE`` is conditioned on the
|
|
615
|
+
owner matching, so SQLite skips the row and ``RETURNING`` comes back
|
|
616
|
+
empty. See ``_refuse_cross_profile_reown``.
|
|
617
|
+
"""
|
|
447
618
|
_scope = getattr(record, 'scope', None) or 'personal'
|
|
448
619
|
_shared = _jd(getattr(record, 'shared_with', None))
|
|
449
|
-
self.execute(
|
|
450
|
-
"""INSERT
|
|
620
|
+
written = self.execute(
|
|
621
|
+
"""INSERT INTO memories
|
|
451
622
|
(memory_id, profile_id, content, session_id, speaker,
|
|
452
623
|
role, session_date, created_at, metadata_json,
|
|
453
624
|
scope, shared_with)
|
|
454
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
625
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
626
|
+
ON CONFLICT(memory_id) DO UPDATE SET
|
|
627
|
+
content = excluded.content,
|
|
628
|
+
session_id = excluded.session_id,
|
|
629
|
+
speaker = excluded.speaker,
|
|
630
|
+
role = excluded.role,
|
|
631
|
+
session_date = excluded.session_date,
|
|
632
|
+
metadata_json = excluded.metadata_json,
|
|
633
|
+
scope = excluded.scope,
|
|
634
|
+
shared_with = excluded.shared_with
|
|
635
|
+
WHERE memories.profile_id = excluded.profile_id
|
|
636
|
+
RETURNING profile_id""",
|
|
455
637
|
(record.memory_id, record.profile_id, record.content,
|
|
456
638
|
record.session_id, record.speaker, record.role,
|
|
457
639
|
record.session_date, record.created_at,
|
|
458
640
|
json.dumps(record.metadata), _scope, _shared),
|
|
459
641
|
)
|
|
642
|
+
if not written:
|
|
643
|
+
self._refuse_cross_profile_reown(
|
|
644
|
+
"memories", record.memory_id, record.profile_id,
|
|
645
|
+
)
|
|
460
646
|
return record.memory_id
|
|
461
647
|
|
|
462
648
|
def update_memory_summary(self, memory_id: str, summary: str) -> None:
|
|
@@ -485,6 +671,22 @@ class DatabaseManager:
|
|
|
485
671
|
pass
|
|
486
672
|
return ""
|
|
487
673
|
|
|
674
|
+
def _atomically(self, work: Any) -> Any:
|
|
675
|
+
"""Run ``work`` in one transaction, joining an open one rather than nesting.
|
|
676
|
+
|
|
677
|
+
A caller already inside ``transaction()`` must not start a second one:
|
|
678
|
+
the lock is re-entrant but ``_connect`` is not, so a nested attempt opens
|
|
679
|
+
a separate connection to the same file while the first still holds its
|
|
680
|
+
write. ``store_fact`` carried this check inline; it is here because
|
|
681
|
+
three more methods now need the same thing, and a projection intent that
|
|
682
|
+
commits in a different transaction from the row it describes is exactly
|
|
683
|
+
the window this whole mechanism exists to close.
|
|
684
|
+
"""
|
|
685
|
+
if getattr(self._txn_state, "conn", None) is not None:
|
|
686
|
+
return work()
|
|
687
|
+
with self.transaction():
|
|
688
|
+
return work()
|
|
689
|
+
|
|
488
690
|
def store_fact(self, fact: AtomicFact) -> str:
|
|
489
691
|
"""Persist an atomic fact. Returns fact_id.
|
|
490
692
|
|
|
@@ -499,6 +701,39 @@ class DatabaseManager:
|
|
|
499
701
|
twice is one fact" — preventing the duplicate explosion that poisons
|
|
500
702
|
importance ranking and core-memory promotion. Empty/whitespace
|
|
501
703
|
content is exempt (handled by placeholder filtering, not dedup).
|
|
704
|
+
|
|
705
|
+
The insert below upserts rather than replaces, for the reason
|
|
706
|
+
``store_memory`` does. ``INSERT OR REPLACE`` is a DELETE followed by an
|
|
707
|
+
INSERT, and eight tables hold
|
|
708
|
+
``FOREIGN KEY (fact_id) REFERENCES atomic_facts (fact_id) ON DELETE
|
|
709
|
+
CASCADE`` — so re-storing a fact under an occupied id dropped its
|
|
710
|
+
retention row, access history, context and importance, and raised
|
|
711
|
+
nothing.
|
|
712
|
+
|
|
713
|
+
The dedup above does not close this: it matches on *content*, so a
|
|
714
|
+
second store of the same id with *different* content falls straight
|
|
715
|
+
through to the insert. ``MemoryEngine.store_fact_direct`` reaches it —
|
|
716
|
+
``canonical_store_fact`` exists to persist a caller-chosen id and
|
|
717
|
+
raises if that id is not preserved. Within one profile an idempotency
|
|
718
|
+
key of ``prebuilt:<fact_id>`` catches the second store, but that key is
|
|
719
|
+
scoped ``(profile_id, source_type, idempotency_key)`` while
|
|
720
|
+
``atomic_facts.fact_id`` is a bare ``TEXT PRIMARY KEY``. Reproduced
|
|
721
|
+
across two profiles on one store: the first profile's fact was replaced
|
|
722
|
+
outright — new owner, new content — and its associations were gone.
|
|
723
|
+
|
|
724
|
+
``created_at`` is deliberately not overwritten: the row's first
|
|
725
|
+
observation is a historical fact, and a re-store is not a new one.
|
|
726
|
+
``pinned`` is not in the column list at all, so the upsert now leaves it
|
|
727
|
+
alone where the replace silently reset it to 0 — pinning is user intent,
|
|
728
|
+
not something a re-store gets to revoke. ``profile_id`` is out of the
|
|
729
|
+
update list for a stronger reason: a store that would change it is
|
|
730
|
+
refused rather than applied, because it is one profile taking another's
|
|
731
|
+
fact rather than a re-store at all. The refusal is a condition on the
|
|
732
|
+
``DO UPDATE`` itself, so there is no window between checking the owner
|
|
733
|
+
and writing the row.
|
|
734
|
+
|
|
735
|
+
``insert_fact_immutable`` remains the right call for a known-new fact
|
|
736
|
+
that must abort on a collision rather than win it.
|
|
502
737
|
"""
|
|
503
738
|
if fact.content and fact.content.strip():
|
|
504
739
|
# Dedup across all LIVE lifecycle zones (active/warm/cold). Excludes
|
|
@@ -533,12 +768,17 @@ class DatabaseManager:
|
|
|
533
768
|
# trustworthy observation that the anchor was missing.
|
|
534
769
|
if self.get_temporal_validity(canonical_id, fact.profile_id) is None:
|
|
535
770
|
self.store_temporal_validity(canonical_id, fact.profile_id)
|
|
771
|
+
# Re-storing known content is the natural moment to notice a
|
|
772
|
+
# projection that was never written — an upgraded store whose
|
|
773
|
+
# graph predates the migration reaches this branch, not the
|
|
774
|
+
# insert below.
|
|
775
|
+
projection_outbox.enqueue(self, canonical_id, fact.profile_id)
|
|
536
776
|
return canonical_id
|
|
537
777
|
_scope = getattr(fact, 'scope', None) or 'personal'
|
|
538
778
|
_shared = _jd(getattr(fact, 'shared_with', None))
|
|
539
779
|
def _insert_with_knowledge_anchor() -> None:
|
|
540
|
-
self.execute(
|
|
541
|
-
"""INSERT
|
|
780
|
+
written = self.execute(
|
|
781
|
+
"""INSERT INTO atomic_facts
|
|
542
782
|
(fact_id, memory_id, profile_id, content, fact_type,
|
|
543
783
|
entities_json, canonical_entities_json,
|
|
544
784
|
observation_date, referenced_date, interval_start, interval_end,
|
|
@@ -548,7 +788,35 @@ class DatabaseManager:
|
|
|
548
788
|
lifecycle, langevin_position,
|
|
549
789
|
emotional_valence, emotional_arousal, signal_type, created_at,
|
|
550
790
|
scope, shared_with)
|
|
551
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
791
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
792
|
+
ON CONFLICT(fact_id) DO UPDATE SET
|
|
793
|
+
memory_id = excluded.memory_id,
|
|
794
|
+
content = excluded.content,
|
|
795
|
+
fact_type = excluded.fact_type,
|
|
796
|
+
entities_json = excluded.entities_json,
|
|
797
|
+
canonical_entities_json = excluded.canonical_entities_json,
|
|
798
|
+
observation_date = excluded.observation_date,
|
|
799
|
+
referenced_date = excluded.referenced_date,
|
|
800
|
+
interval_start = excluded.interval_start,
|
|
801
|
+
interval_end = excluded.interval_end,
|
|
802
|
+
confidence = excluded.confidence,
|
|
803
|
+
importance = excluded.importance,
|
|
804
|
+
evidence_count = excluded.evidence_count,
|
|
805
|
+
access_count = excluded.access_count,
|
|
806
|
+
source_turn_ids_json = excluded.source_turn_ids_json,
|
|
807
|
+
session_id = excluded.session_id,
|
|
808
|
+
embedding = excluded.embedding,
|
|
809
|
+
fisher_mean = excluded.fisher_mean,
|
|
810
|
+
fisher_variance = excluded.fisher_variance,
|
|
811
|
+
lifecycle = excluded.lifecycle,
|
|
812
|
+
langevin_position = excluded.langevin_position,
|
|
813
|
+
emotional_valence = excluded.emotional_valence,
|
|
814
|
+
emotional_arousal = excluded.emotional_arousal,
|
|
815
|
+
signal_type = excluded.signal_type,
|
|
816
|
+
scope = excluded.scope,
|
|
817
|
+
shared_with = excluded.shared_with
|
|
818
|
+
WHERE atomic_facts.profile_id = excluded.profile_id
|
|
819
|
+
RETURNING profile_id""",
|
|
552
820
|
(fact.fact_id, fact.memory_id, fact.profile_id, fact.content,
|
|
553
821
|
fact.fact_type.value,
|
|
554
822
|
json.dumps(fact.entities), json.dumps(fact.canonical_entities),
|
|
@@ -556,15 +824,29 @@ class DatabaseManager:
|
|
|
556
824
|
fact.interval_start, fact.interval_end,
|
|
557
825
|
fact.confidence, fact.importance, fact.evidence_count, fact.access_count,
|
|
558
826
|
json.dumps(fact.source_turn_ids), fact.session_id,
|
|
559
|
-
encode_embedding(fact.embedding),
|
|
827
|
+
encode_embedding(fact.embedding),
|
|
828
|
+
encode_float_vector(fact.fisher_mean),
|
|
829
|
+
encode_float_vector(fact.fisher_variance),
|
|
560
830
|
fact.lifecycle.value, _jd(fact.langevin_position),
|
|
561
831
|
fact.emotional_valence, fact.emotional_arousal,
|
|
562
832
|
fact.signal_type.value, fact.created_at, _scope, _shared),
|
|
563
833
|
)
|
|
834
|
+
if not written:
|
|
835
|
+
self._refuse_cross_profile_reown(
|
|
836
|
+
"atomic_facts", fact.fact_id, fact.profile_id,
|
|
837
|
+
)
|
|
564
838
|
# Every fact written after 4.0.2 has an explicit transaction-time
|
|
565
839
|
# anchor. Absence deliberately represents pre-4.0.2
|
|
566
840
|
# ``legacy_unknown``; never backfill it from ``created_at``.
|
|
567
841
|
self.store_temporal_validity(fact.fact_id, fact.profile_id)
|
|
842
|
+
# The graph and the vectors live in other storage engines, so the
|
|
843
|
+
# intent to project this fact is queued here, in this transaction.
|
|
844
|
+
# Enqueueing in the storage layer rather than at each pipeline call
|
|
845
|
+
# site is deliberate: ingestion, the background materializer, the
|
|
846
|
+
# consolidator and the CLI all write facts through this method, and
|
|
847
|
+
# a call site that forgets would produce a memory that is stored
|
|
848
|
+
# and unrecallable.
|
|
849
|
+
projection_outbox.enqueue(self, fact.fact_id, fact.profile_id)
|
|
568
850
|
|
|
569
851
|
# The fact and its transaction-time anchor are one logical write. Do
|
|
570
852
|
# not open a nested transaction when an owner already holds one.
|
|
@@ -593,8 +875,12 @@ class DatabaseManager:
|
|
|
593
875
|
source_turn_ids=_jl(d.get("source_turn_ids_json")),
|
|
594
876
|
session_id=d.get("session_id", ""),
|
|
595
877
|
embedding=decode_embedding(d.get("embedding"), fact_id=d.get("fact_id", "<unknown>")),
|
|
596
|
-
fisher_mean=
|
|
597
|
-
|
|
878
|
+
fisher_mean=decode_float_vector(
|
|
879
|
+
d.get("fisher_mean"), field="fisher_mean",
|
|
880
|
+
fact_id=d.get("fact_id", "<unknown>")),
|
|
881
|
+
fisher_variance=decode_float_vector(
|
|
882
|
+
d.get("fisher_variance"), field="fisher_variance",
|
|
883
|
+
fact_id=d.get("fact_id", "<unknown>")),
|
|
598
884
|
lifecycle=MemoryLifecycle(d["lifecycle"]) if d.get("lifecycle") else MemoryLifecycle.ACTIVE,
|
|
599
885
|
langevin_position=_jl(d.get("langevin_position"), None),
|
|
600
886
|
emotional_valence=d.get("emotional_valence", 0.0),
|
|
@@ -647,8 +933,8 @@ class DatabaseManager:
|
|
|
647
933
|
json.dumps(fact.source_turn_ids),
|
|
648
934
|
fact.session_id,
|
|
649
935
|
encode_embedding(fact.embedding),
|
|
650
|
-
|
|
651
|
-
|
|
936
|
+
encode_float_vector(fact.fisher_mean),
|
|
937
|
+
encode_float_vector(fact.fisher_variance),
|
|
652
938
|
fact.lifecycle.value,
|
|
653
939
|
_jd(fact.langevin_position),
|
|
654
940
|
fact.emotional_valence,
|
|
@@ -660,6 +946,7 @@ class DatabaseManager:
|
|
|
660
946
|
),
|
|
661
947
|
)
|
|
662
948
|
self.store_temporal_validity(fact.fact_id, fact.profile_id)
|
|
949
|
+
projection_outbox.enqueue(self, fact.fact_id, fact.profile_id)
|
|
663
950
|
return fact.fact_id
|
|
664
951
|
|
|
665
952
|
def set_pinned(self, fact_id: str, pinned: bool) -> None:
|
|
@@ -686,8 +973,12 @@ class DatabaseManager:
|
|
|
686
973
|
include_shared=include_shared,
|
|
687
974
|
prefix="f",
|
|
688
975
|
)
|
|
976
|
+
# Pins are injected straight into an agent's context, which makes this
|
|
977
|
+
# the most consequential display path in the class: a withheld row here
|
|
978
|
+
# is not merely shown, it is asserted as background truth.
|
|
689
979
|
rows = self.execute(
|
|
690
980
|
f"SELECT f.* FROM atomic_facts f WHERE {where} AND f.pinned = 1 "
|
|
981
|
+
f"{self.visible_fact_clause('f')} "
|
|
691
982
|
"AND NOT EXISTS ("
|
|
692
983
|
" SELECT 1 FROM fact_temporal_validity tv "
|
|
693
984
|
" WHERE tv.fact_id = f.fact_id "
|
|
@@ -733,6 +1024,64 @@ class DatabaseManager:
|
|
|
733
1024
|
self._archive_col_present = True
|
|
734
1025
|
return present
|
|
735
1026
|
|
|
1027
|
+
def _has_quarantine_column(self) -> bool:
|
|
1028
|
+
"""Whether atomic_facts carries the ``quarantined`` column.
|
|
1029
|
+
|
|
1030
|
+
Same shape as ``_has_archive_status``: cached once True (a column never
|
|
1031
|
+
disappears), re-checked while absent so a later schema pass is picked
|
|
1032
|
+
up. ``storage.schema.create_all_tables`` adds the column at every engine
|
|
1033
|
+
init, so on any store the daemon has opened this is True — the guard
|
|
1034
|
+
exists for a bare DatabaseManager pointed at a store that engine init
|
|
1035
|
+
never touched, where filtering on the column would raise instead of
|
|
1036
|
+
returning results.
|
|
1037
|
+
"""
|
|
1038
|
+
if getattr(self, "_quarantine_col_present", False):
|
|
1039
|
+
return True
|
|
1040
|
+
present = any(
|
|
1041
|
+
dict(row).get("name") == "quarantined"
|
|
1042
|
+
for row in self.execute("PRAGMA table_info(atomic_facts)")
|
|
1043
|
+
)
|
|
1044
|
+
if present:
|
|
1045
|
+
self._quarantine_col_present = True
|
|
1046
|
+
return present
|
|
1047
|
+
|
|
1048
|
+
def visible_fact_clause(
|
|
1049
|
+
self, prefix: str = "", *, include_quarantined: bool = False,
|
|
1050
|
+
) -> str:
|
|
1051
|
+
"""AND-clause excluding rows no caller should be shown as a memory.
|
|
1052
|
+
|
|
1053
|
+
Two exclusions, one definition: soft-deleted (``archive_status``) and
|
|
1054
|
+
withheld (``quarantined``). Both are presence-guarded, because each
|
|
1055
|
+
column arrives with a migration and may be absent on a store the engine
|
|
1056
|
+
has not opened.
|
|
1057
|
+
|
|
1058
|
+
WHY THIS EXISTS AS A FUNCTION. 4.0.10 first put the quarantine filter in
|
|
1059
|
+
``get_facts_by_ids`` alone, reasoning that every retrieval channel
|
|
1060
|
+
re-authorises through it and the engine drops what it cannot hydrate.
|
|
1061
|
+
That reasoning was correct and the conclusion was wrong: it covered the
|
|
1062
|
+
RECALL pipeline, and ``search``, ``list_recent``, ``fetch``, the MCP
|
|
1063
|
+
resources and the dashboard's own search are not the recall pipeline.
|
|
1064
|
+
Measured on a copy of the author's store, ``search_facts_fts`` returned
|
|
1065
|
+
20 withheld rows out of 50 and ``get_all_facts`` 66 out of 400 — the
|
|
1066
|
+
exact defect the design was meant to prevent, in the paths the design
|
|
1067
|
+
never looked at.
|
|
1068
|
+
|
|
1069
|
+
There is no single SQL choke point in this codebase; ``_scope_where`` is
|
|
1070
|
+
spliced against six other tables and cannot carry a fact column. So the
|
|
1071
|
+
honest form of "one place" is one CLAUSE with an enumerable set of call
|
|
1072
|
+
sites, and a test that fails when a read path does not use it:
|
|
1073
|
+
tests/test_storage/test_no_read_path_shows_a_withheld_row.py
|
|
1074
|
+
|
|
1075
|
+
``include_quarantined=True`` is for repair, erasure and export — paths
|
|
1076
|
+
that must reach a withheld row to act on it.
|
|
1077
|
+
"""
|
|
1078
|
+
return _compose_visible_clause(
|
|
1079
|
+
prefix,
|
|
1080
|
+
has_archive=self._has_archive_status(),
|
|
1081
|
+
has_quarantine=self._has_quarantine_column(),
|
|
1082
|
+
include_quarantined=include_quarantined,
|
|
1083
|
+
)
|
|
1084
|
+
|
|
736
1085
|
def get_all_facts(
|
|
737
1086
|
self, profile_id: str, limit: int | None = None,
|
|
738
1087
|
*,
|
|
@@ -755,14 +1104,10 @@ class DatabaseManager:
|
|
|
755
1104
|
# hard, env-tunable ceiling even when the caller passes limit=None.
|
|
756
1105
|
if limit is None:
|
|
757
1106
|
limit = _unbounded_facts_ceiling()
|
|
758
|
-
#
|
|
759
|
-
archive_clause = (
|
|
760
|
-
" AND COALESCE(archive_status, 'live') != 'archived'"
|
|
761
|
-
if self._has_archive_status()
|
|
762
|
-
else ""
|
|
763
|
-
)
|
|
1107
|
+
# Soft-deleted and withheld rows are not memories a caller may see.
|
|
764
1108
|
rows = self.execute(
|
|
765
|
-
f"SELECT * FROM atomic_facts WHERE {where}
|
|
1109
|
+
f"SELECT * FROM atomic_facts WHERE {where}"
|
|
1110
|
+
f"{self.visible_fact_clause()} "
|
|
766
1111
|
"ORDER BY created_at DESC LIMIT ?",
|
|
767
1112
|
(*params, int(limit)),
|
|
768
1113
|
)
|
|
@@ -790,14 +1135,12 @@ class DatabaseManager:
|
|
|
790
1135
|
include_global=include_global,
|
|
791
1136
|
include_shared=include_shared,
|
|
792
1137
|
)
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
else ""
|
|
797
|
-
)
|
|
1138
|
+
# Crossing a profile boundary is the last place a withheld row should
|
|
1139
|
+
# appear: it would be a model's non-answer presented to somebody else
|
|
1140
|
+
# as one of this profile's shared memories.
|
|
798
1141
|
rows = self.execute(
|
|
799
1142
|
f"SELECT * FROM atomic_facts WHERE {where} AND profile_id != ?"
|
|
800
|
-
f"{
|
|
1143
|
+
f"{self.visible_fact_clause()} ORDER BY created_at DESC",
|
|
801
1144
|
(*params, profile_id),
|
|
802
1145
|
)
|
|
803
1146
|
return [self._row_to_fact(r) for r in rows]
|
|
@@ -880,6 +1223,10 @@ class DatabaseManager:
|
|
|
880
1223
|
# converted store, one fact at a time, undoing the conversion
|
|
881
1224
|
# wherever a fact is updated.
|
|
882
1225
|
clean[k] = encode_embedding(v) if v is not None else None
|
|
1226
|
+
elif k in ("fisher_mean", "fisher_variance"):
|
|
1227
|
+
# Same hazard, same answer: these are float vectors of the same
|
|
1228
|
+
# width as the embedding and are stored the same way.
|
|
1229
|
+
clean[k] = encode_float_vector(v) if v is not None else None
|
|
883
1230
|
elif isinstance(v, (list, dict)):
|
|
884
1231
|
clean[k] = json.dumps(v)
|
|
885
1232
|
elif isinstance(v, (MemoryLifecycle, FactType, SignalType)):
|
|
@@ -887,25 +1234,49 @@ class DatabaseManager:
|
|
|
887
1234
|
else:
|
|
888
1235
|
clean[k] = v
|
|
889
1236
|
set_clause = ", ".join(f"{k} = ?" for k in clean)
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1237
|
+
|
|
1238
|
+
def _write() -> None:
|
|
1239
|
+
if profile_id is not None:
|
|
1240
|
+
self.execute(
|
|
1241
|
+
f"UPDATE atomic_facts SET {set_clause} "
|
|
1242
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
1243
|
+
(*clean.values(), fact_id, profile_id),
|
|
1244
|
+
)
|
|
1245
|
+
else:
|
|
1246
|
+
self.execute(
|
|
1247
|
+
f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ?",
|
|
1248
|
+
(*clean.values(), fact_id),
|
|
1249
|
+
)
|
|
1250
|
+
# Only an update that changes something a projection is derived
|
|
1251
|
+
# from needs re-projecting. Recall bumps access_count on every hit,
|
|
1252
|
+
# so queueing on any update at all would hand the drain worker one
|
|
1253
|
+
# row per returned memory per recall, for a column neither Cozo nor
|
|
1254
|
+
# Lance holds.
|
|
1255
|
+
if set(updates) & projection_outbox.PROJECTED_FACT_COLUMNS:
|
|
1256
|
+
if profile_id is not None:
|
|
1257
|
+
projection_outbox.enqueue(self, fact_id, profile_id)
|
|
1258
|
+
else:
|
|
1259
|
+
projection_outbox.enqueue_for_fact(self, fact_id)
|
|
1260
|
+
|
|
1261
|
+
self._atomically(_write)
|
|
900
1262
|
|
|
901
1263
|
def delete_fact(self, fact_id: str, profile_id: str | None = None) -> None:
|
|
902
1264
|
"""Hard-delete a fact.
|
|
903
1265
|
|
|
904
1266
|
DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
|
|
905
|
-
embedding_metadata / fact_retention
|
|
1267
|
+
embedding_metadata / fact_retention cascade. The explicit
|
|
906
1268
|
embedding_metadata delete below is belt-and-suspenders for the case a
|
|
907
1269
|
future caller routes through a connection without FK enforcement.
|
|
908
1270
|
|
|
1271
|
+
``graph_edges`` does NOT cascade, whatever this docstring used to say.
|
|
1272
|
+
Its only foreign key is to ``profiles``; there is none to
|
|
1273
|
+
``atomic_facts``, because an edge's endpoints can be entity ids as well
|
|
1274
|
+
as fact ids and a single column cannot reference two tables. So the
|
|
1275
|
+
edges are deleted here, explicitly. Without that, deleting a memory left
|
|
1276
|
+
its connections behind pointing at nothing, and the graph tidy-up pass
|
|
1277
|
+
would not reach them until its next run -- during which a search could
|
|
1278
|
+
still follow an edge into a memory that no longer exists.
|
|
1279
|
+
|
|
909
1280
|
Tenant safety: when ``profile_id`` is supplied the delete is constrained
|
|
910
1281
|
to that tenant (the fact must belong to it), so a fact_id from another
|
|
911
1282
|
profile cannot be destroyed. Authorized routes always pass it.
|
|
@@ -917,14 +1288,37 @@ class DatabaseManager:
|
|
|
917
1288
|
)
|
|
918
1289
|
if not row:
|
|
919
1290
|
return # not this tenant's fact — no-op
|
|
920
|
-
|
|
921
|
-
|
|
1291
|
+
# A forgotten memory that survives in the graph or the vector index is
|
|
1292
|
+
# still recallable, which makes an erasure receipt a false statement. So
|
|
1293
|
+
# the deletes and the queued removal are one transaction: the process
|
|
1294
|
+
# can die immediately afterwards and the projections still get cleaned.
|
|
1295
|
+
def _write() -> None:
|
|
1296
|
+
# Read the tenant while the fact is still there. After the DELETE
|
|
1297
|
+
# there is nothing left to resolve it from, and a queued removal
|
|
1298
|
+
# filed under the wrong tenant is a fact id that outlives that
|
|
1299
|
+
# tenant's erasure.
|
|
1300
|
+
owner = profile_id or projection_outbox.resolve_profile(self, fact_id)
|
|
922
1301
|
self.execute(
|
|
923
|
-
"DELETE FROM
|
|
924
|
-
|
|
1302
|
+
"DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,),
|
|
1303
|
+
)
|
|
1304
|
+
if profile_id is not None:
|
|
1305
|
+
self.execute(
|
|
1306
|
+
"DELETE FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
1307
|
+
(fact_id, profile_id),
|
|
1308
|
+
)
|
|
1309
|
+
else:
|
|
1310
|
+
self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
|
|
1311
|
+
# Both directions: an edge naming this fact at either end is now an
|
|
1312
|
+
# edge to nothing.
|
|
1313
|
+
self.execute(
|
|
1314
|
+
"DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?",
|
|
1315
|
+
(fact_id, fact_id),
|
|
1316
|
+
)
|
|
1317
|
+
projection_outbox.enqueue(
|
|
1318
|
+
self, fact_id, owner or "default", projection_outbox.OP_DELETE,
|
|
925
1319
|
)
|
|
926
|
-
|
|
927
|
-
|
|
1320
|
+
|
|
1321
|
+
self._atomically(_write)
|
|
928
1322
|
|
|
929
1323
|
def gc_orphaned_embedding_metadata(self) -> int:
|
|
930
1324
|
"""Remove embedding_metadata rows whose parent atomic_fact is gone.
|
|
@@ -953,14 +1347,21 @@ class DatabaseManager:
|
|
|
953
1347
|
include_global: bool = False,
|
|
954
1348
|
include_shared: bool = False,
|
|
955
1349
|
) -> int:
|
|
956
|
-
"""
|
|
1350
|
+
"""Memories this profile has, as the owner would count them.
|
|
1351
|
+
|
|
1352
|
+
Counts what a caller can be shown, which is why it applies
|
|
1353
|
+
``visible_fact_clause``. It fed the dashboard's "All memories 5,093" and
|
|
1354
|
+
was counting 1,195 withheld summaries and every soft-deleted row into
|
|
1355
|
+
that figure -- a number the owner reads as "how much do I remember".
|
|
1356
|
+
"""
|
|
957
1357
|
where, params = _scope_where(
|
|
958
1358
|
profile_id,
|
|
959
1359
|
include_global=include_global,
|
|
960
1360
|
include_shared=include_shared,
|
|
961
1361
|
)
|
|
962
1362
|
rows = self.execute(
|
|
963
|
-
f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}"
|
|
1363
|
+
f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}"
|
|
1364
|
+
f"{self.visible_fact_clause()}", (*params,),
|
|
964
1365
|
)
|
|
965
1366
|
return int(rows[0]["c"]) if rows else 0
|
|
966
1367
|
|
|
@@ -1073,30 +1474,59 @@ class DatabaseManager:
|
|
|
1073
1474
|
duplicate we keep the MAX weight (strongest association wins) and
|
|
1074
1475
|
return the existing edge_id.
|
|
1075
1476
|
"""
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
)
|
|
1082
|
-
|
|
1083
|
-
|
|
1477
|
+
# The edge and the re-projection of its endpoints are one logical write.
|
|
1478
|
+
# Left as separate statements they were three separate commits, which
|
|
1479
|
+
# cost three fsyncs on a path the materializer runs tens of thousands of
|
|
1480
|
+
# times, and left a window where the edge was durable and the intent to
|
|
1481
|
+
# project it was not.
|
|
1482
|
+
def _write() -> str:
|
|
1483
|
+
existing = self.execute(
|
|
1484
|
+
"SELECT edge_id FROM graph_edges "
|
|
1485
|
+
"WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
|
|
1486
|
+
"LIMIT 1",
|
|
1487
|
+
(edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
|
|
1488
|
+
)
|
|
1489
|
+
if existing:
|
|
1490
|
+
canonical_id = dict(existing[0])["edge_id"]
|
|
1491
|
+
self.execute(
|
|
1492
|
+
"UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
|
|
1493
|
+
(edge.weight, canonical_id),
|
|
1494
|
+
)
|
|
1495
|
+
self._enqueue_edge_endpoints(edge)
|
|
1496
|
+
return canonical_id
|
|
1497
|
+
_scope = getattr(edge, 'scope', None) or 'personal'
|
|
1498
|
+
_shared = _jd(getattr(edge, 'shared_with', None))
|
|
1084
1499
|
self.execute(
|
|
1085
|
-
"
|
|
1086
|
-
|
|
1500
|
+
"""INSERT OR REPLACE INTO graph_edges
|
|
1501
|
+
(edge_id, profile_id, source_id, target_id, edge_type, weight, created_at,
|
|
1502
|
+
scope, shared_with)
|
|
1503
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
1504
|
+
(edge.edge_id, edge.profile_id, edge.source_id, edge.target_id,
|
|
1505
|
+
edge.edge_type.value, edge.weight, edge.created_at, _scope, _shared),
|
|
1087
1506
|
)
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
self.
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1507
|
+
self._enqueue_edge_endpoints(edge)
|
|
1508
|
+
return edge.edge_id
|
|
1509
|
+
|
|
1510
|
+
return self._atomically(_write)
|
|
1511
|
+
|
|
1512
|
+
def _enqueue_edge_endpoints(self, edge: GraphEdge) -> None:
|
|
1513
|
+
"""Re-queue both ends of an edge for projection.
|
|
1514
|
+
|
|
1515
|
+
Ingestion is queryable-first: a fact commits immediately and its edges
|
|
1516
|
+
arrive afterwards, from the background materializer. A projection
|
|
1517
|
+
queued only when the fact was inserted would therefore be written
|
|
1518
|
+
before a single edge existed, leaving the node in the graph with none
|
|
1519
|
+
of its connections — which is precisely the adjacency the graph is
|
|
1520
|
+
consulted for.
|
|
1521
|
+
|
|
1522
|
+
An endpoint may be an entity id rather than a fact id. Those are queued
|
|
1523
|
+
too and the drain skips whatever it cannot find as a fact; filtering
|
|
1524
|
+
here would mean a lookup per endpoint on every edge write, which the
|
|
1525
|
+
materializer does tens of thousands of times.
|
|
1526
|
+
"""
|
|
1527
|
+
projection_outbox.enqueue_many(
|
|
1528
|
+
self, (edge.source_id, edge.target_id), edge.profile_id,
|
|
1098
1529
|
)
|
|
1099
|
-
return edge.edge_id
|
|
1100
1530
|
|
|
1101
1531
|
def get_edges_for_node(
|
|
1102
1532
|
self, node_id: str, profile_id: str,
|
|
@@ -1230,16 +1660,15 @@ class DatabaseManager:
|
|
|
1230
1660
|
include_shared=include_shared,
|
|
1231
1661
|
prefix="f",
|
|
1232
1662
|
)
|
|
1233
|
-
#
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
else ""
|
|
1238
|
-
)
|
|
1663
|
+
# Full-text search is a display path: the dashboard search box, the
|
|
1664
|
+
# `search` tool and `fetch` all land here, and none of them go through
|
|
1665
|
+
# the recall engine. Before 4.0.10 put the clause here it returned 20
|
|
1666
|
+
# withheld rows out of 50 on the author's store.
|
|
1239
1667
|
rows = self.execute(
|
|
1240
1668
|
f"""SELECT f.* FROM atomic_facts_fts AS fts
|
|
1241
1669
|
JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
|
|
1242
|
-
WHERE fts.atomic_facts_fts MATCH ? AND {where}
|
|
1670
|
+
WHERE fts.atomic_facts_fts MATCH ? AND {where}
|
|
1671
|
+
{self.visible_fact_clause('f')}
|
|
1243
1672
|
ORDER BY fts.rank LIMIT ?""",
|
|
1244
1673
|
(match_expr, *params, limit),
|
|
1245
1674
|
)
|
|
@@ -1270,12 +1699,22 @@ class DatabaseManager:
|
|
|
1270
1699
|
# ------------------------------------------------------------------
|
|
1271
1700
|
|
|
1272
1701
|
def get_fact(self, fact_id: str, profile_id: str | None = None) -> AtomicFact | None:
|
|
1273
|
-
"""Get a single
|
|
1702
|
+
"""Get a single row by ID, exactly as stored. NOT a display path.
|
|
1274
1703
|
|
|
1275
1704
|
C4 defense-in-depth: when ``profile_id`` is provided the lookup is
|
|
1276
1705
|
tenant-scoped so a fact_id from another profile cannot resolve. Left
|
|
1277
1706
|
optional (fact_id is a random UUID sourced from already-scoped queries)
|
|
1278
1707
|
to avoid destabilizing the core store/consolidation write path.
|
|
1708
|
+
|
|
1709
|
+
DELIBERATELY UNFILTERED, and this is load-bearing. It applies neither
|
|
1710
|
+
``archive_status`` nor ``quarantined`` because it is the primitive that
|
|
1711
|
+
write paths, correction handling and the 4.0.10 repair use to read a row
|
|
1712
|
+
they already hold the id of — including a withheld one, which they must
|
|
1713
|
+
be able to see in order to act on it. ``visible_fact_clause`` is for the
|
|
1714
|
+
paths that answer a question; this one answers "what is in that row".
|
|
1715
|
+
|
|
1716
|
+
A caller taking a fact_id from user input and rendering the result wants
|
|
1717
|
+
``get_facts_by_ids`` instead.
|
|
1279
1718
|
"""
|
|
1280
1719
|
if profile_id is not None:
|
|
1281
1720
|
rows = self.execute(
|
|
@@ -1292,8 +1731,36 @@ class DatabaseManager:
|
|
|
1292
1731
|
self, fact_ids: list[str], profile_id: str,
|
|
1293
1732
|
include_global: bool = False,
|
|
1294
1733
|
include_shared: bool = False,
|
|
1734
|
+
*,
|
|
1735
|
+
include_quarantined: bool = False,
|
|
1295
1736
|
) -> list[AtomicFact]:
|
|
1296
|
-
"""Get multiple facts by their IDs, scoped to a profile.
|
|
1737
|
+
"""Get multiple facts by their IDs, scoped to a profile.
|
|
1738
|
+
|
|
1739
|
+
THIS IS THE PLACE QUARANTINE IS ENFORCED, and the only one.
|
|
1740
|
+
|
|
1741
|
+
Every retrieval channel re-authorises its candidates through here
|
|
1742
|
+
(``retrieval/scope_policy.py`` — "candidate generators may use caches,
|
|
1743
|
+
approximate indexes, or graph stores that are not the authorization
|
|
1744
|
+
source of truth"), and the engine hydrates the fused set from here too.
|
|
1745
|
+
A fact this method does not return has no content to show, and
|
|
1746
|
+
``retrieval/engine.py`` drops it: ``if fact is None: continue``. So one
|
|
1747
|
+
clause here covers bm25, semantic, temporal, entity, hopfield and
|
|
1748
|
+
spreading activation, in normal and deep recall alike, whether or not
|
|
1749
|
+
the forgetting filter is registered.
|
|
1750
|
+
|
|
1751
|
+
The alternatives were checked and rejected. ``_scope_where`` looks like
|
|
1752
|
+
the natural home but is spliced against ``graph_edges``,
|
|
1753
|
+
``temporal_events``, ``memories``, ``bm25_tokens``,
|
|
1754
|
+
``fact_temporal_validity`` and ``correction_cases`` as well as
|
|
1755
|
+
``atomic_facts``, so a column reference there breaks eight call sites.
|
|
1756
|
+
``ForgettingFilter`` is optional (it no-ops when forgetting is
|
|
1757
|
+
disabled) and excludes nothing in deep recall.
|
|
1758
|
+
|
|
1759
|
+
``include_quarantined=True`` is for repair, export and erasure — paths
|
|
1760
|
+
that must be able to see a withheld row in order to act on it. It is
|
|
1761
|
+
keyword-only and greppable on purpose: every caller that opts in is
|
|
1762
|
+
meant to be found in one search.
|
|
1763
|
+
"""
|
|
1297
1764
|
if not fact_ids:
|
|
1298
1765
|
return []
|
|
1299
1766
|
where, params = _scope_where(
|
|
@@ -1301,19 +1768,68 @@ class DatabaseManager:
|
|
|
1301
1768
|
include_global=include_global,
|
|
1302
1769
|
include_shared=include_shared,
|
|
1303
1770
|
)
|
|
1304
|
-
archive_clause = (
|
|
1305
|
-
" AND COALESCE(archive_status, 'live') != 'archived'"
|
|
1306
|
-
if self._has_archive_status()
|
|
1307
|
-
else ""
|
|
1308
|
-
)
|
|
1309
1771
|
placeholders = ",".join("?" for _ in fact_ids)
|
|
1310
1772
|
rows = self.execute(
|
|
1311
1773
|
f"SELECT * FROM atomic_facts WHERE fact_id IN ({placeholders}) "
|
|
1312
|
-
f"AND {where}
|
|
1774
|
+
f"AND {where}"
|
|
1775
|
+
f"{self.visible_fact_clause(include_quarantined=include_quarantined)} "
|
|
1776
|
+
"ORDER BY created_at DESC",
|
|
1313
1777
|
(*fact_ids, *params),
|
|
1314
1778
|
)
|
|
1315
1779
|
return [self._row_to_fact(r) for r in rows]
|
|
1316
1780
|
|
|
1781
|
+
def visible_fact_ids(
|
|
1782
|
+
self, fact_ids: list[str] | tuple[str, ...], profile_id: str,
|
|
1783
|
+
include_global: bool = False,
|
|
1784
|
+
include_shared: bool = False,
|
|
1785
|
+
*,
|
|
1786
|
+
include_quarantined: bool = False,
|
|
1787
|
+
) -> set[str]:
|
|
1788
|
+
"""Which of *fact_ids* this profile may see. Same rule, no hydration.
|
|
1789
|
+
|
|
1790
|
+
``get_facts_by_ids`` is the authorization source of truth and every
|
|
1791
|
+
retrieval channel re-authorises through it — but a channel deciding
|
|
1792
|
+
*which* of its candidates are allowed does not need their content, and
|
|
1793
|
+
paying for the content is most of what recall costs.
|
|
1794
|
+
|
|
1795
|
+
Measured on the author's store: the entity channel authorised 3,659
|
|
1796
|
+
candidates to return 20, and that single call was **374 ms of a 430 ms
|
|
1797
|
+
recall — 87%**. Not the graph walk, which is 3.8 ms. The cost is
|
|
1798
|
+
``_row_to_fact`` decoding a 768-float embedding and two 768-float Fisher
|
|
1799
|
+
vectors per row: about 8.4 million floats deserialised to answer a
|
|
1800
|
+
yes/no question about 3,659 ids. The same trap is recorded a few
|
|
1801
|
+
hundred lines up, where loading full facts "turned one new fact into a
|
|
1802
|
+
5-second recall stall".
|
|
1803
|
+
|
|
1804
|
+
The predicate is built by the same two calls as ``get_facts_by_ids``, in
|
|
1805
|
+
the same order, so the two cannot answer differently. A test asserts
|
|
1806
|
+
that on the same inputs. Batched because the id list is unbounded and a
|
|
1807
|
+
single ``IN`` clause is not.
|
|
1808
|
+
"""
|
|
1809
|
+
if not fact_ids:
|
|
1810
|
+
return set()
|
|
1811
|
+
where, params = _scope_where(
|
|
1812
|
+
profile_id,
|
|
1813
|
+
include_global=include_global,
|
|
1814
|
+
include_shared=include_shared,
|
|
1815
|
+
)
|
|
1816
|
+
visible = self.visible_fact_clause(include_quarantined=include_quarantined)
|
|
1817
|
+
unique = list(dict.fromkeys(fact_ids))
|
|
1818
|
+
allowed: set[str] = set()
|
|
1819
|
+
# Well inside SQLITE_MAX_VARIABLE_NUMBER once the scope parameters are
|
|
1820
|
+
# added, on every build this ships against.
|
|
1821
|
+
chunk = 800
|
|
1822
|
+
for start in range(0, len(unique), chunk):
|
|
1823
|
+
batch = unique[start:start + chunk]
|
|
1824
|
+
placeholders = ",".join("?" for _ in batch)
|
|
1825
|
+
rows = self.execute(
|
|
1826
|
+
f"SELECT fact_id FROM atomic_facts WHERE fact_id IN ({placeholders}) "
|
|
1827
|
+
f"AND {where}{visible}",
|
|
1828
|
+
(*batch, *params),
|
|
1829
|
+
)
|
|
1830
|
+
allowed.update(dict(row)["fact_id"] for row in rows)
|
|
1831
|
+
return allowed
|
|
1832
|
+
|
|
1317
1833
|
def store_entity_profile(self, ep: EntityProfile) -> str:
|
|
1318
1834
|
"""Persist an entity profile. Returns profile_entry_id."""
|
|
1319
1835
|
self.execute(
|