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.
@@ -102,6 +102,37 @@ def enrich_fact(
102
102
  )
103
103
 
104
104
 
105
+ # ---------------------------------------------------------------------------
106
+ # Vector dual-write helper (P1-2 / embeddings-vector-01)
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=None):
110
+ """Dual-write a fact's embedding to the ANN index + sqlite-vec store.
111
+
112
+ Embeds on-demand when the fact has no embedding (e.g. consolidated
113
+ summary facts created without one), so UPDATE/SUPERSEDE and consolidated
114
+ facts remain visible to the semantic channel instead of having a row in
115
+ ``atomic_facts`` but none in the vector store.
116
+ """
117
+ if not getattr(fact, "embedding", None) and embedder is not None and fact.content:
118
+ try:
119
+ fact.embedding = embedder.embed(fact.content)
120
+ except Exception as _emb_exc: # pragma: no cover - defensive
121
+ logger.debug("on-demand embed failed for %s: %s", fact.fact_id, _emb_exc)
122
+ return
123
+ if not getattr(fact, "embedding", None):
124
+ return
125
+ if ann_index:
126
+ ann_index.add(fact.fact_id, fact.embedding)
127
+ # V3.2: VectorStore upsert (sqlite-vec) -- dual-write (Rule 12)
128
+ if vector_store and getattr(vector_store, "available", False):
129
+ vector_store.upsert(
130
+ fact_id=fact.fact_id,
131
+ profile_id=profile_id,
132
+ embedding=fact.embedding,
133
+ )
134
+
135
+
105
136
  # ---------------------------------------------------------------------------
106
137
  # run_store (was MemoryEngine.store)
107
138
  # ---------------------------------------------------------------------------
@@ -175,10 +206,20 @@ def run_store(
175
206
  )
176
207
  db.store_memory(record)
177
208
 
178
- facts = fact_extractor.extract_facts(
179
- turns=[content], session_id=session_id,
180
- session_date=parsed_date, speaker_a=speaker,
181
- )
209
+ try:
210
+ facts = fact_extractor.extract_facts(
211
+ turns=[content], session_id=session_id,
212
+ session_date=parsed_date, speaker_a=speaker,
213
+ )
214
+ except Exception as _extract_exc:
215
+ # P0-1 (remember-write-04): an extractor EXCEPTION (transient LLM/embed
216
+ # backend error) must NOT orphan the already-committed memory. The None
217
+ # guard below only handled a None *return*, not a raise. Treat a raise
218
+ # as "no facts" so the verbatim/raw fallback persists the content.
219
+ logger.warning(
220
+ "extract_facts() raised — falling back to raw fact: %s", _extract_exc,
221
+ )
222
+ facts = None
182
223
 
183
224
  # v3.4.38: Defensive None guard. extract_facts() returns None on transient
184
225
  # failures (embedding worker timeout, LLM call fail). Without this guard,
@@ -257,58 +298,77 @@ def run_store(
257
298
  )
258
299
 
259
300
  if consolidator:
260
- action = consolidator.consolidate(fact, profile_id)
261
- if action.action_type.value == "noop":
262
- continue
263
-
264
- # Opinion confidence tracking: reinforce or decay
265
- if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
266
- try:
267
- existing = db.get_fact(action.new_fact_id)
268
- if existing and existing.fact_type == FactType.OPINION:
269
- new_conf = min(1.0, existing.confidence + 0.1)
270
- db.update_fact(action.new_fact_id, {"confidence": new_conf})
271
- except Exception:
272
- pass
273
- elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
274
- try:
275
- old_id = getattr(action, "old_fact_id", None)
276
- if old_id:
277
- old_fact = db.get_fact(old_id)
278
- if old_fact:
279
- new_conf = max(0.0, old_fact.confidence - 0.2)
280
- db.update_fact(old_id, {"confidence": new_conf})
281
- except Exception:
282
- pass
283
-
284
- if action.action_type.value in ("update", "supersede"):
285
- updated_fact = db.get_fact(action.new_fact_id)
286
- if updated_fact:
287
- if graph_builder:
288
- graph_builder.build_edges(updated_fact, profile_id)
289
- if observation_builder:
290
- for eid in updated_fact.canonical_entities:
291
- observation_builder.update_profile(
292
- eid, updated_fact, profile_id,
293
- )
294
- stored_ids.append(action.new_fact_id)
295
- continue
296
- # ADD case: consolidator already stored the fact (F8 fix)
297
- # Fall through to post-processing below
301
+ try:
302
+ action = consolidator.consolidate(fact, profile_id)
303
+ except Exception as _consolidate_exc:
304
+ # P0-1 (remember-write-03): a consolidate failure (e.g. LLM
305
+ # timeout) must NOT orphan the already-committed memory. Fall
306
+ # back to storing the raw enriched fact so the content stays
307
+ # retrievable across all channels.
308
+ logger.warning(
309
+ "consolidate() failed for fact %s — storing raw fact as "
310
+ "fallback: %s", fact.fact_id, _consolidate_exc,
311
+ )
312
+ action = None
313
+
314
+ if action is not None:
315
+ if action.action_type.value == "noop":
316
+ continue
317
+
318
+ # Opinion confidence tracking: reinforce or decay
319
+ if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
320
+ try:
321
+ existing = db.get_fact(action.new_fact_id)
322
+ if existing and existing.fact_type == FactType.OPINION:
323
+ new_conf = min(1.0, existing.confidence + 0.1)
324
+ db.update_fact(action.new_fact_id, {"confidence": new_conf})
325
+ except Exception:
326
+ pass
327
+ elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
328
+ try:
329
+ old_id = getattr(action, "old_fact_id", None)
330
+ if old_id:
331
+ old_fact = db.get_fact(old_id)
332
+ if old_fact:
333
+ new_conf = max(0.0, old_fact.confidence - 0.2)
334
+ db.update_fact(old_id, {"confidence": new_conf})
335
+ except Exception:
336
+ pass
337
+
338
+ if action.action_type.value in ("update", "supersede"):
339
+ updated_fact = db.get_fact(action.new_fact_id)
340
+ if updated_fact:
341
+ # P1-2 (embeddings-vector-01): the merged/superseding
342
+ # fact must reach the vector store (embed on-demand if
343
+ # it has none) — otherwise it is invisible to the
344
+ # semantic channel despite living in atomic_facts.
345
+ _upsert_fact_vectors(
346
+ updated_fact, profile_id, ann_index, vector_store, embedder,
347
+ )
348
+ if graph_builder:
349
+ graph_builder.build_edges(updated_fact, profile_id)
350
+ if observation_builder:
351
+ for eid in updated_fact.canonical_entities:
352
+ observation_builder.update_profile(
353
+ eid, updated_fact, profile_id,
354
+ )
355
+ stored_ids.append(action.new_fact_id)
356
+ continue
357
+ # ADD case: consolidator already stored the fact (F8 fix)
358
+ # Fall through to post-processing below
359
+ else:
360
+ # Consolidate failed → store the raw fact ourselves so the
361
+ # memory is never left without a retrievable fact, then fall
362
+ # through to post-processing (embeddings, graph, context).
363
+ db.store_fact(fact)
298
364
  else:
299
365
  db.store_fact(fact)
300
366
 
301
367
  stored_ids.append(fact.fact_id)
302
368
 
303
- if fact.embedding and ann_index:
304
- ann_index.add(fact.fact_id, fact.embedding)
305
- # V3.2: VectorStore upsert (sqlite-vec) -- dual-write (Rule 12)
306
- if fact.embedding and vector_store and vector_store.available:
307
- vector_store.upsert(
308
- fact_id=fact.fact_id,
309
- profile_id=profile_id,
310
- embedding=fact.embedding,
311
- )
369
+ # Dual-write embedding to ANN index + vector store (embed on-demand if
370
+ # a consolidated ADD fact arrived without one). See _upsert_fact_vectors.
371
+ _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder)
312
372
  # Phase 2: Generate contextual description (after consolidator, before graph_builder)
313
373
  if context_generator:
314
374
  try:
@@ -489,6 +549,15 @@ def run_store_fact_direct(
489
549
  and graph edges are all populated — even for auxiliary data.
490
550
  Creates a parent memory record to satisfy FK constraint.
491
551
  """
552
+ # remember-write-02: gate low-quality content (empty, bare category tags,
553
+ # placeholder/template leakage) at the WRITE boundary, matching run_store's
554
+ # gate. Previously this direct path had no filter, so junk entered the KB
555
+ # and polluted evidence/stats/embeddings (the read-side filter only hid it).
556
+ from superlocalmemory.core.injection import is_low_quality
557
+ if is_low_quality(fact.content):
558
+ logger.debug("run_store_fact_direct: skipping low-quality content")
559
+ return fact.fact_id
560
+
492
561
  # Create parent memory record (FK: atomic_facts.memory_id → memories.memory_id)
493
562
  if not fact.memory_id:
494
563
  record = MemoryRecord(
@@ -0,0 +1,60 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Pure decision logic for the MCP stdin-EOF self-termination monitor.
6
+
7
+ Isolated here (no imports, no side effects) so the kqueue ``EV_EOF``
8
+ handling contract is unit-testable without importing the FastMCP server
9
+ module, which starts daemon threads and auto-starts the SLM daemon at
10
+ import time.
11
+
12
+ Background (v3.6.4 fix)
13
+ -----------------------
14
+ The stdin-EOF monitor (``superlocalmemory.mcp.server._stdin_eof_monitor``)
15
+ exists to reap orphaned ``slm mcp`` processes when an IDE/agent abandons the
16
+ stdio pipe without quitting. It registers ``EVFILT_READ | EV_EOF`` on stdin
17
+ and terminates the process when the write-end closes.
18
+
19
+ On macOS, ``EVFILT_READ`` reports ``EV_EOF`` *together with* still-readable
20
+ bytes (``ev.data > 0``) when the write-end is closed while a final request
21
+ is still buffered in the pipe. The original monitor exited on the EOF flag
22
+ alone — dropping that buffered request and tearing down a session that
23
+ still had a pending in-flight call. Under strict MCP hosts whose transport
24
+ half-closes stdin around reconnect/teardown, this surfaced as the server
25
+ self-terminating mid-request, which the host then logged as a keepalive
26
+ failure and respawned (observed against the Hermes agent).
27
+
28
+ The guard defers termination until the buffer is genuinely drained
29
+ (``ev.data <= 0``), letting the FastMCP reader consume the last request
30
+ first. Genuine disconnects (EOF with an empty buffer) still terminate
31
+ immediately — behaviour identical to before for the common case.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ __all__ = ["eof_action"]
37
+
38
+
39
+ def eof_action(flags: int, data: int, eof_flag: int) -> str:
40
+ """Decide how the stdin-EOF monitor should react to one kqueue event.
41
+
42
+ Args:
43
+ flags: ``kevent.flags`` bitmask returned by ``kqueue.control``.
44
+ data: ``kevent.data`` — for ``EVFILT_READ`` this is the number of
45
+ bytes still readable on the descriptor.
46
+ eof_flag: the platform ``select.KQ_EV_EOF`` constant (injected so
47
+ this function stays import-free and trivially testable).
48
+
49
+ Returns:
50
+ - ``"exit"`` — genuine end-of-stream: write-end closed and the
51
+ buffer is drained (``data <= 0``). Safe to self-terminate.
52
+ - ``"drain"`` — write-end closed but unread bytes remain
53
+ (``data > 0``). Must NOT terminate yet; let the reader consume
54
+ the buffered request first, otherwise it is silently dropped.
55
+ - ``"ignore"`` — no EOF on this event (ordinary readability or a
56
+ spurious wake); nothing to do.
57
+ """
58
+ if not (flags & eof_flag):
59
+ return "ignore"
60
+ return "drain" if data > 0 else "exit"
@@ -288,11 +288,26 @@ _watchdog_thread.start()
288
288
  # closes WITHOUT consuming any bytes, so it cannot race with FastMCP's asyncio
289
289
  # stdin reader. On Linux (no kqueue), the watchdog alone provides coverage.
290
290
  def _stdin_eof_monitor() -> None:
291
- """Exit when the IDE closes our stdin pipe (kqueue — macOS only)."""
292
- import select as _sel, os as _os_eof
291
+ """Exit when the IDE closes our stdin pipe (kqueue — macOS only).
292
+
293
+ V3.6.4: kqueue ``EVFILT_READ`` reports ``EV_EOF`` *together with*
294
+ still-readable bytes (``ev.data > 0``) when the write-end closes while a
295
+ final request is buffered. Exiting on the EOF flag alone (pre-3.6.4)
296
+ dropped that in-flight request and self-terminated a session that still
297
+ had work to deliver — strict MCP hosts (e.g. the Hermes agent) then
298
+ logged a keepalive failure and respawned the process. We now defer
299
+ termination until the buffer is genuinely drained (see ``_stdin_guard``).
300
+ """
301
+ import select as _sel, os as _os_eof, time as _time
302
+ from superlocalmemory.mcp._stdin_guard import eof_action
293
303
  _mlog = logging.getLogger(__name__ + ".stdin_monitor")
294
304
  if not hasattr(_sel, "kqueue"):
295
305
  return # Linux / non-macOS: watchdog covers process death
306
+ # Bounded grace for the FastMCP reader to drain a buffered final request
307
+ # before we tear down. ~2 s ceiling (40 × 50 ms): a genuine EOF means the
308
+ # session is ending regardless, so we never wait indefinitely.
309
+ _DRAIN_POLL_S = 0.05
310
+ _DRAIN_MAX_POLLS = 40
296
311
  try:
297
312
  fd = sys.stdin.fileno()
298
313
  kq = _sel.kqueue()
@@ -301,9 +316,23 @@ def _stdin_eof_monitor() -> None:
301
316
  while True:
302
317
  evs = kq.control(None, 4, 30.0) # 30 s poll — low cost
303
318
  for ev in evs:
304
- if ev.flags & _sel.KQ_EV_EOF:
305
- _mlog.info("stdin write-end closed (kqueue EOF), self-terminating")
306
- _os_eof._exit(0)
319
+ action = eof_action(ev.flags, ev.data, _sel.KQ_EV_EOF)
320
+ if action == "ignore":
321
+ continue
322
+ if action == "drain":
323
+ # Write-end closed but unread bytes remain. Let the
324
+ # FastMCP reader consume the final request(s); poll until
325
+ # drained or the grace ceiling elapses.
326
+ for _ in range(_DRAIN_MAX_POLLS):
327
+ _time.sleep(_DRAIN_POLL_S)
328
+ recheck = kq.control(None, 4, 0) # non-blocking
329
+ if not recheck:
330
+ break
331
+ ev = recheck[0]
332
+ if eof_action(ev.flags, ev.data, _sel.KQ_EV_EOF) != "drain":
333
+ break
334
+ _mlog.info("stdin write-end closed (kqueue EOF, drained), self-terminating")
335
+ _os_eof._exit(0)
307
336
  except Exception as exc:
308
337
  _mlog.debug("stdin EOF monitor error: %s — watchdog will cover", exc)
309
338
 
@@ -21,10 +21,19 @@ _STATIC_MECHANISMS = {"settings-file", "config-file", "print-only"}
21
21
 
22
22
 
23
23
  def _proxy_configured() -> bool:
24
- """Return True if proxy_enabled=True in optimize.json (no liveness check)."""
24
+ """Return True if proxy_enabled=True in optimize.json (no liveness check).
25
+
26
+ Reads from the module-level _store if it has been set (daemon process or
27
+ tests that call _set_config_store). Falls back to a fresh ConfigStore read
28
+ from disk when _store is None — which is always the case in CLI subprocess
29
+ context because _set_config_store() is only called by the daemon on startup.
30
+ """
25
31
  try:
26
- from superlocalmemory.optimize.config import get_optimize_config
27
- return get_optimize_config().proxy_enabled
32
+ from superlocalmemory.optimize.config import _store
33
+ if _store is not None:
34
+ return _store.get().proxy_enabled
35
+ from superlocalmemory.optimize.config.store import ConfigStore
36
+ return ConfigStore().get().proxy_enabled
28
37
  except Exception:
29
38
  return False
30
39
 
@@ -153,9 +153,42 @@ class CacheManager:
153
153
  # ---- INTERFACE-CONTRACT §4 public methods ----
154
154
 
155
155
  def build_key(self, req: Any, tenant_id: str) -> str | None:
156
- """Build a deterministic cache key for req + tenant_id."""
157
- if isinstance(req, dict):
158
- model_id = req.get("model", "")
156
+ """Build a deterministic cache key for req + tenant_id.
157
+
158
+ BUG-FIX (v3.6.3): Two bugs repaired here:
159
+ 1. tenant_id="default" failed KeyBuilder's 64-char hex SHA-256 validation,
160
+ silently raising ValueError caught by fail-open wrappers → cache never
161
+ stored or retrieved anything via the proxy path. Fix: normalize any
162
+ non-hex tenant_id to its SHA-256 digest before passing to KeyBuilder.
163
+ 2. ProxyRequest objects have a `body` dict, not model_id/messages/system
164
+ attributes. The old `getattr(req, "model_id", "")` path silently
165
+ returned empty strings → all proxy requests got the same (invalid) key.
166
+ Fix: detect ProxyRequest and extract fields from .body.
167
+ """
168
+ import hashlib as _hashlib
169
+ import json as _json
170
+ import re as _re
171
+ _HEX64 = _re.compile(r"[0-9a-f]{64}")
172
+ # Normalize tenant_id: KeyBuilder requires a 64-char lowercase hex SHA-256.
173
+ if not _HEX64.fullmatch(tenant_id or ""):
174
+ tenant_id = _hashlib.sha256(tenant_id.encode()).hexdigest()
175
+
176
+ if isinstance(req, ProxyRequest):
177
+ # Extract semantic fields from the parsed JSON body.
178
+ body = req.body or {}
179
+ model_id = body.get("model", "") or ""
180
+ messages = body.get("messages", []) or []
181
+ system_raw = body.get("system", "") or ""
182
+ # Anthropic allows system as a list of content blocks — normalise to str.
183
+ if isinstance(system_raw, list):
184
+ system = _json.dumps(system_raw, sort_keys=True, separators=(",", ":"))
185
+ else:
186
+ system = str(system_raw)
187
+ # params: everything except fields extracted above and stream flag.
188
+ _SKIP = frozenset({"model", "messages", "system", "stream"})
189
+ params = {k: v for k, v in body.items() if k not in _SKIP}
190
+ elif isinstance(req, dict):
191
+ model_id = req.get("model", "") or ""
159
192
  messages = req.get("messages", []) or []
160
193
  params = req.get("params", {}) or {}
161
194
  system = req.get("system", "") or ""
@@ -174,9 +207,18 @@ class CacheManager:
174
207
  )
175
208
 
176
209
  def get(self, req: Any, tenant_id: str) -> "CachedResponse | None":
177
- """CacheHook.check() entry point."""
210
+ """CacheHook.check() entry point.
211
+
212
+ BUG-FIX (v3.6.3): Previously returned None on cache miss, which caused
213
+ _safe_cache_check to return CachedResponse(cache_key=""). The empty
214
+ cache_key is falsy, so the store condition in handle_messages
215
+ (``cache_result.cache_key``) was always False → cache was NEVER
216
+ populated. Fix: return a miss CachedResponse that carries the computed
217
+ key so the store path can proceed.
218
+ """
178
219
  key = self.build_key(req, tenant_id)
179
220
  if key is None:
221
+ # Uncacheable (non-zero temperature, etc.) — signal with None.
180
222
  return None
181
223
  row = self._exact.get(key, tenant_id)
182
224
  if row is not None:
@@ -188,7 +230,8 @@ class CacheManager:
188
230
  ttl_seconds=0,
189
231
  )
190
232
  self._metrics.exact_misses += 1
191
- return None
233
+ # Return miss WITH the key so callers can use it for cache storage.
234
+ return CachedResponse(hit=False, data=None, cache_key=key, ttl_seconds=0)
192
235
 
193
236
  def set(self, req: Any, resp: Any, tenant_id: str) -> None:
194
237
  """CacheHook.store() entry point."""
@@ -196,8 +239,10 @@ class CacheManager:
196
239
  key = self.build_key(req, tenant_id)
197
240
  if key is None:
198
241
  return
199
- if isinstance(req, dict):
200
- model_id = req.get("model", "")
242
+ if isinstance(req, ProxyRequest):
243
+ model_id = (req.body or {}).get("model", "") or ""
244
+ elif isinstance(req, dict):
245
+ model_id = req.get("model", "") or ""
201
246
  else:
202
247
  model_id = getattr(req, "model_id", "") or ""
203
248
  tags = [
@@ -216,9 +261,18 @@ class CacheManager:
216
261
  # ---- CacheHook protocol implementation (INTERFACE-CONTRACT §3) ----
217
262
 
218
263
  def check(self, req: ProxyRequest) -> "CachedResponse | None":
219
- """CacheHook.check() — look up by ProxyRequest; fail-open on error."""
264
+ """CacheHook.check() — look up by ProxyRequest; fail-open on error.
265
+
266
+ BUG-FIX (v3.6.3): on_miss() was never called from the proxy path,
267
+ so MetricsCollector.misses stayed at 0 and the dashboard always showed
268
+ 0 misses. Fixed by calling on_miss() here whenever get() returns a
269
+ cache-miss result.
270
+ """
220
271
  try:
221
- return self.get(req, tenant_id="default")
272
+ result = self.get(req, tenant_id="default")
273
+ if result is not None and not result.hit:
274
+ MetricsCollector.get_instance().on_miss()
275
+ return result
222
276
  except Exception as exc:
223
277
  logger.warning("CacheManager.check raised (fail-open): %s", exc)
224
278
  return None
@@ -71,10 +71,6 @@ class CompressRouter:
71
71
 
72
72
  if not cfg.compress_enabled:
73
73
  return req
74
- if req.stream:
75
- return req
76
- if req.has_tools:
77
- return req # §6.5 safety rule
78
74
 
79
75
  body = dict(req.body)
80
76
  messages = body.get("messages", [])
@@ -139,8 +135,8 @@ class CompressRouter:
139
135
  try:
140
136
  saved = max(0, before_tokens - after_tokens)
141
137
  if self._metrics_counters is not None:
142
- # M-02: Call proper method instead of accessing private attribute
143
- self._metrics_counters.on_compress(saved, lossy)
138
+ # M-02: pass before/after directly (bytes_original, bytes_after contract)
139
+ self._metrics_counters.on_compress(before_tokens, after_tokens)
144
140
  logger.debug("on_compress: saved=%d tokens lossy=%s", saved, lossy)
145
141
  except Exception as exc:
146
142
  logger.debug("on_compress metrics update failed (non-fatal): %s", exc)
@@ -169,9 +165,11 @@ class CompressRouter:
169
165
  for idx, msg in enumerate(messages):
170
166
  role = msg.get("role", "")
171
167
  is_tool_msg = (
172
- role == "tool"
173
- or role == "user"
174
- or _msg_has_tool_result(msg)
168
+ role == "tool" # OpenAI tool result messages (plain text, skip entirely)
169
+ # Anthropic tool_result blocks are NOT skipped — _compress_content_block
170
+ # handles type=="tool_result" blocks by compressing their text content
171
+ # while preserving the block structure. This is where the bulk of
172
+ # Claude Code output lives (bash results, file contents, JSON data).
175
173
  )
176
174
  if idx in protect_indices or is_tool_msg:
177
175
  new_messages.append(msg)