superlocalmemory 3.8.7 → 3.8.9

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 (48) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/core/embeddings.py +128 -13
  34. package/src/superlocalmemory/core/engine.py +6 -1
  35. package/src/superlocalmemory/core/engine_ingestion.py +5 -0
  36. package/src/superlocalmemory/core/ingestion_command.py +36 -0
  37. package/src/superlocalmemory/core/materialization_control.py +20 -0
  38. package/src/superlocalmemory/core/ollama_embedder.py +5 -0
  39. package/src/superlocalmemory/core/recall_gate.py +63 -4
  40. package/src/superlocalmemory/core/recall_pipeline.py +40 -0
  41. package/src/superlocalmemory/core/store_pipeline.py +10 -0
  42. package/src/superlocalmemory/encoding/scene_builder.py +105 -15
  43. package/src/superlocalmemory/mcp/tools_core.py +16 -7
  44. package/src/superlocalmemory/retrieval/entity_channel.py +201 -56
  45. package/src/superlocalmemory/retrieval/vector_store.py +238 -123
  46. package/src/superlocalmemory/server/recall_health.py +3 -1
  47. package/src/superlocalmemory/server/unified_daemon.py +106 -16
  48. package/src/superlocalmemory/storage/embedding_migrator.py +88 -60
package/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.8.9] - 2026-07-27 — Reliable MCP recall and background processing
9
+
10
+ ### Fixed
11
+ - MCP recall is reliable through both supported connection methods: local
12
+ stdio and Streamable HTTP. HTTP tool calls no longer hang the daemon.
13
+ - Changing dashboard memory or model settings no longer leaves background
14
+ processing stuck. Your saved memories stay available while processing resumes
15
+ safely with the updated settings.
16
+ - Newly saved memories remain immediately searchable while their richer
17
+ background indexing completes.
18
+
19
+ ### Notes
20
+ - Existing memories and configuration are preserved during upgrade. No manual
21
+ migration is required.
22
+
23
+ ## [3.8.8] - 2026-07-27 — Live-database recall and vector integrity
24
+
25
+ ### Fixed
26
+ - Foreground recall now retains priority while startup repair and canonical
27
+ remember enrichment are active. Background embedding work yields between
28
+ items and cannot cold-start the shared local model ahead of a user query.
29
+ - Daemon readiness now verifies that the local embedding subprocess is
30
+ actually alive and has served a request instead of trusting a stale startup
31
+ flag.
32
+ - Scene clustering reuses durable fact embeddings and ignores legacy scene
33
+ rows whose facts were already consolidated away. Mature databases no longer
34
+ issue thousands of redundant model requests or repeatedly recycle the
35
+ embedding worker after restart.
36
+ - sqlite-vec row allocation is serialized across processes, and every metadata
37
+ pointer is validated against the vector payload's profile. Cross-profile
38
+ row-id collisions are repaired without exposing another profile's memory.
39
+ - Vector writes roll back abandoned transactions, missing vectors are repaired
40
+ exactly, and semantic search expands past legacy orphan payloads.
41
+ - Entity-cache startup reads are bounded and load only the columns used by the
42
+ entity channel.
43
+ - Exact lexical evidence remains protected after learned ranking so a freshly
44
+ remembered exact marker cannot be displaced by a weaker semantic match.
45
+
46
+ ### Notes
47
+ - Recall remains physically query-only; remember owns durable writes and
48
+ continues full canonical enrichment asynchronously after immediate queryable
49
+ admission.
50
+ - This candidate must pass the installed wheel, installed npm tarball,
51
+ dashboard, MCP, CLI, and mature live-database gates before publication.
52
+
8
53
  ## [3.8.7] - 2026-07-27 — Existing graph-store compatibility
9
54
 
10
55
  ### Fixed
@@ -12,9 +57,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
57
  Cozo graph stores. Version 3.8.6 selected PyCozo 0.7.6, whose incompatible
13
58
  on-disk format could stop the daemon at startup with
14
59
  `Unknown storage version 1`.
60
+ - A native Cozo panic is now contained at the optional projection boundary.
61
+ The daemon remains available on canonical SQLite and preserves the graph
62
+ files unchanged instead of losing access to memory.
15
63
 
16
64
  ### Notes
17
- - No graph rebuild or database migration is required.
65
+ - Existing PyCozo 0.3.0 graphs require no rebuild or database migration.
18
66
  - All serialized-write, read-only recall, admission-journal, and dead-letter
19
67
  fixes from 3.8.6 are unchanged.
20
68
 
package/README.md CHANGED
@@ -5,15 +5,15 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- <h1 align="center">SuperLocalMemory V3.8.7</h1>
8
+ <h1 align="center">SuperLocalMemory V3.8.8</h1>
9
9
  <p align="center"><strong>Enterprise-grade, local-first memory for AI agents and teams.</strong><br/>
10
10
  <em>A persistent, auditable long-term brain for your agents that runs on your own infrastructure — with multi-workspace isolation, role-based access, and GDPR + EU AI Act governance controls built in.</em></p>
11
- <p align="center"><code>v3.8.7</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
11
+ <p align="center"><code>v3.8.8</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
12
12
  Proxy: <code>slm wrap claude</code> &nbsp;·&nbsp; MCP: add <code>slm_compress</code> to your config &nbsp;·&nbsp; Skill: zero-config</p>
13
13
  <p align="center"><strong>3 public research preprints</strong> (arXiv + Zenodo archives) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
14
14
 
15
15
  <p align="center">
16
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.7-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.7 — Current Release"/></a>
16
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.8-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.8 — Current Release"/></a>
17
17
  <a href="https://arxiv.org/abs/2603.14588"><img src="https://img.shields.io/badge/arXiv-2603.14588-b31b1b?style=for-the-badge&logo=arxiv&logoColor=white" alt="arXiv Paper"/></a>
18
18
  <a href="#three-surfaces-proxy--mcp-tools--skill"><img src="https://img.shields.io/badge/Proxy_|_MCP_|_Skill-22c55e?style=for-the-badge" alt="Three Surfaces: Proxy, MCP Tools, Skill"/></a>
19
19
  <a href="https://pypi.org/project/superlocalmemory/"><img src="https://img.shields.io/pypi/v/superlocalmemory?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"/></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.8.7",
3
+ "version": "3.8.9",
4
4
  "description": "Local-first agent memory with MCP and an agent-native CLI. Documented clients include Claude Code, Cursor, and Windsurf.",
5
5
  "keywords": [
6
6
  "ai-memory",
@@ -15,5 +15,5 @@
15
15
  "mcpServers": "./.mcp.json",
16
16
  "name": "superlocalmemory",
17
17
  "repository": "https://github.com/qualixar/superlocalmemory",
18
- "version": "3.8.7"
18
+ "version": "3.8.9"
19
19
  }
package/plugin/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- BEGIN SuperLocalMemory v3.8.7 -->
1
+ <!-- BEGIN SuperLocalMemory v3.8.9 -->
2
2
 
3
3
  ## SuperLocalMemory (SLM) — Agent Rules
4
4
 
@@ -39,6 +39,6 @@ slm-recall · slm-remember · slm-session · slm-status · slm-cache · slm-comp
39
39
  ### Subagents
40
40
  slm-memory-advisor (memory decisions, session hygiene, scope/profile guidance) · slm-optimize-advisor (context compression + KV cache) · slm-governance-advisor (scope/roles/compliance/GDPR)
41
41
 
42
- <!-- END SuperLocalMemory v3.8.7 -->
42
+ <!-- END SuperLocalMemory v3.8.9 -->
43
43
 
44
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -77,4 +77,4 @@ slm-scope · slm-governance · slm-profile · slm-remember · slm-recall
77
77
  # What NOT to do
78
78
  Never session_init twice; never forget without dry-run preview; never store secrets; never bypass role checks; never claim an erasure succeeded without verifying via recall.
79
79
 
80
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
80
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -68,4 +68,4 @@ assessment. The gate is the authority.
68
68
 
69
69
  ---
70
70
 
71
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
71
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -46,4 +46,4 @@ slm-recall · slm-remember · slm-session · slm-scope · slm-profile · slm-gov
46
46
  # What NOT to do
47
47
  Never session_init twice; never forget dry_run=False without reporting preview; never dump a whole file into remember; never invent a memory; never claim "saved" without success:true / clean CLI exit; never bypass scope or governance restrictions.
48
48
 
49
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
49
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -41,4 +41,4 @@ slm-compress · slm-cache · slm-status · slm-profile
41
41
  # What NOT to do
42
42
  Never compress code-for-edit/JSON-to-parse/<500 chars; never store secrets/ccr_ids; never let optimize failure block/alter the task; never claim a specific savings %; never carry ccr_ids across profile switches.
43
43
 
44
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -1 +1 @@
1
- superlocalmemory==3.8.7
1
+ superlocalmemory==3.8.9
@@ -145,4 +145,4 @@ These subcommands control daemon-level cache settings. They do not read or write
145
145
 
146
146
  ---
147
147
 
148
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -147,4 +147,4 @@ Content over 1 MB (1 000 000 bytes UTF-8) is processed but `reversible` is force
147
147
 
148
148
  ---
149
149
 
150
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -245,4 +245,4 @@ Before running any destructive operation (`forget`, `compact_memories`):
245
245
 
246
246
  ---
247
247
 
248
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
248
+ *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
@@ -311,4 +311,4 @@ profile. See `slm-profile` for the full profile switching workflow.
311
311
 
312
312
  ---
313
313
 
314
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -96,4 +96,4 @@ paused, name the approval needed; when errored, quote the short detail.
96
96
 
97
97
  ---
98
98
 
99
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
@@ -145,4 +145,4 @@ Name them differently in your MCP config (e.g. `superlocalmemory-personal` and
145
145
 
146
146
  ---
147
147
 
148
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
148
+ *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
@@ -236,4 +236,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
236
236
 
237
237
  ---
238
238
 
239
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
@@ -238,4 +238,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
238
238
 
239
239
  ---
240
240
 
241
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
@@ -173,4 +173,4 @@ to review the impact. See `slm-remember` for the full deletion discipline.
173
173
 
174
174
  ---
175
175
 
176
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
@@ -227,4 +227,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
227
227
 
228
228
  ---
229
229
 
230
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
@@ -128,4 +128,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
128
128
  - **slm-optimize-advisor** — context compression and KV cache
129
129
  - **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
130
130
 
131
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
131
+ SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
@@ -145,4 +145,4 @@ These subcommands control daemon-level cache settings. They do not read or write
145
145
 
146
146
  ---
147
147
 
148
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
@@ -147,4 +147,4 @@ Content over 1 MB (1 000 000 bytes UTF-8) is processed but `reversible` is force
147
147
 
148
148
  ---
149
149
 
150
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
@@ -311,4 +311,4 @@ profile. See `slm-profile` for the full profile switching workflow.
311
311
 
312
312
  ---
313
313
 
314
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
@@ -236,4 +236,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
236
236
 
237
237
  ---
238
238
 
239
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later*
@@ -238,4 +238,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
238
238
 
239
239
  ---
240
240
 
241
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later*
@@ -227,4 +227,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
227
227
 
228
228
  ---
229
229
 
230
- *SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v3.8.7 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.8.7"
3
+ version = "3.8.9"
4
4
  description = "Local-first agent memory with auditable hybrid retrieval"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-or-later"
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
32
32
  os.environ["OMP_NUM_THREADS"] = "2"
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
- __version__ = "3.8.7"
35
+ __version__ = "3.8.9"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -220,6 +220,7 @@ class EmbeddingService:
220
220
  self._owns_worker_lock = False
221
221
  self._request_count: int = 0
222
222
  self._http_client: object | None = None
223
+ self._remote_ready = False
223
224
 
224
225
  # Register for atexit cleanup (prevent orphaned workers)
225
226
  ref = weakref.ref(self, _live_embedding_services.discard)
@@ -246,6 +247,31 @@ class EmbeddingService:
246
247
  return bool(self._config.api_endpoint and self._config.api_key)
247
248
  return self._available
248
249
 
250
+ @property
251
+ def is_warm(self) -> bool:
252
+ """Return whether the configured backend has served a request.
253
+
254
+ ``is_available`` only means that the backend may be started. A local
255
+ sentence-transformers cold start can take minutes on Apple Silicon, so
256
+ background enrichment must not mistake availability for readiness and
257
+ occupy the only worker ahead of an interactive recall.
258
+ """
259
+ config = getattr(self, "_config", None)
260
+ if config is not None and (
261
+ config.is_openai_compatible or config.is_cloud
262
+ ):
263
+ return bool(
264
+ getattr(self, "_remote_ready", False)
265
+ and self.is_available
266
+ )
267
+ proc = getattr(self, "_worker_proc", None)
268
+ if proc is None or getattr(self, "_request_count", 0) <= 0:
269
+ return False
270
+ try:
271
+ return proc.poll() is None
272
+ except Exception:
273
+ return False
274
+
249
275
  @property
250
276
  def dimension(self) -> int:
251
277
  return self._config.dimension
@@ -291,13 +317,27 @@ class EmbeddingService:
291
317
  """Embed a single text string. Returns list of floats or None."""
292
318
  if not text or not text.strip():
293
319
  raise ValueError("Cannot embed empty text")
320
+ from superlocalmemory.core.recall_gate import wait_for_foreground_idle
321
+ wait_for_foreground_idle()
294
322
  if self._config.is_openai_compatible:
295
- vecs = self._openai_compatible_embed_batch([text])
296
- vec = vecs[0]
297
- self._validate_dimension(np.asarray(vec))
298
- return vec
323
+ try:
324
+ vecs = self._openai_compatible_embed_batch([text])
325
+ vec = vecs[0]
326
+ self._validate_dimension(np.asarray(vec))
327
+ self._remote_ready = True
328
+ return vec
329
+ except Exception:
330
+ self._remote_ready = False
331
+ raise
299
332
  if self._config.is_cloud:
300
- return self._cloud_embed_single(text)
333
+ try:
334
+ vec = self._cloud_embed_single(text)
335
+ self._validate_dimension(np.asarray(vec))
336
+ self._remote_ready = True
337
+ return vec
338
+ except Exception:
339
+ self._remote_ready = False
340
+ raise
301
341
  result = self._subprocess_embed([text])
302
342
  if result is None:
303
343
  return None
@@ -309,14 +349,34 @@ class EmbeddingService:
309
349
  """Embed a batch of texts."""
310
350
  if not texts:
311
351
  raise ValueError("Cannot embed empty batch")
352
+ from superlocalmemory.core.recall_gate import is_background_work
353
+ if is_background_work():
354
+ # A single large background batch can own the only local inference
355
+ # worker for tens of seconds. Slice it so a recall arriving after
356
+ # this call started gets priority before the next text.
357
+ return [self.embed(text) for text in texts]
312
358
  if self._config.is_openai_compatible:
313
- results = self._openai_compatible_embed_batch(texts)
314
- for vec in results:
315
- if vec is not None:
316
- self._validate_dimension(np.asarray(vec))
317
- return results
359
+ try:
360
+ results = self._openai_compatible_embed_batch(texts)
361
+ for vec in results:
362
+ if vec is not None:
363
+ self._validate_dimension(np.asarray(vec))
364
+ self._remote_ready = any(vec is not None for vec in results)
365
+ return results
366
+ except Exception:
367
+ self._remote_ready = False
368
+ raise
318
369
  if self._config.is_cloud:
319
- return self._cloud_embed_batch(texts)
370
+ try:
371
+ results = self._cloud_embed_batch(texts)
372
+ for vec in results:
373
+ if vec is not None:
374
+ self._validate_dimension(np.asarray(vec))
375
+ self._remote_ready = any(vec is not None for vec in results)
376
+ return results
377
+ except Exception:
378
+ self._remote_ready = False
379
+ raise
320
380
  result = self._subprocess_embed(texts)
321
381
  if result is None:
322
382
  return [None] * len(texts)
@@ -353,6 +413,8 @@ class EmbeddingService:
353
413
  Includes a timeout (_SUBPROCESS_RESPONSE_TIMEOUT seconds) so the CLI
354
414
  never hangs indefinitely on cold model loads or network issues.
355
415
  """
416
+ from superlocalmemory.core.recall_gate import wait_for_foreground_idle
417
+ wait_for_foreground_idle()
356
418
  with self._lock:
357
419
  # Only an explicit terminal disable (``False``) short-circuits. A
358
420
  # ``None`` availability is the recall-health self-heal's "re-probe"
@@ -738,9 +800,32 @@ class EmbeddingService:
738
800
  client = self._get_http_client()
739
801
  last_error: Exception | None = None
740
802
  for attempt in range(max_retries):
803
+ from superlocalmemory.core.materialization_control import (
804
+ MaterializationDeferred,
805
+ )
806
+ from superlocalmemory.core.recall_gate import (
807
+ background_preempt_requested,
808
+ is_background_work,
809
+ )
810
+ if background_preempt_requested():
811
+ raise MaterializationDeferred(
812
+ "background embedding yielded to runtime transition"
813
+ )
741
814
  try:
742
- resp = client.post(endpoint, headers=headers, json=body)
815
+ request_kwargs = {"headers": headers, "json": body}
816
+ if is_background_work():
817
+ # Runtime reconfigure drains admitted operations in five
818
+ # seconds. A background remote read must leave enough
819
+ # scheduling margin to observe that transition and release
820
+ # its lease, while interactive recall keeps the provider's
821
+ # normal timeout budget.
822
+ request_kwargs["timeout"] = 3.5
823
+ resp = client.post(endpoint, **request_kwargs)
743
824
  resp.raise_for_status()
825
+ if background_preempt_requested():
826
+ raise MaterializationDeferred(
827
+ "background embedding yielded to runtime transition"
828
+ )
744
829
  data = resp.json()
745
830
  if "data" not in data or not isinstance(data["data"], list):
746
831
  raise ValueError(
@@ -756,6 +841,12 @@ class EmbeddingService:
756
841
  )
757
842
  return results
758
843
  except Exception as exc:
844
+ if isinstance(exc, MaterializationDeferred):
845
+ raise
846
+ if background_preempt_requested():
847
+ raise MaterializationDeferred(
848
+ "background embedding yielded to runtime transition"
849
+ ) from exc
759
850
  last_error = exc
760
851
  if attempt < max_retries - 1:
761
852
  time.sleep(2 ** attempt)
@@ -795,15 +886,39 @@ class EmbeddingService:
795
886
  client = self._get_http_client()
796
887
  last_error: Exception | None = None
797
888
  for attempt in range(max_retries):
889
+ from superlocalmemory.core.materialization_control import (
890
+ MaterializationDeferred,
891
+ )
892
+ from superlocalmemory.core.recall_gate import (
893
+ background_preempt_requested,
894
+ is_background_work,
895
+ )
896
+ if background_preempt_requested():
897
+ raise MaterializationDeferred(
898
+ "background embedding yielded to runtime transition"
899
+ )
798
900
  try:
799
- resp = client.post(url, headers=headers, json=body)
901
+ request_kwargs = {"headers": headers, "json": body}
902
+ if is_background_work():
903
+ request_kwargs["timeout"] = 3.5
904
+ resp = client.post(url, **request_kwargs)
800
905
  resp.raise_for_status()
906
+ if background_preempt_requested():
907
+ raise MaterializationDeferred(
908
+ "background embedding yielded to runtime transition"
909
+ )
801
910
  data = resp.json()
802
911
  results = []
803
912
  for item in sorted(data["data"], key=lambda d: d["index"]):
804
913
  results.append(item["embedding"])
805
914
  return results
806
915
  except Exception as exc:
916
+ if isinstance(exc, MaterializationDeferred):
917
+ raise
918
+ if background_preempt_requested():
919
+ raise MaterializationDeferred(
920
+ "background embedding yielded to runtime transition"
921
+ ) from exc
807
922
  last_error = exc
808
923
  if attempt < max_retries - 1:
809
924
  time.sleep(2 ** attempt)
@@ -637,7 +637,12 @@ class MemoryEngine:
637
637
  except (ValueError, TypeError):
638
638
  _timeout_s = 0.5 # default 500 ms
639
639
  try:
640
- _future = self._store_fast_embed_pool.submit(_embedder_ref.embed, fact_text)
640
+ def _best_effort_embed():
641
+ from superlocalmemory.core.recall_gate import background_work
642
+ with background_work():
643
+ return _embedder_ref.embed(fact_text)
644
+
645
+ _future = self._store_fast_embed_pool.submit(_best_effort_embed)
641
646
  try:
642
647
  emb = _future.result(timeout=_timeout_s)
643
648
  if emb:
@@ -736,6 +736,11 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
736
736
  materialization_checkpoint=checkpoint_materialization,
737
737
  )
738
738
  except Exception as exc:
739
+ from superlocalmemory.core.materialization_control import (
740
+ MaterializationDeferred,
741
+ )
742
+ if isinstance(exc, MaterializationDeferred):
743
+ raise
739
744
  # ``run_store`` checkpoints the completed relational pipeline
740
745
  # immediately before post-hooks run. Prefer that durable ledger
741
746
  # over rebuilding state from local variables: a one-time hook
@@ -22,6 +22,7 @@ from dataclasses import dataclass, field
22
22
  from enum import Enum
23
23
  from typing import Any, Callable
24
24
 
25
+ from superlocalmemory.core.materialization_control import MaterializationDeferred
25
26
  from superlocalmemory.storage.database import DatabaseManager
26
27
 
27
28
  logger = logging.getLogger("superlocalmemory.ingestion_command")
@@ -606,6 +607,31 @@ class IngestionOperationRepository:
606
607
  ) from exc
607
608
  return self._from_row(rows[0])
608
609
 
610
+ def defer_enriching(
611
+ self,
612
+ operation_id: str,
613
+ *,
614
+ owner: str,
615
+ ) -> IngestionOperation:
616
+ """Release a transition-preempted lease without consuming a retry.
617
+
618
+ Queryable evidence remains durable. The compare-and-swap owner check
619
+ prevents a stale worker from requeueing work that another process has
620
+ already reclaimed.
621
+ """
622
+ rows = self.db.execute(
623
+ "UPDATE ingestion_operations SET state='queryable', "
624
+ "lease_owner='', lease_expires_at=0, next_retry_at=0, "
625
+ "attempt_count=CASE WHEN attempt_count > 0 THEN attempt_count - 1 ELSE 0 END, "
626
+ "last_error='', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
627
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
628
+ "RETURNING *",
629
+ (operation_id, owner),
630
+ )
631
+ if not rows:
632
+ raise InvalidStateTransition("enriching lease ownership was lost")
633
+ return self._from_row(rows[0])
634
+
609
635
  def reap_stuck_enriching(
610
636
  self,
611
637
  *,
@@ -936,6 +962,11 @@ class IngestionCommand:
936
962
  )
937
963
  except LeaseLost:
938
964
  raise
965
+ except MaterializationDeferred:
966
+ return self.repository.defer_enriching(
967
+ operation_id,
968
+ owner=self._owner,
969
+ )
939
970
  except Exception as exc:
940
971
  return self.repository.finish_enriching(
941
972
  operation_id,
@@ -983,6 +1014,11 @@ class IngestionCommand:
983
1014
  )
984
1015
  except LeaseLost:
985
1016
  raise
1017
+ except MaterializationDeferred:
1018
+ return self.repository.defer_enriching(
1019
+ operation.operation_id,
1020
+ owner=self._owner,
1021
+ )
986
1022
  except Exception as exc:
987
1023
  return self.repository.finish_enriching(
988
1024
  operation.operation_id,
@@ -0,0 +1,20 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3
4
+
5
+ """Control-flow signals for best-effort materialization work.
6
+
7
+ These exceptions deliberately live outside the ingestion state machine and
8
+ embedding implementation so either layer can request a durable deferral
9
+ without introducing an import cycle.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ class MaterializationDeferred(RuntimeError):
16
+ """Best-effort enrichment yielded to a runtime transition.
17
+
18
+ The queryable projection is already durable. This signal must be handled
19
+ by :class:`IngestionCommand` as a requeue, never as a failed attempt.
20
+ """