superlocalmemory 3.6.2 → 3.6.4

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.
@@ -1,4 +1,16 @@
1
- """openai_surface.py — OpenAI /v1/chat/completions and /v1/embeddings."""
1
+ """openai_surface.py — OpenAI /v1/chat/completions and /v1/embeddings.
2
+
3
+ BUG-FIXES (v3.6.3):
4
+ - Streaming path previously bypassed cache AND had _safe_compress not imported
5
+ (NameError on any non-streaming compressed call). Same set of bugs as
6
+ anthropic_surface.py pre-v3.6.3. Fixed with the same pattern:
7
+ cache check → _stream_and_cache_forward → post-stream store.
8
+ - _safe_compress added to imports (was missing; NameError on compress path).
9
+ - Streaming cache: accumulate SSE, parse to JSON, store so future identical
10
+ calls are served from cache. OpenAI SSE format differs from Anthropic's
11
+ so a dedicated _parse_openai_sse_to_json / _openai_sse_from_cached helper
12
+ is used.
13
+ """
2
14
 
3
15
  from __future__ import annotations
4
16
 
@@ -6,7 +18,7 @@ import json
6
18
  import logging
7
19
 
8
20
  from fastapi.requests import Request
9
- from fastapi.responses import Response
21
+ from fastapi.responses import Response, StreamingResponse
10
22
 
11
23
  from superlocalmemory.optimize.proxy._helpers import (
12
24
  _OPENAI_FORWARD_HEADERS,
@@ -18,6 +30,8 @@ from superlocalmemory.optimize.proxy._helpers import (
18
30
  _safe_cache_check,
19
31
  _safe_cache_hit_callbacks,
20
32
  _safe_cache_store,
33
+ _safe_compress,
34
+ _stream_and_cache_forward,
21
35
  _stream_forward,
22
36
  )
23
37
  from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
@@ -27,6 +41,269 @@ logger = logging.getLogger("slm.optimize.proxy.openai")
27
41
  _UPSTREAM_BASE = "https://api.openai.com"
28
42
 
29
43
 
44
+ # ---------------------------------------------------------------------------
45
+ # OpenAI SSE helpers (streaming cache hit/store)
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _parse_openai_sse_to_json(sse_bytes: bytes) -> bytes | None:
49
+ """Parse an accumulated OpenAI SSE stream into a single chat.completion JSON.
50
+
51
+ OpenAI streaming format:
52
+ data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[
53
+ {"index":0,"delta":{"role":"assistant","content":"Hello"},...}]}
54
+ ...
55
+ data: [DONE]
56
+
57
+ Assembles into the non-streaming chat.completion format so it can be stored
58
+ in the cache and replayed as SSE on the next identical call.
59
+
60
+ BUG-FIX (v3.6.4): Previously returned None for responses containing
61
+ tool_calls, meaning OpenAI-compatible tool-bearing clients (Codex CLI,
62
+ Antigravity) were NEVER cached. Now accumulates tool_calls by index and
63
+ includes them in the stored JSON so _openai_sse_from_cached_json can
64
+ replay them correctly.
65
+
66
+ Returns None only for incomplete streams (no [DONE], missing id).
67
+ """
68
+ completion_id = ""
69
+ model = ""
70
+ created = 0
71
+ text_acc: dict[int, str] = {} # choice_index → accumulated content
72
+ finish_reasons: dict[int, str] = {}
73
+ # tool_calls_acc[choice_idx][tc_idx] = {id, type, function: {name, args_parts}}
74
+ tool_calls_acc: dict[int, dict[int, dict]] = {}
75
+ prompt_tokens = 0
76
+ completion_tokens = 0
77
+ done_seen = False
78
+
79
+ for raw_line in sse_bytes.decode("utf-8", errors="replace").split("\n"):
80
+ line = raw_line.rstrip("\r")
81
+ if not line.startswith("data: "):
82
+ continue
83
+ data_str = line[6:].strip()
84
+ if data_str == "[DONE]":
85
+ done_seen = True
86
+ continue
87
+ try:
88
+ chunk = json.loads(data_str)
89
+ except json.JSONDecodeError:
90
+ continue
91
+
92
+ if not completion_id:
93
+ completion_id = chunk.get("id", "")
94
+ if not model:
95
+ model = chunk.get("model", "")
96
+ if not created:
97
+ created = chunk.get("created", 0)
98
+
99
+ for choice in chunk.get("choices", []):
100
+ idx = choice.get("index", 0)
101
+ delta = choice.get("delta", {})
102
+
103
+ content = delta.get("content")
104
+ if content:
105
+ text_acc[idx] = text_acc.get(idx, "") + content
106
+
107
+ for tc in delta.get("tool_calls", []):
108
+ tc_idx = tc.get("index", 0)
109
+ if idx not in tool_calls_acc:
110
+ tool_calls_acc[idx] = {}
111
+ if tc_idx not in tool_calls_acc[idx]:
112
+ tool_calls_acc[idx][tc_idx] = {
113
+ "id": "",
114
+ "type": "function",
115
+ "function": {"name": "", "arguments_parts": []},
116
+ }
117
+ entry = tool_calls_acc[idx][tc_idx]
118
+ if tc.get("id"):
119
+ entry["id"] = tc["id"]
120
+ if tc.get("type"):
121
+ entry["type"] = tc["type"]
122
+ fn = tc.get("function", {})
123
+ if fn.get("name"):
124
+ entry["function"]["name"] = fn["name"]
125
+ if fn.get("arguments") is not None:
126
+ entry["function"]["arguments_parts"].append(fn["arguments"])
127
+
128
+ fr = choice.get("finish_reason")
129
+ if fr:
130
+ finish_reasons[idx] = fr
131
+
132
+ usage = chunk.get("usage") or {}
133
+ if usage:
134
+ prompt_tokens = usage.get("prompt_tokens", prompt_tokens)
135
+ completion_tokens = usage.get("completion_tokens", completion_tokens)
136
+
137
+ if not done_seen or not completion_id:
138
+ return None
139
+
140
+ all_choice_indices = sorted(set(list(text_acc.keys()) + list(tool_calls_acc.keys())))
141
+ if not all_choice_indices:
142
+ return None
143
+
144
+ choices = []
145
+ for i in all_choice_indices:
146
+ finish_reason = finish_reasons.get(i, "stop")
147
+ message: dict = {"role": "assistant"}
148
+
149
+ if i in tool_calls_acc:
150
+ tool_calls = [
151
+ {
152
+ "id": tool_calls_acc[i][ti]["id"],
153
+ "type": tool_calls_acc[i][ti].get("type", "function"),
154
+ "function": {
155
+ "name": tool_calls_acc[i][ti]["function"]["name"],
156
+ "arguments": "".join(
157
+ tool_calls_acc[i][ti]["function"]["arguments_parts"]
158
+ ),
159
+ },
160
+ }
161
+ for ti in sorted(tool_calls_acc[i].keys())
162
+ ]
163
+ message["content"] = None
164
+ message["tool_calls"] = tool_calls
165
+ else:
166
+ message["content"] = text_acc.get(i, "")
167
+
168
+ choices.append({
169
+ "index": i,
170
+ "message": message,
171
+ "logprobs": None,
172
+ "finish_reason": finish_reason,
173
+ })
174
+
175
+ result = {
176
+ "id": completion_id,
177
+ "object": "chat.completion",
178
+ "created": created,
179
+ "model": model,
180
+ "choices": choices,
181
+ "usage": {
182
+ "prompt_tokens": prompt_tokens,
183
+ "completion_tokens": completion_tokens,
184
+ "total_tokens": prompt_tokens + completion_tokens,
185
+ },
186
+ }
187
+ return json.dumps(result, separators=(",", ":")).encode("utf-8")
188
+
189
+
190
+ def _openai_sse_from_cached_json(cached_bytes: bytes) -> "StreamingResponse | None":
191
+ """Convert a stored chat.completion JSON back to an OpenAI SSE stream.
192
+
193
+ Used when an identical streaming request hits the cache — we replay the
194
+ stored JSON as the SSE events the OpenAI API would have emitted, preserving
195
+ the streaming contract with the client (e.g. Codex CLI, Antigravity).
196
+
197
+ BUG-FIX (v3.6.4): Handles tool_calls in the cached message, replaying
198
+ them as proper OpenAI SSE delta events with index/id/name/arguments chunks.
199
+
200
+ Returns None if the bytes are not a parseable chat.completion object.
201
+ """
202
+ try:
203
+ resp = json.loads(cached_bytes)
204
+ except (json.JSONDecodeError, ValueError):
205
+ return None
206
+
207
+ if resp.get("object") != "chat.completion":
208
+ return None
209
+
210
+ async def _generate():
211
+ completion_id = resp.get("id", "")
212
+ model = resp.get("model", "")
213
+ created = resp.get("created", 0)
214
+
215
+ for choice in resp.get("choices", []):
216
+ idx = choice.get("index", 0)
217
+ message = choice.get("message", {})
218
+ tool_calls = message.get("tool_calls")
219
+ content = message.get("content") or ""
220
+ finish_reason = choice.get("finish_reason", "stop")
221
+
222
+ if tool_calls:
223
+ # First chunk: role + tool_call headers (id, type, name, empty args)
224
+ first_delta = {
225
+ "role": "assistant",
226
+ "content": None,
227
+ "tool_calls": [
228
+ {
229
+ "index": ti,
230
+ "id": tc["id"],
231
+ "type": tc.get("type", "function"),
232
+ "function": {
233
+ "name": tc["function"]["name"],
234
+ "arguments": "",
235
+ },
236
+ }
237
+ for ti, tc in enumerate(tool_calls)
238
+ ],
239
+ }
240
+ first_chunk = {
241
+ "id": completion_id, "object": "chat.completion.chunk",
242
+ "created": created, "model": model,
243
+ "choices": [{"index": idx, "delta": first_delta, "finish_reason": None}],
244
+ }
245
+ yield f"data: {json.dumps(first_chunk)}\n\n".encode()
246
+
247
+ # Argument chunks per tool call (50-char pieces)
248
+ chunk_size = 50
249
+ for ti, tc in enumerate(tool_calls):
250
+ args = tc.get("function", {}).get("arguments", "")
251
+ for start in range(0, max(len(args), 1), chunk_size):
252
+ piece = args[start: start + chunk_size]
253
+ arg_chunk = {
254
+ "id": completion_id, "object": "chat.completion.chunk",
255
+ "created": created, "model": model,
256
+ "choices": [{
257
+ "index": idx,
258
+ "delta": {"tool_calls": [{"index": ti, "function": {"arguments": piece}}]},
259
+ "finish_reason": None,
260
+ }],
261
+ }
262
+ yield f"data: {json.dumps(arg_chunk)}\n\n".encode()
263
+
264
+ # Finish chunk
265
+ finish_chunk = {
266
+ "id": completion_id, "object": "chat.completion.chunk",
267
+ "created": created, "model": model,
268
+ "choices": [{"index": idx, "delta": {}, "finish_reason": finish_reason}],
269
+ }
270
+ yield f"data: {json.dumps(finish_chunk)}\n\n".encode()
271
+
272
+ else:
273
+ # Text response replay
274
+ role_chunk = {
275
+ "id": completion_id, "object": "chat.completion.chunk",
276
+ "created": created, "model": model,
277
+ "choices": [{"index": idx, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
278
+ }
279
+ yield f"data: {json.dumps(role_chunk)}\n\n".encode()
280
+
281
+ chunk_size = 100
282
+ for start in range(0, max(len(content), 1), chunk_size):
283
+ piece = content[start: start + chunk_size]
284
+ content_chunk = {
285
+ "id": completion_id, "object": "chat.completion.chunk",
286
+ "created": created, "model": model,
287
+ "choices": [{"index": idx, "delta": {"content": piece}, "finish_reason": None}],
288
+ }
289
+ yield f"data: {json.dumps(content_chunk)}\n\n".encode()
290
+
291
+ finish_chunk = {
292
+ "id": completion_id, "object": "chat.completion.chunk",
293
+ "created": created, "model": model,
294
+ "choices": [{"index": idx, "delta": {}, "finish_reason": finish_reason}],
295
+ }
296
+ yield f"data: {json.dumps(finish_chunk)}\n\n".encode()
297
+
298
+ yield b"data: [DONE]\n\n"
299
+
300
+ return StreamingResponse(_generate(), media_type="text/event-stream")
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Route handlers
305
+ # ---------------------------------------------------------------------------
306
+
30
307
  async def handle_chat_completions(proxy: object, request: Request) -> Response:
31
308
  request_id = await proxy.next_request_id()
32
309
  upstream_url = f"{_UPSTREAM_BASE}/v1/chat/completions"
@@ -47,14 +324,60 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
47
324
  )
48
325
 
49
326
  if stream:
327
+ # BUG-FIX (v3.6.3): streaming path previously bypassed cache
328
+ # entirely — same bug as anthropic_surface.py pre-fix. OpenAI
329
+ # clients (Codex CLI, Antigravity, openai-python) use stream=True
330
+ # by default, so savings were permanently 0.
331
+
332
+ # 1. Cache check
333
+ if proxy.hooks.cache:
334
+ cache_result = await _safe_cache_check(proxy.hooks, ctx)
335
+ if cache_result and cache_result.hit and cache_result.data:
336
+ logger.debug(
337
+ "[%s] OpenAI streaming cache HIT key=%s",
338
+ request_id, cache_result.cache_key,
339
+ )
340
+ await _safe_cache_hit_callbacks(
341
+ proxy.hooks, ctx, cache_result.data, tokens_saved=0
342
+ )
343
+ sse_resp = _openai_sse_from_cached_json(cache_result.data)
344
+ if sse_resp is not None:
345
+ return sse_resp
346
+
347
+ # 2. Compression on request body
348
+ outbound_bytes = body_bytes
349
+ if proxy.hooks.compress:
350
+ compress_result = await _safe_compress(proxy.hooks, ctx)
351
+ if compress_result.body_bytes != body_bytes:
352
+ outbound_bytes = compress_result.body_bytes
353
+
50
354
  fwd_headers = _build_forward_headers(request, _OPENAI_FORWARD_HEADERS)
51
- fwd_headers["content-length"] = str(len(body_bytes))
52
- return await _stream_forward(
53
- proxy, request_id, fwd_headers, body_bytes, upstream_url
355
+ fwd_headers["content-length"] = str(len(outbound_bytes))
356
+
357
+ # 3. Stream + accumulate for cache store
358
+ store_callback = None
359
+ if proxy.hooks.cache:
360
+ _hooks = proxy.hooks
361
+ _ctx = ctx
362
+ async def _store_from_openai_sse(sse_bytes: bytes) -> None:
363
+ parsed = _parse_openai_sse_to_json(sse_bytes)
364
+ if parsed is None:
365
+ return
366
+ prov = ProviderResponse(
367
+ modified=False, body={}, body_bytes=parsed,
368
+ tokens_before=0, tokens_after=0, strategy="none",
369
+ )
370
+ await _safe_cache_store(_hooks, _ctx, prov)
371
+ store_callback = _store_from_openai_sse
372
+
373
+ return await _stream_and_cache_forward(
374
+ proxy, request_id, fwd_headers, outbound_bytes, upstream_url,
375
+ on_complete=store_callback,
54
376
  )
55
377
 
378
+ # --- Non-streaming path ---
56
379
  cache_result = None
57
- if not has_tools and proxy.hooks.cache:
380
+ if proxy.hooks.cache:
58
381
  cache_result = await _safe_cache_check(proxy.hooks, ctx)
59
382
  if cache_result.hit and cache_result.data:
60
383
  await _safe_cache_hit_callbacks(
@@ -82,7 +405,6 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
82
405
 
83
406
  if (
84
407
  upstream_resp.status_code == 200
85
- and not has_tools
86
408
  and proxy.hooks.cache
87
409
  and cache_result is not None
88
410
  and cache_result.cache_key
@@ -17,7 +17,7 @@ from superlocalmemory.optimize.proxy.lifecycle import HookChain
17
17
 
18
18
  logger = logging.getLogger("slm.optimize.proxy")
19
19
 
20
- _PROXY_VERSION = "3.6.0"
20
+ _PROXY_VERSION = "3.6.3"
21
21
  _REQUEST_TIMEOUT_S = 300.0
22
22
  _CONNECT_TIMEOUT_S = 10.0
23
23
  _MAX_CONNECTIONS = 100
@@ -145,7 +145,14 @@ def _load_hooks(config: OptimizeConfig) -> HookChain:
145
145
  )
146
146
 
147
147
  if config.compress_enabled:
148
- # Compress is Phase 2; not wired in P1. P1 keeps the slot for the seam.
149
- pass
148
+ try:
149
+ from superlocalmemory.optimize.compress.router import CompressRouter
150
+ from superlocalmemory.optimize.metrics.counters import MetricsCollector
151
+ compress_hook = CompressRouter.get_instance()
152
+ compress_hook.set_metrics(MetricsCollector.get_instance())
153
+ except Exception as exc:
154
+ logger.warning(
155
+ "compress hook load failed (proxy continues without compress): %s", exc
156
+ )
150
157
 
151
158
  return HookChain(cache=cache_hook, compress=compress_hook)
@@ -182,16 +182,26 @@ class RetrievalEngine:
182
182
  # V3.3.19: Only bridge.discover() (86ms). Removed bridge.spreading_activation()
183
183
  # which did per-node SQL queries across 254K edges → 78s latency.
184
184
  # The SYNAPSE SA channel already provides proper SA with in-memory caching.
185
+ # recall-retrieval-01: O(1) membership/score lookups instead of repeated
186
+ # O(N) `any(...)`/`next(...)` scans inside the bridge + scene loops
187
+ # (was O(N^2) per recall, ~400 ms on large sessions). Kept in sync as
188
+ # `fused` grows so behaviour is identical.
189
+ fused_ids = {fr.fact_id for fr in fused}
190
+ fused_scores = {fr.fact_id: fr.fused_score for fr in fused}
191
+
185
192
  if self._bridge is not None and strat.query_type in ("multi_hop", "entity", "factual", "general"):
186
193
  try:
187
194
  seed_ids = [fr.fact_id for fr in fused[:10]]
188
195
  bridges = self._bridge.discover(seed_ids, profile_id, max_bridges=10)
189
196
  for fid, score in bridges:
190
- if not any(fr.fact_id == fid for fr in fused):
197
+ if fid not in fused_ids:
198
+ new_score = score * 0.8
191
199
  fused.append(FusionResult(
192
- fact_id=fid, fused_score=score * 0.8,
200
+ fact_id=fid, fused_score=new_score,
193
201
  channel_ranks={}, channel_scores={},
194
202
  ))
203
+ fused_ids.add(fid)
204
+ fused_scores[fid] = new_score
195
205
  except Exception as exc:
196
206
  logger.warning("Bridge discovery: %s", exc)
197
207
 
@@ -206,14 +216,15 @@ class RetrievalEngine:
206
216
  for fid in top_ids:
207
217
  for scene in scenes_map.get(fid, [])[:2]:
208
218
  for sfid in scene.fact_ids:
209
- if not any(f.fact_id == sfid for f in fused) and sfid not in expanded_ids:
219
+ if sfid not in fused_ids and sfid not in expanded_ids:
210
220
  expanded_ids.add(sfid)
221
+ new_score = fused_scores.get(fid, 0.5) * 0.8
211
222
  fused.append(FusionResult(
212
- fact_id=sfid, fused_score=(
213
- next((f.fused_score for f in fused if f.fact_id == fid), 0.5) * 0.8
214
- ),
223
+ fact_id=sfid, fused_score=new_score,
215
224
  channel_ranks={}, channel_scores={},
216
225
  ))
226
+ fused_ids.add(sfid)
227
+ fused_scores[sfid] = new_score
217
228
  except Exception as exc:
218
229
  logger.warning("Scene expansion: %s", exc)
219
230
 
@@ -118,9 +118,17 @@ class EntityGraphChannel:
118
118
  """
119
119
  # Check staleness: profile changed or new edges added since last load
120
120
  current_count = self._get_edge_count(profile_id)
121
+ # memory-bounding-01: also reload if the cache is older than the TTL,
122
+ # even when the edge COUNT is unchanged. Edge weights/pruning can mutate
123
+ # the graph without changing the count (e.g. store_edge MAX-merge), and a
124
+ # count-stable window would otherwise serve a stale adjacency map.
125
+ import time as _t_ec
126
+ _now_ec = _t_ec.monotonic()
127
+ _fresh = (_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < 300.0
121
128
  if (self._adj_profile == profile_id
122
129
  and self._adj
123
- and self._adj_edge_count == current_count):
130
+ and self._adj_edge_count == current_count
131
+ and _fresh):
124
132
  return
125
133
  adj: dict[str, list[tuple[str, float]]] = defaultdict(list)
126
134
  try:
@@ -138,6 +146,7 @@ class EntityGraphChannel:
138
146
  self._adj = dict(adj) # Convert defaultdict to regular dict (no accidental growth)
139
147
  self._adj_profile = profile_id
140
148
  self._adj_edge_count = current_count
149
+ self._adj_loaded_at = _now_ec # memory-bounding-01: TTL reference
141
150
  # Also load entity maps (same staleness lifecycle)
142
151
  self._load_entity_maps(profile_id)
143
152
  # v3.4.1: Load graph intelligence metrics (P0)
@@ -302,7 +302,9 @@ class HopfieldChannel:
302
302
  return (self._cached_matrix, self._cached_fact_ids)
303
303
 
304
304
  # Step 2: Load facts (V3.3.12: cap to most recent 5000 to bound memory)
305
- facts = self._db.get_all_facts(profile_id)[:5000]
305
+ # memory-bounding-02: push the cap into SQL (LIMIT) so we don't
306
+ # deserialize the whole table just to slice it.
307
+ facts = self._db.get_all_facts(profile_id, limit=5000)
306
308
  if not facts:
307
309
  return (None, [])
308
310
 
@@ -188,7 +188,47 @@ class DatabaseManager:
188
188
  return ""
189
189
 
190
190
  def store_fact(self, fact: AtomicFact) -> str:
191
- """Persist an atomic fact. Returns fact_id."""
191
+ """Persist an atomic fact. Returns fact_id.
192
+
193
+ v3.6.4 — idempotent on content. If an ACTIVE fact with identical
194
+ content already exists for this profile, reinforce it (bump
195
+ evidence_count + access_count) and return its fact_id instead of
196
+ inserting a duplicate row. The passed fact's ``fact_id`` is rewritten
197
+ to the canonical id so downstream writes keyed on it (embeddings,
198
+ graph edges, context) target the real fact rather than orphaning.
199
+
200
+ This enforces the memory-system invariant "storing the same fact
201
+ twice is one fact" — preventing the duplicate explosion that poisons
202
+ importance ranking and core-memory promotion. Empty/whitespace
203
+ content is exempt (handled by placeholder filtering, not dedup).
204
+ """
205
+ if fact.content and fact.content.strip():
206
+ # Dedup across all LIVE lifecycle zones (active/warm/cold). Excludes
207
+ # 'archived' — that is soft-deleted/forgotten, so re-storing the same
208
+ # content correctly re-learns it as a fresh fact. Matching only
209
+ # 'active' (pre-3.6.4) re-opened the duplication window for every
210
+ # fact that aged to warm/cold (the bulk of the KB).
211
+ existing = self.execute(
212
+ "SELECT fact_id FROM atomic_facts "
213
+ "WHERE profile_id = ? AND content = ? "
214
+ "AND lifecycle IN ('active', 'warm', 'cold') "
215
+ "ORDER BY created_at LIMIT 1",
216
+ (fact.profile_id, fact.content),
217
+ )
218
+ if existing:
219
+ canonical_id = dict(existing[0])["fact_id"]
220
+ self.execute(
221
+ "UPDATE atomic_facts "
222
+ "SET evidence_count = evidence_count + 1, "
223
+ " access_count = access_count + 1 "
224
+ "WHERE fact_id = ?",
225
+ (canonical_id,),
226
+ )
227
+ # Rewrite caller's id so downstream embedding/graph/context
228
+ # writes target the canonical fact (idempotent), not an
229
+ # orphaned id that was never inserted.
230
+ fact.fact_id = canonical_id
231
+ return canonical_id
192
232
  self.execute(
193
233
  """INSERT OR REPLACE INTO atomic_facts
194
234
  (fact_id, memory_id, profile_id, content, fact_type,
@@ -259,12 +299,26 @@ class DatabaseManager:
259
299
  )
260
300
  return [self._row_to_fact(r) for r in rows]
261
301
 
262
- def get_all_facts(self, profile_id: str) -> list[AtomicFact]:
263
- """All facts for a profile, newest first."""
264
- rows = self.execute(
265
- "SELECT * FROM atomic_facts WHERE profile_id = ? ORDER BY created_at DESC",
266
- (profile_id,),
267
- )
302
+ def get_all_facts(
303
+ self, profile_id: str, limit: int | None = None,
304
+ ) -> list[AtomicFact]:
305
+ """All facts for a profile, newest first.
306
+
307
+ memory-bounding-02: optional SQL LIMIT so callers needing only the
308
+ most-recent N (e.g. the Hopfield channel's 5000 cap) don't deserialize
309
+ the entire table into AtomicFact objects. Default (None) = all facts.
310
+ """
311
+ if limit is not None:
312
+ rows = self.execute(
313
+ "SELECT * FROM atomic_facts WHERE profile_id = ? "
314
+ "ORDER BY created_at DESC LIMIT ?",
315
+ (profile_id, int(limit)),
316
+ )
317
+ else:
318
+ rows = self.execute(
319
+ "SELECT * FROM atomic_facts WHERE profile_id = ? ORDER BY created_at DESC",
320
+ (profile_id,),
321
+ )
268
322
  return [self._row_to_fact(r) for r in rows]
269
323
 
270
324
  _MAX_FACTS_PER_ENTITY_LOOKUP: int = 100
@@ -325,9 +379,38 @@ class DatabaseManager:
325
379
  )
326
380
 
327
381
  def delete_fact(self, fact_id: str) -> None:
328
- """Hard-delete a fact."""
382
+ """Hard-delete a fact.
383
+
384
+ DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
385
+ embedding_metadata / fact_retention / edges cascade. The explicit
386
+ embedding_metadata delete below is belt-and-suspenders for the case a
387
+ future caller routes through a connection without FK enforcement.
388
+ """
389
+ self.execute("DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,))
329
390
  self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
330
391
 
392
+ def gc_orphaned_embedding_metadata(self) -> int:
393
+ """Remove embedding_metadata rows whose parent atomic_fact is gone.
394
+
395
+ P1-3 (embeddings-vector-02): orphans accumulate when facts are deleted
396
+ through a connection that has FK enforcement OFF (the ON DELETE CASCADE
397
+ never fires). Vector search maps a vec0 rowid → fact_id via this table;
398
+ orphans return stale fact_ids that fail downstream fetch. This
399
+ maintenance sweep removes them regardless of how they were created.
400
+ Returns the number of rows deleted.
401
+ """
402
+ rows = self.execute(
403
+ "SELECT COUNT(*) AS c FROM embedding_metadata "
404
+ "WHERE fact_id NOT IN (SELECT fact_id FROM atomic_facts)"
405
+ )
406
+ n = int(rows[0]["c"]) if rows else 0
407
+ if n:
408
+ self.execute(
409
+ "DELETE FROM embedding_metadata "
410
+ "WHERE fact_id NOT IN (SELECT fact_id FROM atomic_facts)"
411
+ )
412
+ return n
413
+
331
414
  def get_fact_count(self, profile_id: str) -> int:
332
415
  """Total fact count for a profile."""
333
416
  rows = self.execute(
@@ -408,7 +491,28 @@ class DatabaseManager:
408
491
  return [self._row_to_fact(r) for r in rows]
409
492
 
410
493
  def store_edge(self, edge: GraphEdge) -> str:
411
- """Persist a graph edge. Returns edge_id."""
494
+ """Persist a graph edge. Returns edge_id.
495
+
496
+ graph-integrity-02: dedup on the LOGICAL edge identity
497
+ (profile, source, target, type). The PK is a random edge_id, so
498
+ without this every re-link created a duplicate row, and NetworkX
499
+ builds read last-weight-wins — corrupting PageRank/centrality. On a
500
+ duplicate we keep the MAX weight (strongest association wins) and
501
+ return the existing edge_id.
502
+ """
503
+ existing = self.execute(
504
+ "SELECT edge_id FROM graph_edges "
505
+ "WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
506
+ "LIMIT 1",
507
+ (edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
508
+ )
509
+ if existing:
510
+ canonical_id = dict(existing[0])["edge_id"]
511
+ self.execute(
512
+ "UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
513
+ (edge.weight, canonical_id),
514
+ )
515
+ return canonical_id
412
516
  self.execute(
413
517
  """INSERT OR REPLACE INTO graph_edges
414
518
  (edge_id, profile_id, source_id, target_id, edge_type, weight, created_at)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.6.0
3
+ Version: 3.6.4
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: AGPL-3.0-or-later