flurryx-code-memory 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. code_memory/__init__.py +1 -0
  2. code_memory/claims/__init__.py +32 -0
  3. code_memory/claims/extractor.py +325 -0
  4. code_memory/claims/indexer.py +258 -0
  5. code_memory/claims/resolver.py +186 -0
  6. code_memory/claims/store.py +424 -0
  7. code_memory/cli.py +1192 -0
  8. code_memory/config.py +268 -0
  9. code_memory/embed/__init__.py +224 -0
  10. code_memory/embed/cache.py +204 -0
  11. code_memory/embed/m3.py +174 -0
  12. code_memory/embed/ollama.py +92 -0
  13. code_memory/embed/tei.py +106 -0
  14. code_memory/episodic/__init__.py +3 -0
  15. code_memory/episodic/sqlite_store.py +278 -0
  16. code_memory/extractor/__init__.py +3 -0
  17. code_memory/extractor/csproj.py +166 -0
  18. code_memory/extractor/dll.py +385 -0
  19. code_memory/extractor/gitignore.py +162 -0
  20. code_memory/extractor/nuget.py +275 -0
  21. code_memory/extractor/sanity.py +124 -0
  22. code_memory/extractor/sln.py +108 -0
  23. code_memory/extractor/treesitter.py +1172 -0
  24. code_memory/graph/__init__.py +3 -0
  25. code_memory/graph/falkor_store.py +740 -0
  26. code_memory/mcp_server.py +1816 -0
  27. code_memory/metrics.py +260 -0
  28. code_memory/orchestrator/__init__.py +13 -0
  29. code_memory/orchestrator/git_delta.py +211 -0
  30. code_memory/orchestrator/ingest_state.py +71 -0
  31. code_memory/orchestrator/pipeline.py +1478 -0
  32. code_memory/orchestrator/reset.py +130 -0
  33. code_memory/orchestrator/resolver.py +825 -0
  34. code_memory/orchestrator/retrieve.py +505 -0
  35. code_memory/resilience.py +73 -0
  36. code_memory/sync/__init__.py +20 -0
  37. code_memory/sync/autostart/__init__.py +42 -0
  38. code_memory/sync/autostart/base.py +106 -0
  39. code_memory/sync/autostart/launchd.py +115 -0
  40. code_memory/sync/autostart/schtasks.py +155 -0
  41. code_memory/sync/autostart/systemd.py +113 -0
  42. code_memory/sync/hooks.py +164 -0
  43. code_memory/sync/safety.py +65 -0
  44. code_memory/sync/snapshot.py +461 -0
  45. code_memory/sync/store.py +399 -0
  46. code_memory/sync/sync.py +405 -0
  47. code_memory/sync/watcher.py +320 -0
  48. code_memory/vector/__init__.py +3 -0
  49. code_memory/vector/qdrant_store.py +302 -0
  50. flurryx_code_memory-0.4.0.dist-info/METADATA +26 -0
  51. flurryx_code_memory-0.4.0.dist-info/RECORD +53 -0
  52. flurryx_code_memory-0.4.0.dist-info/WHEEL +4 -0
  53. flurryx_code_memory-0.4.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,186 @@
1
+ """Qdrant-backed entity resolution for claim subjects and objects.
2
+
3
+ Each unique entity referenced by a claim becomes a point in a per-project
4
+ ``claim_entities__<slug>`` collection. The point payload stores:
5
+
6
+ * ``canonical`` — the first form we saw for this entity (preserved
7
+ casing).
8
+ * ``aliases`` — every distinct form we've seen since.
9
+
10
+ On :meth:`EntityResolver.resolve`:
11
+
12
+ 1. Embed the input text via the project's :mod:`Ollama` embedder.
13
+ 2. Search the collection. If the top hit's cosine score is
14
+ ``>= threshold`` (default ``0.85``), reuse it: append the new
15
+ surface form to ``aliases`` and return the existing ID.
16
+ 3. Otherwise create a fresh point with a new UUID.
17
+
18
+ Concurrency caveat: extraction may run in detached background processes,
19
+ so two near-simultaneous extractions of the same entity could each take
20
+ the "create new" branch. That's acceptable — the next extraction sees
21
+ both and merges around whichever wins the search. False merges (two
22
+ distinct entities collapsed into one) are the failure mode worth fearing,
23
+ hence the conservative threshold.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import uuid
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ from ..config import CONFIG, Config
34
+ from ..embed import Embedder, get_embedder
35
+ from ..vector import QdrantStore, VectorRecord
36
+
37
+ _LOG = logging.getLogger(__name__)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class EntityRef:
42
+ """Return type for a single resolve() call."""
43
+
44
+ id: str
45
+ canonical: str
46
+ was_new: bool
47
+
48
+
49
+ class EntityResolver:
50
+ """Embed → search → reuse-or-create entity points in Qdrant."""
51
+
52
+ def __init__(
53
+ self,
54
+ project: str,
55
+ vector: QdrantStore | None = None,
56
+ embedder: Embedder | None = None,
57
+ threshold: float | None = None,
58
+ cfg: Config | None = None,
59
+ ) -> None:
60
+ self.cfg = cfg or CONFIG.for_project(project)
61
+ self.threshold = (
62
+ threshold if threshold is not None else CONFIG.claims_entity_threshold
63
+ )
64
+ self.vector = vector or QdrantStore()
65
+ self.embedder = embedder or get_embedder()
66
+ self._collection = self.cfg.qdrant_claim_entities
67
+ self._ensured = False
68
+
69
+ # ------------------------------------------------------------ public
70
+
71
+ def resolve(self, text: str) -> EntityRef | None:
72
+ """Resolve a surface form to a canonical entity.
73
+
74
+ Returns ``None`` when the input is empty after stripping, or when
75
+ the embedder / Qdrant client fails — the caller should treat
76
+ ``None`` as "skip entity resolution for this row" and persist the
77
+ claim without an entity ID.
78
+ """
79
+ surface = (text or "").strip()
80
+ if not surface:
81
+ return None
82
+
83
+ try:
84
+ self._ensure_collection()
85
+ except Exception: # noqa: BLE001
86
+ _LOG.exception("entity resolver: ensure_collection failed")
87
+ return None
88
+
89
+ try:
90
+ vec = self.embedder.embed_one(surface)
91
+ except Exception: # noqa: BLE001
92
+ _LOG.exception("entity resolver: embed failed for %r", surface)
93
+ return None
94
+
95
+ try:
96
+ hits = self.vector.search(self._collection, vec, top_k=1)
97
+ except LookupError:
98
+ # Collection vanished between ensure_collection and search.
99
+ # Treat as miss and continue with a new entity.
100
+ hits = []
101
+ except Exception: # noqa: BLE001
102
+ _LOG.exception("entity resolver: search failed for %r", surface)
103
+ return None
104
+
105
+ if hits and hits[0].score >= self.threshold:
106
+ top = hits[0]
107
+ canonical = str(top.payload.get("canonical") or surface)
108
+ self._record_alias(top.id, surface, top.payload)
109
+ return EntityRef(id=top.id, canonical=canonical, was_new=False)
110
+
111
+ # No close match — mint a new entity.
112
+ new_id = str(uuid.uuid4())
113
+ try:
114
+ self.vector.upsert(
115
+ self._collection,
116
+ [
117
+ VectorRecord(
118
+ id=new_id,
119
+ vector=vec,
120
+ payload={"canonical": surface, "aliases": [surface]},
121
+ )
122
+ ],
123
+ )
124
+ except Exception: # noqa: BLE001
125
+ _LOG.exception(
126
+ "entity resolver: upsert failed for new entity %r", surface
127
+ )
128
+ return None
129
+ return EntityRef(id=new_id, canonical=surface, was_new=True)
130
+
131
+ # ----------------------------------------------------------- helpers
132
+
133
+ def _ensure_collection(self) -> None:
134
+ if self._ensured:
135
+ return
136
+ self.vector.ensure_collection(self._collection)
137
+ self._ensured = True
138
+
139
+ def _record_alias(
140
+ self,
141
+ entity_id: str,
142
+ surface: str,
143
+ payload: dict[str, Any],
144
+ ) -> None:
145
+ """Append ``surface`` to the entity's alias list if it's new.
146
+
147
+ Best-effort: failures here only mean the alias list lags slightly
148
+ behind reality. The canonical ID still points at the right
149
+ entity.
150
+ """
151
+ existing = payload.get("aliases")
152
+ aliases: list[str] = (
153
+ list(existing) if isinstance(existing, list) else []
154
+ )
155
+ if surface in aliases:
156
+ return
157
+ aliases.append(surface)
158
+ # We don't have the original embedding here, so re-embed the
159
+ # canonical form to keep the point's vector stable.
160
+ canonical = str(payload.get("canonical") or surface)
161
+ try:
162
+ vec = self.embedder.embed_one(canonical)
163
+ except Exception: # noqa: BLE001
164
+ _LOG.exception(
165
+ "entity resolver: re-embed for alias update failed (%r)",
166
+ canonical,
167
+ )
168
+ return
169
+ try:
170
+ self.vector.upsert(
171
+ self._collection,
172
+ [
173
+ VectorRecord(
174
+ id=entity_id,
175
+ vector=vec,
176
+ payload={
177
+ "canonical": canonical,
178
+ "aliases": aliases,
179
+ },
180
+ )
181
+ ],
182
+ )
183
+ except Exception: # noqa: BLE001
184
+ _LOG.exception(
185
+ "entity resolver: alias upsert failed for %s", entity_id
186
+ )
@@ -0,0 +1,424 @@
1
+ """SQLite store for extracted user claims with bi-temporal validity.
2
+
3
+ Schema mirrors the ``episodes`` table's idempotent-migration pattern.
4
+ Each claim row carries:
5
+
6
+ * ``valid_at`` — when the user asserted it (prompt timestamp)
7
+ * ``valid_to`` — when the assertion was superseded (NULL = current)
8
+ * ``recorded_at`` — when we ingested it (system clock)
9
+ * ``head_sha`` — git HEAD at extraction time
10
+
11
+ The combination gives bi-temporal queries: "as of commit X, what did the
12
+ user say about Y?" and "what was the user's stated preference for Y at
13
+ time T according to what we knew at time T'?".
14
+
15
+ Contradiction handling: predicates listed in :data:`SINGLE_VALUED_PREDICATES`
16
+ are treated as functional — at most one open ``(subject, predicate)`` per
17
+ session. Upserting a new ``(s, p, o2)`` closes the prior ``(s, p, o1)`` by
18
+ setting its ``valid_to = new.valid_at``.
19
+
20
+ Multi-valued predicates (e.g. ``mentioned``, ``worked-on``) coexist
21
+ without conflict.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sqlite3
27
+ import time
28
+ import uuid
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any, Iterable
32
+
33
+ from ..config import CONFIG
34
+
35
+ # Predicates whose object is functional: at most one currently-valid
36
+ # assertion per (subject, predicate). A second extraction with a
37
+ # different object closes the previous one.
38
+ #
39
+ # Keep this list small and hand-curated — automatic detection would
40
+ # require a richer schema than we want to ask the LLM to produce.
41
+ SINGLE_VALUED_PREDICATES: frozenset[str] = frozenset(
42
+ {
43
+ "prefers",
44
+ "uses", # primary-tool sense: "we use Postgres" → switching closes prior
45
+ "deployed-to",
46
+ "is-located-at",
47
+ "is-a",
48
+ "owns",
49
+ "assigned-to",
50
+ "depends-on",
51
+ }
52
+ )
53
+
54
+
55
+ _BASE_SCHEMA = """
56
+ CREATE TABLE IF NOT EXISTS claims (
57
+ id TEXT PRIMARY KEY,
58
+ subject TEXT NOT NULL,
59
+ predicate TEXT NOT NULL,
60
+ object TEXT NOT NULL,
61
+ polarity INTEGER NOT NULL,
62
+ confidence REAL NOT NULL,
63
+ evidence_span TEXT NOT NULL,
64
+ valid_at REAL NOT NULL,
65
+ valid_to REAL,
66
+ recorded_at REAL NOT NULL,
67
+ head_sha TEXT,
68
+ session_id TEXT,
69
+ source_prompt_id TEXT
70
+ );
71
+ """
72
+
73
+ # Migrations are idempotent. Each statement runs independently; failures
74
+ # from a re-run (duplicate column, already-existing index) are swallowed
75
+ # so an existing DB catches up to the latest schema on open. Columns
76
+ # referenced by an index MUST appear in the migration list before the
77
+ # index that uses them.
78
+ _MIGRATIONS: tuple[str, ...] = (
79
+ "CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims(subject)",
80
+ "CREATE INDEX IF NOT EXISTS idx_claims_predicate ON claims(predicate)",
81
+ "CREATE INDEX IF NOT EXISTS idx_claims_valid_at ON claims(valid_at)",
82
+ "CREATE INDEX IF NOT EXISTS idx_claims_valid_to ON claims(valid_to)",
83
+ "CREATE INDEX IF NOT EXISTS idx_claims_head_sha ON claims(head_sha)",
84
+ "CREATE INDEX IF NOT EXISTS idx_claims_session ON claims(session_id)",
85
+ # Entity-resolution back-references: nullable so legacy rows that
86
+ # were extracted before the resolver shipped keep working.
87
+ "ALTER TABLE claims ADD COLUMN entity_subject_id TEXT",
88
+ "ALTER TABLE claims ADD COLUMN entity_object_id TEXT",
89
+ "CREATE INDEX IF NOT EXISTS idx_claims_entity_subject ON claims(entity_subject_id)",
90
+ "CREATE INDEX IF NOT EXISTS idx_claims_entity_object ON claims(entity_object_id)",
91
+ )
92
+
93
+
94
+ class UpsertResult(str):
95
+ """Result of :meth:`ClaimsStore.upsert`.
96
+
97
+ Subclasses ``str`` so the old contract — ``upsert`` returning a
98
+ claim id — is preserved for callers that just want the id:
99
+
100
+ cid = store.upsert(record) # works as before
101
+ assert isinstance(cid, str) # still true
102
+
103
+ Newer callers (the Qdrant claim indexer) read the extra fields to
104
+ know which prior rows were closed in the same transaction so they
105
+ can flip ``open=false`` on the matching Qdrant points.
106
+ """
107
+
108
+ closed_ids: list[str]
109
+ was_new: bool
110
+
111
+ def __new__(
112
+ cls,
113
+ claim_id: str,
114
+ *,
115
+ closed_ids: list[str] | None = None,
116
+ was_new: bool = True,
117
+ ) -> "UpsertResult":
118
+ inst = super().__new__(cls, claim_id)
119
+ inst.closed_ids = list(closed_ids or [])
120
+ inst.was_new = was_new
121
+ return inst
122
+
123
+ @property
124
+ def claim_id(self) -> str:
125
+ return str(self)
126
+
127
+
128
+ @dataclass
129
+ class ClaimRecord:
130
+ subject: str
131
+ predicate: str
132
+ object: str
133
+ polarity: bool = True
134
+ confidence: float = 1.0
135
+ evidence_span: str = ""
136
+ valid_at: float = field(default_factory=time.time)
137
+ valid_to: float | None = None
138
+ recorded_at: float = field(default_factory=time.time)
139
+ head_sha: str | None = None
140
+ session_id: str | None = None
141
+ source_prompt_id: str | None = None
142
+ # Canonical entity IDs from the Qdrant entity resolver. NULL when
143
+ # resolution was skipped (claims_enabled but resolver disabled, or
144
+ # legacy rows from before the resolver shipped).
145
+ entity_subject_id: str | None = None
146
+ entity_object_id: str | None = None
147
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
148
+
149
+
150
+ class ClaimsStore:
151
+ """SQLite-backed claim store with single-valued predicate contradiction."""
152
+
153
+ def __init__(self, path: Path | None = None) -> None:
154
+ self.path = path or CONFIG.claims_db
155
+ self.path.parent.mkdir(parents=True, exist_ok=True)
156
+ self.conn = sqlite3.connect(self.path)
157
+ self.conn.executescript(_BASE_SCHEMA)
158
+ for stmt in _MIGRATIONS:
159
+ try:
160
+ self.conn.execute(stmt)
161
+ except sqlite3.OperationalError:
162
+ # idempotent migration — already applied
163
+ pass
164
+ self.conn.commit()
165
+
166
+ # -------------------------------------------------------------- write
167
+
168
+ def upsert(self, claim: ClaimRecord) -> UpsertResult:
169
+ """Insert a claim, closing any conflicting prior assertion.
170
+
171
+ Dedupe: an open row with the same (subject, predicate, object,
172
+ polarity) is refreshed in place — its ``recorded_at`` becomes
173
+ ``now``, its ``confidence`` becomes ``max(prev, new)``, and a
174
+ non-empty new ``evidence_span`` overwrites a missing/equal one.
175
+ This prevents bloat when the agent re-asserts the same claim
176
+ across turns or sessions (which happens often with the
177
+ "ACT BEFORE ANSWERING" nudge in the plugins).
178
+
179
+ For single-valued predicates: any open ``(subject, predicate)``
180
+ row with a different ``object`` gets ``valid_to`` set to the
181
+ new claim's ``valid_at``. Polarity flips also close.
182
+
183
+ Returns an :class:`UpsertResult` carrying both the canonical id
184
+ of the (refreshed-or-new) claim AND the ids of any rows that got
185
+ closed by this insertion. Callers maintaining a secondary index
186
+ (the Qdrant claim vector store) need both: insert/refresh the
187
+ canonical id, and flip ``open=false`` on the closed ids.
188
+
189
+ Backwards compatibility: ``UpsertResult`` is a string subclass
190
+ so legacy callers using ``store.upsert(c)`` as the claim id keep
191
+ working (``str(result) == result.claim_id``).
192
+ """
193
+ closed_ids: list[str] = []
194
+ if claim.predicate in SINGLE_VALUED_PREDICATES:
195
+ closed_ids = self._close_conflicting(claim)
196
+
197
+ existing_id = self._find_open_duplicate(claim)
198
+ if existing_id is not None:
199
+ self._refresh_existing(existing_id, claim)
200
+ self.conn.commit()
201
+ return UpsertResult(existing_id, closed_ids=closed_ids, was_new=False)
202
+
203
+ self.conn.execute(
204
+ "INSERT INTO claims("
205
+ "id, subject, predicate, object, polarity, confidence, "
206
+ "evidence_span, valid_at, valid_to, recorded_at, "
207
+ "head_sha, session_id, source_prompt_id, "
208
+ "entity_subject_id, entity_object_id) "
209
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
210
+ (
211
+ claim.id,
212
+ claim.subject,
213
+ claim.predicate,
214
+ claim.object,
215
+ 1 if claim.polarity else 0,
216
+ claim.confidence,
217
+ claim.evidence_span,
218
+ claim.valid_at,
219
+ claim.valid_to,
220
+ claim.recorded_at,
221
+ claim.head_sha,
222
+ claim.session_id,
223
+ claim.source_prompt_id,
224
+ claim.entity_subject_id,
225
+ claim.entity_object_id,
226
+ ),
227
+ )
228
+ self.conn.commit()
229
+ return UpsertResult(claim.id, closed_ids=closed_ids, was_new=True)
230
+
231
+ def _find_open_duplicate(self, claim: ClaimRecord) -> str | None:
232
+ """Return the id of an open row identical on (s,p,o,polarity), or None."""
233
+ row = self.conn.execute(
234
+ "SELECT id FROM claims "
235
+ "WHERE valid_to IS NULL "
236
+ " AND subject = ? AND predicate = ? AND object = ? "
237
+ " AND polarity = ? "
238
+ "LIMIT 1",
239
+ (
240
+ claim.subject,
241
+ claim.predicate,
242
+ claim.object,
243
+ 1 if claim.polarity else 0,
244
+ ),
245
+ ).fetchone()
246
+ return None if row is None else str(row[0])
247
+
248
+ def _refresh_existing(self, claim_id: str, claim: ClaimRecord) -> None:
249
+ """Refresh an existing open dupe with the new claim's metadata.
250
+
251
+ Confidence is monotonic non-decreasing (keep the strongest
252
+ assertion seen). Evidence is overwritten only when the new
253
+ span is non-empty so we never erase a quote with a blank one.
254
+ Session/prompt-id only fill in if previously NULL, preserving
255
+ the first observation's provenance.
256
+ """
257
+ self.conn.execute(
258
+ "UPDATE claims SET "
259
+ " confidence = MAX(confidence, ?), "
260
+ " evidence_span = CASE "
261
+ " WHEN ? <> '' THEN ? "
262
+ " ELSE evidence_span "
263
+ " END, "
264
+ " recorded_at = ?, "
265
+ " head_sha = COALESCE(?, head_sha), "
266
+ " session_id = COALESCE(session_id, ?), "
267
+ " source_prompt_id = COALESCE(source_prompt_id, ?), "
268
+ " entity_subject_id = COALESCE(entity_subject_id, ?), "
269
+ " entity_object_id = COALESCE(entity_object_id, ?) "
270
+ "WHERE id = ?",
271
+ (
272
+ claim.confidence,
273
+ claim.evidence_span,
274
+ claim.evidence_span,
275
+ claim.recorded_at,
276
+ claim.head_sha,
277
+ claim.session_id,
278
+ claim.source_prompt_id,
279
+ claim.entity_subject_id,
280
+ claim.entity_object_id,
281
+ claim_id,
282
+ ),
283
+ )
284
+
285
+ def upsert_many(self, claims: Iterable[ClaimRecord]) -> list[UpsertResult]:
286
+ return [self.upsert(c) for c in claims]
287
+
288
+ def _close_conflicting(self, claim: ClaimRecord) -> list[str]:
289
+ """Close prior open assertions that conflict with the new claim.
290
+
291
+ Conflict := same (subject, predicate) but different object OR
292
+ polarity flip. Scope is global (not per-session) because user
293
+ preferences carry across sessions; restricting to one session
294
+ would defeat the point.
295
+
296
+ Returns the ids of the rows whose ``valid_to`` was just set, so
297
+ a caller maintaining a secondary index can flip their ``open``
298
+ flag in lockstep. Returns ``[]`` when nothing matched.
299
+ """
300
+ rows = self.conn.execute(
301
+ "SELECT id FROM claims "
302
+ "WHERE subject = ? AND predicate = ? "
303
+ " AND valid_to IS NULL "
304
+ " AND (object <> ? OR polarity <> ?)",
305
+ (
306
+ claim.subject,
307
+ claim.predicate,
308
+ claim.object,
309
+ 1 if claim.polarity else 0,
310
+ ),
311
+ ).fetchall()
312
+ closed_ids = [str(r[0]) for r in rows]
313
+ if closed_ids:
314
+ self.conn.execute(
315
+ "UPDATE claims "
316
+ "SET valid_to = ? "
317
+ "WHERE subject = ? AND predicate = ? "
318
+ " AND valid_to IS NULL "
319
+ " AND (object <> ? OR polarity <> ?)",
320
+ (
321
+ claim.valid_at,
322
+ claim.subject,
323
+ claim.predicate,
324
+ claim.object,
325
+ 1 if claim.polarity else 0,
326
+ ),
327
+ )
328
+ return closed_ids
329
+
330
+ # --------------------------------------------------------------- read
331
+
332
+ def current(self, subject: str | None = None) -> list[ClaimRecord]:
333
+ """Return all currently-valid claims (``valid_to IS NULL``)."""
334
+ if subject is None:
335
+ rows = self.conn.execute(
336
+ _SELECT_ALL + " WHERE valid_to IS NULL ORDER BY valid_at DESC"
337
+ ).fetchall()
338
+ else:
339
+ rows = self.conn.execute(
340
+ _SELECT_ALL
341
+ + " WHERE valid_to IS NULL AND subject = ? "
342
+ "ORDER BY valid_at DESC",
343
+ (subject,),
344
+ ).fetchall()
345
+ return [_row_to_claim(r) for r in rows]
346
+
347
+ def as_of(self, when: float, subject: str | None = None) -> list[ClaimRecord]:
348
+ """Bi-temporal point query: claims valid at world-time ``when``.
349
+
350
+ A claim is valid at ``when`` iff
351
+ ``valid_at <= when < (valid_to or +inf)``.
352
+ """
353
+ base = (
354
+ _SELECT_ALL
355
+ + " WHERE valid_at <= ? AND (valid_to IS NULL OR valid_to > ?)"
356
+ )
357
+ if subject is None:
358
+ rows = self.conn.execute(
359
+ base + " ORDER BY valid_at DESC", (when, when)
360
+ ).fetchall()
361
+ else:
362
+ rows = self.conn.execute(
363
+ base + " AND subject = ? ORDER BY valid_at DESC",
364
+ (when, when, subject),
365
+ ).fetchall()
366
+ return [_row_to_claim(r) for r in rows]
367
+
368
+ def by_id(self, claim_id: str) -> ClaimRecord | None:
369
+ row = self.conn.execute(
370
+ _SELECT_ALL + " WHERE id = ?", (claim_id,)
371
+ ).fetchone()
372
+ return _row_to_claim(row) if row else None
373
+
374
+ def by_ids(self, ids: list[str]) -> list[ClaimRecord]:
375
+ """Batch fetch by id list. Preserves caller ordering.
376
+
377
+ Mirrors :meth:`EpisodicStore.by_ids`. Used to hydrate
378
+ ``ClaimRecord`` rows after a Qdrant semantic search returns ids.
379
+ Unknown ids are silently dropped — they may have been pruned
380
+ between the vector hit and this lookup.
381
+ """
382
+ if not ids:
383
+ return []
384
+ placeholders = ",".join("?" for _ in ids)
385
+ rows = self.conn.execute(
386
+ _SELECT_ALL + f" WHERE id IN ({placeholders})", ids
387
+ ).fetchall()
388
+ by_id = {row[0]: row for row in rows}
389
+ return [_row_to_claim(by_id[i]) for i in ids if i in by_id]
390
+
391
+ def count(self) -> int:
392
+ (n,) = self.conn.execute("SELECT COUNT(*) FROM claims").fetchone()
393
+ return int(n)
394
+
395
+ def close(self) -> None:
396
+ self.conn.close()
397
+
398
+
399
+ _SELECT_ALL = (
400
+ "SELECT id, subject, predicate, object, polarity, confidence, "
401
+ "evidence_span, valid_at, valid_to, recorded_at, "
402
+ "head_sha, session_id, source_prompt_id, "
403
+ "entity_subject_id, entity_object_id FROM claims"
404
+ )
405
+
406
+
407
+ def _row_to_claim(row: tuple[Any, ...]) -> ClaimRecord:
408
+ return ClaimRecord(
409
+ id=row[0],
410
+ subject=row[1],
411
+ predicate=row[2],
412
+ object=row[3],
413
+ polarity=bool(row[4]),
414
+ confidence=row[5],
415
+ evidence_span=row[6],
416
+ valid_at=row[7],
417
+ valid_to=row[8],
418
+ recorded_at=row[9],
419
+ head_sha=row[10],
420
+ session_id=row[11],
421
+ source_prompt_id=row[12],
422
+ entity_subject_id=row[13] if len(row) > 13 else None,
423
+ entity_object_id=row[14] if len(row) > 14 else None,
424
+ )