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 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,32 @@
1
+ """User-prompt claim extraction (Graphiti-style).
2
+
3
+ The pipeline turns substantive user prompts into structured
4
+ ``(subject, predicate, object)`` claims with bi-temporal validity so a
5
+ later session can answer "what did the user say about X last Tuesday?"
6
+ without re-reading every prompt.
7
+
8
+ Layout:
9
+ * :mod:`.extractor` — local-LLM extraction (Ollama, gemma2:9b default).
10
+ * :mod:`.store` — SQLite store with bi-temporal columns and a
11
+ single-valued predicate registry for contradiction
12
+ handling.
13
+ """
14
+
15
+ from .extractor import Claim, ClaimExtractor, ExtractionError
16
+ from .indexer import ClaimsIndexer, make_claims_indexer
17
+ from .resolver import EntityRef, EntityResolver
18
+ from .store import ClaimRecord, ClaimsStore, SINGLE_VALUED_PREDICATES, UpsertResult
19
+
20
+ __all__ = [
21
+ "Claim",
22
+ "ClaimExtractor",
23
+ "ClaimRecord",
24
+ "ClaimsIndexer",
25
+ "ClaimsStore",
26
+ "EntityRef",
27
+ "EntityResolver",
28
+ "ExtractionError",
29
+ "SINGLE_VALUED_PREDICATES",
30
+ "UpsertResult",
31
+ "make_claims_indexer",
32
+ ]
@@ -0,0 +1,325 @@
1
+ """Local-LLM claim extractor.
2
+
3
+ Calls an Ollama-served instruct model (gemma2:9b by default) in JSON
4
+ mode and returns a list of :class:`Claim` records. Output is validated
5
+ defensively because LLMs lie:
6
+
7
+ * ``evidence_span`` must be a literal substring of the source prompt.
8
+ Hallucinated triples that paraphrase the input are dropped.
9
+ * ``confidence`` below ``CLAIMS_MIN_CONFIDENCE`` is dropped.
10
+ * Empty / non-string subject or object is dropped.
11
+
12
+ The extractor never raises on a malformed model response — it returns
13
+ an empty list so the caller (an async hook) never blocks the session.
14
+ The only raised exception is :class:`ExtractionError` for hard
15
+ infrastructure failures (Ollama unreachable, model not pulled).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from ..config import CONFIG
28
+
29
+ _LOG = logging.getLogger(__name__)
30
+
31
+
32
+ class ExtractionError(RuntimeError):
33
+ """Raised when the LLM backend itself is unreachable or misconfigured."""
34
+
35
+
36
+ # Closed predicate vocabulary. The system prompt instructs the model to
37
+ # stay inside this set; _coerce enforces it so a noisy generation can't
38
+ # smuggle in free-form predicates that would defeat single-valued
39
+ # contradiction handling downstream.
40
+ _ALLOWED_PREDICATES: frozenset[str] = frozenset(
41
+ {
42
+ "uses",
43
+ "prefers",
44
+ "rejected",
45
+ "wants-to",
46
+ "is-located-at",
47
+ "depends-on",
48
+ "deployed-to",
49
+ "owns",
50
+ "is-a",
51
+ "mentioned",
52
+ "worked-on",
53
+ }
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Claim:
59
+ subject: str
60
+ predicate: str
61
+ object: str
62
+ polarity: bool # True = asserts, False = negates ("does not use X")
63
+ confidence: float
64
+ evidence_span: str
65
+
66
+
67
+ # JSON schema embedded in the prompt. Ollama's structured-output mode
68
+ # uses this verbatim to constrain the decoder. The schema is intentionally
69
+ # narrow — predicates are normalized to kebab-case verbs so downstream
70
+ # resolution doesn't have to disambiguate "uses" / "USES" / "Uses".
71
+ _OUTPUT_SCHEMA = {
72
+ "type": "object",
73
+ "properties": {
74
+ "claims": {
75
+ "type": "array",
76
+ "items": {
77
+ "type": "object",
78
+ "properties": {
79
+ "subject": {"type": "string", "minLength": 1},
80
+ "predicate": {"type": "string", "minLength": 1},
81
+ "object": {"type": "string", "minLength": 1},
82
+ "polarity": {"type": "boolean"},
83
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1},
84
+ "evidence_span": {"type": "string", "minLength": 1},
85
+ },
86
+ "required": [
87
+ "subject",
88
+ "predicate",
89
+ "object",
90
+ "polarity",
91
+ "confidence",
92
+ "evidence_span",
93
+ ],
94
+ },
95
+ }
96
+ },
97
+ "required": ["claims"],
98
+ }
99
+
100
+
101
+ _SYSTEM_PROMPT = """\
102
+ You extract DURABLE factual claims from a software engineer's chat
103
+ message. Durable = the assertion is likely still true in a future
104
+ session, not transient task state.
105
+
106
+ Output JSON only, matching the provided schema. Each claim is a
107
+ (subject, predicate, object) triple plus polarity, confidence, and an
108
+ ``evidence_span`` that is a verbatim substring of the input.
109
+
110
+ Rules:
111
+ - Predicate is kebab-case verb phrase from this closed vocabulary:
112
+ "uses", "prefers", "rejected", "wants-to", "is-located-at",
113
+ "depends-on", "deployed-to", "owns", "is-a", "mentioned",
114
+ "worked-on". Reject any predicate outside this list.
115
+ - Subject and object are short noun phrases lifted from the message;
116
+ normalize case but keep technical identifiers as written.
117
+ - HARD FILTER — skip and emit no claim for:
118
+ * Questions of any kind ("should I…", "why does…", "is X…").
119
+ * Hypotheticals / counterfactuals ("if we used…", "suppose X…").
120
+ * Imperatives directed at YOU the assistant ("fix this", "run X",
121
+ "look at Y") — those are task state, not durable facts.
122
+ * Opinions about third parties or general industry statements.
123
+ * Small talk, acknowledgments, meta-comments about the conversation.
124
+ * Anything that would be obvious from the codebase itself (e.g.
125
+ "this file imports React" — already in the source).
126
+ - Only extract assertions the user is making about their PROJECT, their
127
+ TOOLING choices, their PREFERENCES, OWNERSHIP, or LOCATIONS — facts
128
+ worth recalling next week.
129
+ - ``confidence`` ∈ [0,1] reflects how certain you are this is a
130
+ durable assertion (not a question, speculation, or task state).
131
+ Below 0.7 → don't emit at all.
132
+ - Be CONSERVATIVE. If in doubt, emit nothing. Empty output is the
133
+ correct answer for most messages.
134
+ - If nothing qualifies, return {"claims": []}.
135
+
136
+ Examples:
137
+
138
+ INPUT: "we use Qdrant for vectors and FalkorDB for the graph"
139
+ OUTPUT: {"claims": [
140
+ {"subject":"project","predicate":"uses","object":"Qdrant",
141
+ "polarity":true,"confidence":0.95,"evidence_span":"use Qdrant for vectors"},
142
+ {"subject":"project","predicate":"uses","object":"FalkorDB",
143
+ "polarity":true,"confidence":0.95,"evidence_span":"FalkorDB for the graph"}
144
+ ]}
145
+
146
+ INPUT: "should I use Redis here?"
147
+ OUTPUT: {"claims": []}
148
+
149
+ INPUT: "fix the bug in auth.py"
150
+ OUTPUT: {"claims": []}
151
+
152
+ INPUT: "look at this file and tell me what it does"
153
+ OUTPUT: {"claims": []}
154
+
155
+ INPUT: "I don't want to ship dark mode"
156
+ OUTPUT: {"claims": [
157
+ {"subject":"user","predicate":"rejected","object":"dark mode",
158
+ "polarity":true,"confidence":0.9,"evidence_span":"don't want to ship dark mode"}
159
+ ]}
160
+
161
+ INPUT: "stop summarizing at the end of every response"
162
+ OUTPUT: {"claims": [
163
+ {"subject":"user","predicate":"prefers","object":"no end-of-turn summaries",
164
+ "polarity":true,"confidence":0.9,
165
+ "evidence_span":"stop summarizing at the end of every response"}
166
+ ]}
167
+
168
+ INPUT: "the billing service lives in apps/api/billing"
169
+ OUTPUT: {"claims": [
170
+ {"subject":"billing service","predicate":"is-located-at",
171
+ "object":"apps/api/billing","polarity":true,"confidence":0.95,
172
+ "evidence_span":"billing service lives in apps/api/billing"}
173
+ ]}
174
+ """
175
+
176
+
177
+ class ClaimExtractor:
178
+ """Thin sync wrapper over Ollama's /api/chat with JSON-mode output.
179
+
180
+ Construction is cheap; the HTTP client is created lazily so import
181
+ of this module never touches the network.
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ url: str | None = None,
187
+ model: str | None = None,
188
+ timeout: float | None = None,
189
+ min_confidence: float | None = None,
190
+ ) -> None:
191
+ self.url = (url or CONFIG.ollama_url).rstrip("/")
192
+ self.model = model or CONFIG.claims_llm_model
193
+ self.timeout = timeout if timeout is not None else CONFIG.claims_llm_timeout
194
+ self.min_confidence = (
195
+ min_confidence
196
+ if min_confidence is not None
197
+ else CONFIG.claims_min_confidence
198
+ )
199
+ self._client: httpx.Client | None = None
200
+
201
+ # ------------------------------------------------------------------ http
202
+
203
+ def _http(self) -> httpx.Client:
204
+ if self._client is None:
205
+ self._client = httpx.Client(timeout=self.timeout)
206
+ return self._client
207
+
208
+ def close(self) -> None:
209
+ if self._client is not None:
210
+ self._client.close()
211
+ self._client = None
212
+
213
+ def __enter__(self) -> ClaimExtractor:
214
+ return self
215
+
216
+ def __exit__(self, *exc: object) -> None:
217
+ self.close()
218
+
219
+ # ----------------------------------------------------------------- extract
220
+
221
+ def extract(self, prompt: str) -> list[Claim]:
222
+ """Run extraction over a single user prompt.
223
+
224
+ Returns the validated, deduplicated, confidence-filtered list.
225
+ Never raises on a malformed model response — returns ``[]``.
226
+ Raises :class:`ExtractionError` only on transport-level failures.
227
+ """
228
+ prompt = prompt.strip()
229
+ if not prompt:
230
+ return []
231
+
232
+ try:
233
+ raw = self._call_ollama(prompt)
234
+ except httpx.HTTPError as exc:
235
+ raise ExtractionError(f"Ollama call failed: {exc}") from exc
236
+
237
+ return self._parse_and_validate(raw, prompt)
238
+
239
+ # ------------------------------------------------------------ internals
240
+
241
+ def _call_ollama(self, prompt: str) -> str:
242
+ payload: dict[str, Any] = {
243
+ "model": self.model,
244
+ "format": _OUTPUT_SCHEMA,
245
+ "stream": False,
246
+ "options": {"temperature": 0.0},
247
+ "messages": [
248
+ {"role": "system", "content": _SYSTEM_PROMPT},
249
+ {"role": "user", "content": prompt},
250
+ ],
251
+ }
252
+ res = self._http().post(f"{self.url}/api/chat", json=payload)
253
+ res.raise_for_status()
254
+ data = res.json()
255
+ msg = data.get("message") or {}
256
+ return str(msg.get("content") or "")
257
+
258
+ def _parse_and_validate(self, raw: str, source_prompt: str) -> list[Claim]:
259
+ if not raw.strip():
260
+ return []
261
+ try:
262
+ parsed = json.loads(raw)
263
+ except json.JSONDecodeError:
264
+ _LOG.warning("claim extractor: non-JSON response, dropping")
265
+ return []
266
+
267
+ items = parsed.get("claims")
268
+ if not isinstance(items, list):
269
+ return []
270
+
271
+ out: list[Claim] = []
272
+ seen: set[tuple[str, str, str, bool]] = set()
273
+ for item in items:
274
+ claim = self._coerce(item, source_prompt)
275
+ if claim is None:
276
+ continue
277
+ key = (
278
+ claim.subject.lower(),
279
+ claim.predicate.lower(),
280
+ claim.object.lower(),
281
+ claim.polarity,
282
+ )
283
+ if key in seen:
284
+ continue
285
+ seen.add(key)
286
+ out.append(claim)
287
+ return out
288
+
289
+ def _coerce(self, item: Any, source_prompt: str) -> Claim | None:
290
+ if not isinstance(item, dict):
291
+ return None
292
+ try:
293
+ subject = str(item["subject"]).strip()
294
+ predicate = str(item["predicate"]).strip().lower().replace(" ", "-")
295
+ obj = str(item["object"]).strip()
296
+ polarity = bool(item["polarity"])
297
+ confidence = float(item["confidence"])
298
+ evidence = str(item["evidence_span"]).strip()
299
+ except (KeyError, TypeError, ValueError):
300
+ return None
301
+
302
+ if not subject or not predicate or not obj or not evidence:
303
+ return None
304
+ if predicate not in _ALLOWED_PREDICATES:
305
+ _LOG.debug(
306
+ "claim extractor: dropping out-of-vocab predicate %r", predicate
307
+ )
308
+ return None
309
+ if confidence < self.min_confidence:
310
+ return None
311
+ # Anti-hallucination: evidence must be present in the source.
312
+ if evidence.lower() not in source_prompt.lower():
313
+ _LOG.debug(
314
+ "claim extractor: dropping hallucinated span %r", evidence
315
+ )
316
+ return None
317
+
318
+ return Claim(
319
+ subject=subject,
320
+ predicate=predicate,
321
+ object=obj,
322
+ polarity=polarity,
323
+ confidence=confidence,
324
+ evidence_span=evidence,
325
+ )
@@ -0,0 +1,258 @@
1
+ """Qdrant-backed semantic index over user claims.
2
+
3
+ ``ClaimsStore`` (SQLite) is the source of truth for the bi-temporal
4
+ claim history. This module layers a vector index on top so retrieval
5
+ can match claims semantically — "we use Postgres" surfaces for a query
6
+ about "DB choice" — instead of relying on the token-overlap heuristic
7
+ in :func:`code_memory.orchestrator.retrieve._rank_claims`.
8
+
9
+ Design choices:
10
+
11
+ * **Keep + flag, not delete.** When a claim is superseded
12
+ (``valid_to`` set), the Qdrant point stays and gets
13
+ ``payload.open = False``. Default retrieval filters ``open=true``;
14
+ this keeps the door open for bi-temporal ``as_of`` semantic queries
15
+ later without re-embedding the corpus.
16
+ * **Embed triple + evidence.** The evidence span carries the user's
17
+ raw phrasing, which is where synonym recall lives ("DB" vs
18
+ "Postgres"). The triple alone is too terse to embed well.
19
+ * **Lazy backfill.** First access detects ``len(qdrant_claims) == 0``
20
+ while ``claims.db`` is non-empty and re-embeds every row. Idempotent:
21
+ re-runs are cheap because the embedder caches per-text.
22
+ * **Token-overlap fallback.** If the embedder or Qdrant is unavailable
23
+ the caller falls back to ``_rank_claims`` (see ``retrieve.py``). The
24
+ indexer raises only when the operation is fundamentally
25
+ side-effecting (upsert), not when reads fail.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from typing import Any, Sequence
32
+
33
+ from ..config import CONFIG, Config, detect_project_slug
34
+ from ..embed import Embedder, HybridVec, get_embedder
35
+ from ..vector import QdrantStore, VectorHit, VectorRecord
36
+ from .store import ClaimRecord, ClaimsStore, UpsertResult
37
+
38
+
39
+ @dataclass
40
+ class ClaimsIndexer:
41
+ """SQLite + Qdrant facade for claim writes and semantic reads.
42
+
43
+ Holds references to the persistent components; callers re-use one
44
+ indexer per project across many upserts. Not thread-safe — the
45
+ underlying SQLite connection isn't either.
46
+ """
47
+
48
+ store: ClaimsStore
49
+ vector: QdrantStore
50
+ embedder: Embedder
51
+ collection: str
52
+ _backfilled: bool = False
53
+
54
+ # ------------------------------------------------------------- write
55
+
56
+ def upsert(self, claim: ClaimRecord) -> UpsertResult:
57
+ """Persist ``claim`` to SQLite + Qdrant atomically (SQLite first).
58
+
59
+ SQLite write is authoritative — if Qdrant raises after the
60
+ SQLite commit, the row still lands and a later
61
+ :meth:`ensure_backfilled` call (or the next ``retrieve``) will
62
+ re-embed it. We prefer "Qdrant temporarily behind" over "lose
63
+ the claim entirely."
64
+ """
65
+ self.ensure_backfilled()
66
+ result = self.store.upsert(claim)
67
+
68
+ # Close path: any predecessor rows closed by this insert get
69
+ # their ``open`` payload flipped. Cheap: no re-embed.
70
+ if result.closed_ids:
71
+ self.vector.set_payload(
72
+ self.collection,
73
+ result.closed_ids,
74
+ {"open": False},
75
+ )
76
+
77
+ if result.was_new:
78
+ self._embed_and_upsert(result.claim_id, claim)
79
+ else:
80
+ # Existing-row refresh: triple unchanged, but confidence and
81
+ # ``recorded_at`` may have moved. Update payload so rerank
82
+ # sees the new score without paying for an embed.
83
+ self.vector.set_payload(
84
+ self.collection,
85
+ [result.claim_id],
86
+ _payload_for(claim, open_=True),
87
+ )
88
+ return result
89
+
90
+ def upsert_many(self, claims: Sequence[ClaimRecord]) -> list[UpsertResult]:
91
+ return [self.upsert(c) for c in claims]
92
+
93
+ # -------------------------------------------------------------- read
94
+
95
+ def search(
96
+ self,
97
+ query_vec: HybridVec,
98
+ top_k: int = 5,
99
+ *,
100
+ include_closed: bool = False,
101
+ ) -> list[VectorHit]:
102
+ """Semantic top-k over claim points.
103
+
104
+ Default filter is ``open=true`` so superseded claims don't leak
105
+ into the orientation context. ``include_closed`` opens the door
106
+ for future bi-temporal point-in-time queries (see the design
107
+ doc note in :mod:`code_memory.orchestrator.retrieve`).
108
+
109
+ Returns an empty list (not an error) when the collection is
110
+ missing — that's the "claims-disabled project" path and the
111
+ caller should fall back to token-overlap silently.
112
+ """
113
+ if self.vector._inspect_collection(self.collection) == "missing":
114
+ return []
115
+ filt: dict[str, Any] | None = None if include_closed else {"open": True}
116
+ try:
117
+ return self.vector.search(
118
+ self.collection,
119
+ query_vec,
120
+ top_k=top_k,
121
+ filt=filt,
122
+ mode="dense",
123
+ )
124
+ except Exception: # noqa: BLE001
125
+ # Vector backend hiccup — return empty so the orchestrator
126
+ # falls through to the SQLite token-overlap fallback rather
127
+ # than dropping claims from the context pack entirely.
128
+ return []
129
+
130
+ # --------------------------------------------------------- backfill
131
+
132
+ def ensure_backfilled(self) -> int:
133
+ """Embed every claim row missing from Qdrant. Idempotent.
134
+
135
+ Runs once per indexer instance. Re-creates the collection if it
136
+ was missing. Returns the count of rows embedded (``0`` when
137
+ already in sync). Cheap on warm runs — the embedder cache hits
138
+ for previously-seen triples.
139
+
140
+ We compare row counts as a soft sync check, not point IDs. If
141
+ SQLite has 42 rows and Qdrant has 42 points we trust they're
142
+ the same set; drift detection would need a per-id scan and we
143
+ don't currently need it.
144
+ """
145
+ if self._backfilled:
146
+ return 0
147
+ self.vector.ensure_collection(self.collection)
148
+ sqlite_count = self.store.count()
149
+ if sqlite_count == 0:
150
+ self._backfilled = True
151
+ return 0
152
+ qdrant_count = self.vector.count(self.collection)
153
+ if qdrant_count >= sqlite_count:
154
+ self._backfilled = True
155
+ return 0
156
+ # Backfill all rows (open + closed) so bi-temporal queries work
157
+ # later. ``current()`` returns only open rows, so use a wider
158
+ # accessor.
159
+ rows = self._all_rows()
160
+ records: list[VectorRecord] = []
161
+ for claim in rows:
162
+ hv = self.embedder.embed_one(_text_for(claim))
163
+ records.append(
164
+ VectorRecord(
165
+ id=claim.id,
166
+ vector=hv,
167
+ payload=_payload_for(claim, open_=claim.valid_to is None),
168
+ )
169
+ )
170
+ if records:
171
+ self.vector.upsert(self.collection, records)
172
+ self._backfilled = True
173
+ return len(records)
174
+
175
+ # ------------------------------------------------------------ helpers
176
+
177
+ def _embed_and_upsert(self, claim_id: str, claim: ClaimRecord) -> None:
178
+ hv = self.embedder.embed_one(_text_for(claim))
179
+ self.vector.upsert(
180
+ self.collection,
181
+ [
182
+ VectorRecord(
183
+ id=claim_id,
184
+ vector=hv,
185
+ payload=_payload_for(claim, open_=True),
186
+ )
187
+ ],
188
+ )
189
+
190
+ def _all_rows(self) -> list[ClaimRecord]:
191
+ """Every row, open or closed. Used for backfill only."""
192
+ rows = self.store.conn.execute(
193
+ "SELECT id, subject, predicate, object, polarity, confidence, "
194
+ "evidence_span, valid_at, valid_to, recorded_at, "
195
+ "head_sha, session_id, source_prompt_id, "
196
+ "entity_subject_id, entity_object_id FROM claims"
197
+ ).fetchall()
198
+ # Reuse the row->record decoder.
199
+ from .store import _row_to_claim
200
+ return [_row_to_claim(r) for r in rows]
201
+
202
+
203
+ def _text_for(claim: ClaimRecord) -> str:
204
+ """Composite text used as the embed input for a claim.
205
+
206
+ ``subject {predicate} object`` is the canonical triple. The
207
+ evidence span — the verbatim user phrasing — gets appended so the
208
+ embedder also sees the natural-language vocabulary the user used
209
+ when asserting the claim. That's where synonym recall comes from
210
+ (e.g. "DB" in evidence aligns with "Postgres" in object).
211
+ """
212
+ polarity = "" if claim.polarity else "not "
213
+ head = f"{claim.subject} {polarity}{claim.predicate} {claim.object}".strip()
214
+ if claim.evidence_span:
215
+ return f"{head}\n\n{claim.evidence_span}"
216
+ return head
217
+
218
+
219
+ def _payload_for(claim: ClaimRecord, *, open_: bool) -> dict[str, Any]:
220
+ """Payload stored alongside each Qdrant point.
221
+
222
+ Carries just enough metadata for reranking (confidence, recency
223
+ via valid_at) and filtering (open). Anything else stays in SQLite.
224
+ """
225
+ return {
226
+ "open": open_,
227
+ "subject": claim.subject,
228
+ "predicate": claim.predicate,
229
+ "object": claim.object,
230
+ "polarity": claim.polarity,
231
+ "confidence": claim.confidence,
232
+ "valid_at": claim.valid_at,
233
+ "head_sha": claim.head_sha,
234
+ }
235
+
236
+
237
+ def make_claims_indexer(
238
+ project: str | None = None,
239
+ *,
240
+ cfg: Config | None = None,
241
+ embedder: Embedder | None = None,
242
+ vector: QdrantStore | None = None,
243
+ store: ClaimsStore | None = None,
244
+ ) -> ClaimsIndexer:
245
+ """Construct a fully wired :class:`ClaimsIndexer` for ``project``.
246
+
247
+ All deps are optional so tests can inject fakes. Production callers
248
+ typically pass nothing and get the configured embedder + Qdrant
249
+ client + per-project SQLite path.
250
+ """
251
+ slug = project or detect_project_slug()
252
+ config = cfg or CONFIG.for_project(slug)
253
+ return ClaimsIndexer(
254
+ store=store or ClaimsStore(path=config.claims_db),
255
+ vector=vector or QdrantStore(),
256
+ embedder=embedder or get_embedder(),
257
+ collection=config.qdrant_claims,
258
+ )