superlocalmemory 3.8.9 → 3.8.10

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 (45) hide show
  1. package/CHANGELOG.md +29 -0
  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/cli/daemon.py +89 -16
  34. package/src/superlocalmemory/core/embeddings.py +35 -4
  35. package/src/superlocalmemory/core/ollama_embedder.py +11 -2
  36. package/src/superlocalmemory/core/remember_admission.py +14 -5
  37. package/src/superlocalmemory/hooks/adapter_base.py +10 -3
  38. package/src/superlocalmemory/mcp/tools_core.py +16 -5
  39. package/src/superlocalmemory/optimize/proxy/capture.py +148 -30
  40. package/src/superlocalmemory/optimize/storage/db.py +6 -2
  41. package/src/superlocalmemory/server/unified_daemon.py +8 -1
  42. package/src/superlocalmemory/storage/admission_codec.py +10 -0
  43. package/src/superlocalmemory/storage/admission_journal.py +182 -67
  44. package/src/superlocalmemory/storage/embedding_migrator.py +27 -13
  45. package/src/superlocalmemory/storage/write_coordinator.py +68 -21
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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.10] - 2026-07-29 — Reliable startup and MCP writes
9
+
10
+ ### Fixed
11
+ - Concurrent remember calls now share one bounded SQLite writer deadline
12
+ across local and database contention. Idempotent retries avoid redundant
13
+ journal writes, and the advisory dispatch marker no longer adds a second
14
+ full disk sync before the canonical write.
15
+ - A remember that has already committed now returns its durable receipt even
16
+ if auxiliary journal reconciliation reaches the caller deadline. Retrying
17
+ returns the same fact instead of reporting an ambiguous failure.
18
+ - Streamable HTTP `remember` calls no longer risk freezing the daemon when a
19
+ transient daemon request fails. Fallback discovery and storage now run away
20
+ from the server event loop, and the running daemon remains the sole writer.
21
+ - Startup failures now preserve and report their original cause instead of
22
+ being replaced by a secondary `profile_runtime` error.
23
+ - Embedding repair now marks a fact complete only after its searchable vector
24
+ projection is durable. A failed projection remains pending and is retried
25
+ instead of silently reducing recall quality.
26
+ - Embedding providers that return the wrong vector dimension are rejected
27
+ before caching or storage, preventing mixed-dimension indexes.
28
+ - Windows shadow capture now preserves its owner-only file protection without
29
+ failing on newly created files on current Windows hosts.
30
+ - Release builds now reject extra, missing, or byte-different Python modules
31
+ across the npm package, Python wheel, and source distribution.
32
+
33
+ ### Notes
34
+ - Existing memories and configuration are preserved. No database migration is
35
+ required.
36
+
8
37
  ## [3.8.9] - 2026-07-27 — Reliable MCP recall and background processing
9
38
 
10
39
  ### Fixed
package/README.md CHANGED
@@ -5,15 +5,15 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- <h1 align="center">SuperLocalMemory V3.8.8</h1>
8
+ <h1 align="center">SuperLocalMemory V3.8.10</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.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/>
11
+ <p align="center"><code>v3.8.10</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.8-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.8 — Current Release"/></a>
16
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.10-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.10 — 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.9",
3
+ "version": "3.8.10",
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.9"
18
+ "version": "3.8.10"
19
19
  }
package/plugin/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- BEGIN SuperLocalMemory v3.8.9 -->
1
+ <!-- BEGIN SuperLocalMemory v3.8.10 -->
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.9 -->
42
+ <!-- END SuperLocalMemory v3.8.10 -->
43
43
 
44
- SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
80
+ SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
71
+ SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
49
+ SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
@@ -1 +1 @@
1
- superlocalmemory==3.8.9
1
+ superlocalmemory==3.8.10
@@ -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.9 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later*
248
+ *SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v3.8.9 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later*
148
+ *SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.10 · 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.9 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later
131
+ SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.10 · 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.8 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.10 · 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.9"
3
+ version = "3.8.10"
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.9"
35
+ __version__ = "3.8.10"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -52,6 +52,7 @@ _LEGACY_PORT = 8767 # backward-compat redirect
52
52
  _DEFAULT_IDLE_TIMEOUT = 0 # v3.4.3: 24/7 default (was 1800)
53
53
  _PID_FILE = None # test-only override; runtime resolution stays dynamic
54
54
  _PORT_FILE = None # test-only override; runtime resolution stays dynamic
55
+ _EXPECTED_DESCRIPTOR_UNSET = object()
55
56
 
56
57
 
57
58
  # ---------------------------------------------------------------------------
@@ -96,11 +97,18 @@ def _is_port_available(port: int) -> bool:
96
97
  """Return whether the daemon port can be exclusively bound right now."""
97
98
  try:
98
99
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as candidate:
99
- # The server binds with address reuse. Mirror that contract here:
100
- # a recently closed listener may leave TCP connections in
101
- # TIME_WAIT, which must not be mistaken for an active owner and
102
- # block a safe restart for the full TCP timeout.
103
- candidate.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
100
+ if sys.platform == "win32":
101
+ # Winsock SO_REUSEADDR can bind an address that is still
102
+ # occupied, so it cannot prove shutdown completion. Request
103
+ # exclusive ownership where available and otherwise use the
104
+ # default non-reuse bind contract.
105
+ exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None)
106
+ if exclusive is not None:
107
+ candidate.setsockopt(socket.SOL_SOCKET, exclusive, 1)
108
+ else:
109
+ # On POSIX, mirror Uvicorn's reuse contract so a closed
110
+ # listener's TIME_WAIT sockets do not block a safe restart.
111
+ candidate.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
104
112
  candidate.bind(("127.0.0.1", port))
105
113
  return True
106
114
  except OSError:
@@ -117,7 +125,13 @@ def _has_tcp_listener(port: int) -> bool:
117
125
  return False
118
126
 
119
127
 
120
- def wait_for_owned_daemon_shutdown(descriptor, timeout: float = 25.0) -> bool:
128
+ def wait_for_owned_daemon_shutdown(
129
+ descriptor,
130
+ timeout: float = 25.0,
131
+ *,
132
+ legacy_pid: int | None = None,
133
+ legacy_port: int | None = None,
134
+ ) -> bool:
121
135
  """Wait for the stopped instance *and* its TCP listener to be gone.
122
136
 
123
137
  Restart must never spawn a replacement just because the descriptor was
@@ -126,11 +140,17 @@ def wait_for_owned_daemon_shutdown(descriptor, timeout: float = 25.0) -> bool:
126
140
  SLM worker cleanup. A descriptor carries process creation time, so PID
127
141
  reuse cannot make this wait target an unrelated process.
128
142
  """
129
- port = descriptor.port if descriptor is not None else _DEFAULT_PORT
143
+ port = (
144
+ descriptor.port
145
+ if descriptor is not None
146
+ else legacy_port if legacy_port is not None else _DEFAULT_PORT
147
+ )
130
148
  deadline = time.monotonic() + timeout
131
149
  while time.monotonic() < deadline:
132
- process_alive = (
133
- descriptor is not None and _descriptor_process_is_alive(descriptor)
150
+ process_alive = bool(
151
+ _descriptor_process_is_alive(descriptor)
152
+ if descriptor is not None
153
+ else legacy_pid is not None and _is_verified_legacy_process(legacy_pid)
134
154
  )
135
155
  if not process_alive and _is_port_available(port):
136
156
  return True
@@ -238,10 +258,35 @@ def daemon_request(
238
258
  body: dict | None = None,
239
259
  *,
240
260
  timeout_seconds: float = 30.0,
261
+ expected_descriptor=_EXPECTED_DESCRIPTOR_UNSET,
262
+ expected_legacy: dict | None = None,
241
263
  ) -> dict | None:
242
264
  """Send a request only after validating the owned daemon identity."""
243
- descriptor = read_descriptor()
265
+ legacy = None
266
+ if expected_legacy is not None:
267
+ # Legacy daemons have no capability header. Bind the compatibility
268
+ # request to the captured PID+port and refuse to adopt a replacement
269
+ # descriptor or a different legacy process during this stop.
270
+ if read_descriptor() is not None:
271
+ return None
272
+ current_legacy = _verified_legacy_health()
273
+ if current_legacy is None or (
274
+ int(current_legacy.get("pid", -1))
275
+ != int(expected_legacy.get("pid", -2))
276
+ or int(current_legacy.get("_legacy_port", -1))
277
+ != int(expected_legacy.get("_legacy_port", -2))
278
+ ):
279
+ return None
280
+ descriptor = None
281
+ legacy = current_legacy
282
+ else:
283
+ descriptor = (
284
+ read_descriptor()
285
+ if expected_descriptor is _EXPECTED_DESCRIPTOR_UNSET
286
+ else expected_descriptor
287
+ )
244
288
  capability: str | None = None
289
+ target_instance: str | None = None
245
290
  if descriptor is not None:
246
291
  health = _fetch_health(descriptor.port)
247
292
  if health is None or not descriptor_matches_health(descriptor, health):
@@ -250,10 +295,11 @@ def daemon_request(
250
295
  return health
251
296
  port = descriptor.port
252
297
  capability = descriptor.capability
298
+ target_instance = descriptor.instance_id
253
299
  elif descriptor_path().exists():
254
300
  return None
255
301
  else:
256
- legacy = _verified_legacy_health()
302
+ legacy = legacy or _verified_legacy_health()
257
303
  if legacy is None:
258
304
  return None
259
305
  if method.upper() == "GET" and path == "/health":
@@ -264,9 +310,9 @@ def daemon_request(
264
310
  url = f"http://127.0.0.1:{port}{path}"
265
311
  data = json.dumps(body).encode() if body else None
266
312
  headers = {"Content-Type": "application/json"} if data else {}
267
- if capability is not None:
313
+ if capability is not None and target_instance is not None:
268
314
  headers["X-SLM-Daemon-Capability"] = capability
269
- headers["X-SLM-Target-Instance"] = descriptor.instance_id
315
+ headers["X-SLM-Target-Instance"] = target_instance
270
316
  # Daemon ownership proves that this CLI targets the local instance; it
271
317
  # does not replace a dashboard user's profile-scoped authorization in
272
318
  # governed workspaces. The user opts in by supplying an explicit
@@ -505,8 +551,35 @@ def stop_daemon() -> bool:
505
551
  Machine-wide process-name scans are forbidden: they can kill another SLM
506
552
  installation or a user's live workers during tests. V3.7 uses the owned
507
553
  HTTP capability; the daemon itself terminates its child process tree.
554
+ Success means the owned process exited and released its listener, not just
555
+ that the asynchronous stop request was accepted.
508
556
  """
509
- if read_descriptor() is None and _verified_legacy_health() is None:
557
+ descriptor = read_descriptor()
558
+ legacy = _verified_legacy_health() if descriptor is None else None
559
+ if descriptor is None and legacy is None:
560
+ return False
561
+ if descriptor is not None:
562
+ response = daemon_request(
563
+ "POST",
564
+ "/stop",
565
+ expected_descriptor=descriptor,
566
+ )
567
+ else:
568
+ if legacy is None:
569
+ return False
570
+ response = daemon_request(
571
+ "POST",
572
+ "/stop",
573
+ expected_legacy=legacy,
574
+ )
575
+ if not response or response.get("status") != "stopping":
576
+ return False
577
+ if descriptor is not None:
578
+ return wait_for_owned_daemon_shutdown(descriptor)
579
+ if legacy is None:
510
580
  return False
511
- response = daemon_request("POST", "/stop")
512
- return bool(response and response.get("status") == "stopping")
581
+ return wait_for_owned_daemon_shutdown(
582
+ None,
583
+ legacy_pid=int(legacy["pid"]),
584
+ legacy_port=int(legacy["_legacy_port"]),
585
+ )
@@ -24,8 +24,9 @@ import sys
24
24
  import threading
25
25
  import time
26
26
  import weakref
27
+ from contextlib import contextmanager
27
28
  from pathlib import Path
28
- from typing import TYPE_CHECKING
29
+ from typing import TYPE_CHECKING, Iterator
29
30
 
30
31
  import numpy as np
31
32
 
@@ -313,6 +314,38 @@ class EmbeddingService:
313
314
  # Public API
314
315
  # ------------------------------------------------------------------
315
316
 
317
+ @contextmanager
318
+ def _request_lock(self) -> Iterator[None]:
319
+ """Admit one model request without starving an interactive recall.
320
+
321
+ A background caller may pass the recall gate and then queue behind an
322
+ in-flight model request. If a recall arrives while it is queued, a
323
+ plain mutex can let that background caller retake the worker first.
324
+ Re-check the gate after acquiring the mutex so at most the already
325
+ running background request can delay a newly arrived recall.
326
+ """
327
+ from superlocalmemory.core.recall_gate import (
328
+ in_flight,
329
+ is_background_work,
330
+ wait_for_foreground_idle,
331
+ )
332
+
333
+ if not is_background_work():
334
+ with self._lock:
335
+ yield
336
+ return
337
+
338
+ while True:
339
+ wait_for_foreground_idle()
340
+ self._lock.acquire()
341
+ if in_flight() == 0:
342
+ break
343
+ self._lock.release()
344
+ try:
345
+ yield
346
+ finally:
347
+ self._lock.release()
348
+
316
349
  def embed(self, text: str) -> list[float] | None:
317
350
  """Embed a single text string. Returns list of floats or None."""
318
351
  if not text or not text.strip():
@@ -413,9 +446,7 @@ class EmbeddingService:
413
446
  Includes a timeout (_SUBPROCESS_RESPONSE_TIMEOUT seconds) so the CLI
414
447
  never hangs indefinitely on cold model loads or network issues.
415
448
  """
416
- from superlocalmemory.core.recall_gate import wait_for_foreground_idle
417
- wait_for_foreground_idle()
418
- with self._lock:
449
+ with self._request_lock():
419
450
  # Only an explicit terminal disable (``False``) short-circuits. A
420
451
  # ``None`` availability is the recall-health self-heal's "re-probe"
421
452
  # signal (recall_health._heal_embedder) — it must fall through and
@@ -227,7 +227,7 @@ class OllamaEmbedder:
227
227
  data = resp.json()
228
228
  # Ollama /api/embed returns {"embeddings": [[...]]}
229
229
  vec = data["embeddings"][0]
230
- return self._normalize(vec)
230
+ return self._normalize_checked(vec)
231
231
 
232
232
  def _call_ollama_embed_batch(self, texts: list[str]) -> list[list[float] | None]:
233
233
  """Call Ollama embed endpoint with batch input.
@@ -245,7 +245,16 @@ class OllamaEmbedder:
245
245
  resp.raise_for_status()
246
246
  data = resp.json()
247
247
  vectors = data.get("embeddings", [])
248
- return [self._normalize(v) for v in vectors]
248
+ return [self._normalize_checked(v) for v in vectors]
249
+
250
+ def _normalize_checked(self, vec: list[float]) -> list[float]:
251
+ """Reject provider drift before a vector can enter any durable store."""
252
+ actual = len(vec)
253
+ if actual != self._dimension:
254
+ raise ValueError(
255
+ f"Ollama embedding dimension {actual} != expected {self._dimension}"
256
+ )
257
+ return self._normalize(vec)
249
258
 
250
259
  @staticmethod
251
260
  def _normalize(vec: list[float]) -> list[float]:
@@ -98,6 +98,7 @@ class RememberService:
98
98
  dispatched = self._journal.mark_dispatched(
99
99
  prepared.journal_id,
100
100
  deadline=deadline,
101
+ known_prepared=prepared.state == "prepared",
101
102
  )
102
103
  if dispatched.original_receipt is not None:
103
104
  return RememberReceipt.from_mapping(dispatched.original_receipt)
@@ -118,11 +119,19 @@ class RememberService:
118
119
  if state in {"committed", "duplicate"}:
119
120
  if not isinstance(receipt, Mapping):
120
121
  raise AdmissionRejected("COMMAND_REJECTED: canonical result had no receipt")
121
- committed = self._journal.mark_committed(
122
- prepared.journal_id,
123
- receipt,
124
- deadline=deadline,
125
- )
122
+ try:
123
+ committed = self._journal.mark_committed(
124
+ prepared.journal_id,
125
+ receipt,
126
+ deadline=deadline,
127
+ )
128
+ except AdmissionJournalUnavailable:
129
+ # The canonical receipt is already durable and idempotent. Do
130
+ # not turn that committed write into an ambiguous client
131
+ # failure merely because the auxiliary journal exhausted the
132
+ # caller's remaining budget. The dispatched record is safe for
133
+ # retry/replay, which will recover the same immutable receipt.
134
+ return RememberReceipt.from_mapping(receipt)
126
135
  return RememberReceipt.from_mapping(committed.original_receipt or receipt)
127
136
 
128
137
  error_code = str(_result_value(result, "error_code") or "COMMAND_REJECTED")
@@ -26,7 +26,6 @@ from __future__ import annotations
26
26
  import hashlib
27
27
  import os
28
28
  import sqlite3
29
- import sys
30
29
  from dataclasses import dataclass
31
30
  from datetime import datetime, timezone
32
31
  from pathlib import Path
@@ -76,8 +75,11 @@ class Adapter(Protocol):
76
75
 
77
76
  def path_sha256(path: Path) -> str:
78
77
  """SHA-256 of the absolute path string, full 64-hex (never truncated)."""
79
- return hashlib.sha256(str(path.resolve() if path.exists()
80
- else path).encode("utf-8")).hexdigest()
78
+ # The identity must not depend on whether the target exists. On Windows,
79
+ # Path.resolve() can normalize an existing path differently from the same
80
+ # not-yet-created path, changing the sync-log key after the first write.
81
+ canonical = os.path.normcase(os.path.abspath(os.fspath(path)))
82
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
81
83
 
82
84
 
83
85
  def _now_iso() -> str:
@@ -252,6 +254,11 @@ def atomic_write(
252
254
  flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
253
255
  if hasattr(os, "O_NOFOLLOW") and _is_posix():
254
256
  flags |= os.O_NOFOLLOW # SEC — POSIX refuses symlinks
257
+ if hasattr(os, "O_BINARY") and not _is_posix():
258
+ # Windows file descriptors default to text mode, which rewrites LF
259
+ # bytes as CRLF. The sync log hashes the caller's original bytes, so
260
+ # text-mode conversion makes an unchanged file look modified forever.
261
+ flags |= os.O_BINARY
255
262
 
256
263
  mode = posix_mode if _is_posix() else windows_mode
257
264
  fd = os.open(str(tmp), flags, mode)