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.
- package/CHANGELOG.md +29 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/daemon.py +89 -16
- package/src/superlocalmemory/core/embeddings.py +35 -4
- package/src/superlocalmemory/core/ollama_embedder.py +11 -2
- package/src/superlocalmemory/core/remember_admission.py +14 -5
- package/src/superlocalmemory/hooks/adapter_base.py +10 -3
- package/src/superlocalmemory/mcp/tools_core.py +16 -5
- package/src/superlocalmemory/optimize/proxy/capture.py +148 -30
- package/src/superlocalmemory/optimize/storage/db.py +6 -2
- package/src/superlocalmemory/server/unified_daemon.py +8 -1
- package/src/superlocalmemory/storage/admission_codec.py +10 -0
- package/src/superlocalmemory/storage/admission_journal.py +182 -67
- package/src/superlocalmemory/storage/embedding_migrator.py +27 -13
- 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 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.
|
|
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> · MCP: add <code>slm_compress</code> to your config · 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.
|
|
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
package/plugin/CLAUDE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- BEGIN SuperLocalMemory v3.8.
|
|
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.
|
|
42
|
+
<!-- END SuperLocalMemory v3.8.10 -->
|
|
43
43
|
|
|
44
|
-
SuperLocalMemory v3.8.
|
|
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.
|
|
80
|
+
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.
|
|
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.
|
|
44
|
+
SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
|
package/plugin/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==3.8.
|
|
1
|
+
superlocalmemory==3.8.10
|
|
@@ -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.
|
|
131
|
+
SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
|
package/pyproject.toml
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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"] =
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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)
|