superlocalmemory 3.7.1 → 3.7.2
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 +10 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +11 -3
- package/plugin/scripts/slm-launch.bat +8 -2
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/scripts/slm-launch +11 -3
- package/plugin-src/scripts/slm-launch.bat +8 -2
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/embedding_worker.py +7 -1
- package/src/superlocalmemory/core/embeddings.py +8 -0
- package/src/superlocalmemory/core/engine.py +1 -3
- package/src/superlocalmemory/core/engine_wiring.py +0 -5
- package/src/superlocalmemory/core/ram_lock.py +42 -4
- package/src/superlocalmemory/evolution/budget.py +43 -8
- package/src/superlocalmemory/mcp/tools_core.py +46 -29
- package/src/superlocalmemory/mesh/broker.py +111 -61
- package/src/superlocalmemory/retrieval/spreading_activation.py +53 -3
- package/src/superlocalmemory/server/unified_daemon.py +24 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ 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.7.2] - 2026-07-16 — Reliability release
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Strengthened daemon-owned local write coordination across Mesh and MCP paths.
|
|
13
|
+
- Kept durable facts queryable when optional enrichment needs a controlled retry.
|
|
14
|
+
- Preserved graph-aware retrieval through the canonical fallback when sqlite-vec is unavailable.
|
|
15
|
+
- Bounded local embedding-worker dependency failures to prevent repeated worker respawns.
|
|
16
|
+
- Hardened Windows RAM reservations and cross-platform installer validation.
|
|
17
|
+
|
|
8
18
|
## [3.7.1] - 2026-07-16 — Installer-parity hotfix
|
|
9
19
|
|
|
10
20
|
### Fixed
|
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
<img src="https://superlocalmemory.com/assets/logo-mark.png" alt="SuperLocalMemory" width="200"/>
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
<h1 align="center">SuperLocalMemory V3.7.
|
|
5
|
+
<h1 align="center">SuperLocalMemory V3.7.2</h1>
|
|
6
6
|
<p align="center"><strong>Cache. Compress. Remember. Three surfaces — proxy, MCP tools, or skill. Every setup covered.</strong><br/>
|
|
7
7
|
<em>Local-first agent memory with explicit operating modes, auditable retrieval, and optional Optimize tools.</em></p>
|
|
8
|
-
<p align="center"><code>v3.7.
|
|
8
|
+
<p align="center"><code>v3.7.2</code> — <strong>Reliability release: durable local writes, cross-platform installation, and bounded enrichment recovery.</strong><br/>
|
|
9
9
|
Proxy: <code>slm wrap claude</code> · MCP: add <code>slm_compress</code> to your config · Skill: zero-config</p>
|
|
10
10
|
<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>
|
|
11
11
|
|
package/package.json
CHANGED
package/plugin/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==3.7.
|
|
1
|
+
superlocalmemory==3.7.2
|
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
# Cross-platform counterpart: slm-launch.bat (Windows)
|
|
5
5
|
# Referenced by plugin/.mcp.json as the MCP server command.
|
|
6
6
|
#
|
|
7
|
-
# Resolves the correct venv binary for POSIX
|
|
8
|
-
#
|
|
7
|
+
# Resolves the correct venv binary for POSIX and joins the namespace daemon
|
|
8
|
+
# before opening the MCP stdio transport. This preserves one writer for all
|
|
9
|
+
# parallel Claude sessions that share CLAUDE_PLUGIN_DATA.
|
|
9
10
|
#
|
|
10
11
|
# On Windows, Claude Code invokes slm-launch.bat instead (same-stem, .bat extension).
|
|
11
12
|
#
|
|
12
13
|
# Environment:
|
|
13
14
|
# CLAUDE_PLUGIN_DATA — persistent data dir where venv lives
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
SLM_BIN="${CLAUDE_PLUGIN_DATA}/venv/bin/slm"
|
|
17
|
+
|
|
18
|
+
if ! "${SLM_BIN}" serve start >&2; then
|
|
19
|
+
echo "SLM plugin: unable to start the owned daemon; refusing a direct MCP writer." >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
exec "${SLM_BIN}" mcp
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
:: Cross-platform counterpart: slm-launch (POSIX bash)
|
|
5
5
|
:: Referenced by plugin/.mcp.json as the MCP server command (Windows picks .bat automatically).
|
|
6
6
|
::
|
|
7
|
-
:: Resolves the correct venv binary for Windows
|
|
8
|
-
::
|
|
7
|
+
:: Resolves the correct venv binary for Windows and joins the namespace daemon
|
|
8
|
+
:: before opening MCP, preserving one writer for parallel Claude sessions.
|
|
9
9
|
::
|
|
10
10
|
:: On Windows, Python venv places entry points in Scripts\ (not bin\ like POSIX).
|
|
11
11
|
:: This launcher bridges the path difference so ONE .mcp.json command field works
|
|
@@ -14,4 +14,10 @@
|
|
|
14
14
|
:: Environment:
|
|
15
15
|
:: CLAUDE_PLUGIN_DATA — persistent data dir where venv lives
|
|
16
16
|
|
|
17
|
+
"%CLAUDE_PLUGIN_DATA%\venv\Scripts\slm.exe" serve start 1>&2
|
|
18
|
+
if errorlevel 1 (
|
|
19
|
+
echo SLM plugin: unable to start the owned daemon; refusing a direct MCP writer. 1>&2
|
|
20
|
+
exit /b 1
|
|
21
|
+
)
|
|
22
|
+
|
|
17
23
|
"%CLAUDE_PLUGIN_DATA%\venv\Scripts\slm.exe" mcp
|
package/plugin-src/manifest.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==3.7.
|
|
1
|
+
superlocalmemory==3.7.2
|
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
# Cross-platform counterpart: slm-launch.bat (Windows)
|
|
5
5
|
# Referenced by plugin/.mcp.json as the MCP server command.
|
|
6
6
|
#
|
|
7
|
-
# Resolves the correct venv binary for POSIX
|
|
8
|
-
#
|
|
7
|
+
# Resolves the correct venv binary for POSIX and joins the namespace daemon
|
|
8
|
+
# before opening the MCP stdio transport. This preserves one writer for all
|
|
9
|
+
# parallel Claude sessions that share CLAUDE_PLUGIN_DATA.
|
|
9
10
|
#
|
|
10
11
|
# On Windows, Claude Code invokes slm-launch.bat instead (same-stem, .bat extension).
|
|
11
12
|
#
|
|
12
13
|
# Environment:
|
|
13
14
|
# CLAUDE_PLUGIN_DATA — persistent data dir where venv lives
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
SLM_BIN="${CLAUDE_PLUGIN_DATA}/venv/bin/slm"
|
|
17
|
+
|
|
18
|
+
if ! "${SLM_BIN}" serve start >&2; then
|
|
19
|
+
echo "SLM plugin: unable to start the owned daemon; refusing a direct MCP writer." >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
exec "${SLM_BIN}" mcp
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
:: Cross-platform counterpart: slm-launch (POSIX bash)
|
|
5
5
|
:: Referenced by plugin/.mcp.json as the MCP server command (Windows picks .bat automatically).
|
|
6
6
|
::
|
|
7
|
-
:: Resolves the correct venv binary for Windows
|
|
8
|
-
::
|
|
7
|
+
:: Resolves the correct venv binary for Windows and joins the namespace daemon
|
|
8
|
+
:: before opening MCP, preserving one writer for parallel Claude sessions.
|
|
9
9
|
::
|
|
10
10
|
:: On Windows, Python venv places entry points in Scripts\ (not bin\ like POSIX).
|
|
11
11
|
:: This launcher bridges the path difference so ONE .mcp.json command field works
|
|
@@ -14,4 +14,10 @@
|
|
|
14
14
|
:: Environment:
|
|
15
15
|
:: CLAUDE_PLUGIN_DATA — persistent data dir where venv lives
|
|
16
16
|
|
|
17
|
+
"%CLAUDE_PLUGIN_DATA%\venv\Scripts\slm.exe" serve start 1>&2
|
|
18
|
+
if errorlevel 1 (
|
|
19
|
+
echo SLM plugin: unable to start the owned daemon; refusing a direct MCP writer. 1>&2
|
|
20
|
+
exit /b 1
|
|
21
|
+
)
|
|
22
|
+
|
|
17
23
|
"%CLAUDE_PLUGIN_DATA%\venv\Scripts\slm.exe" mcp
|
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.7.
|
|
35
|
+
__version__ = "3.7.2"
|
|
36
36
|
|
|
37
37
|
_REQUIRED_VERSIONS = {
|
|
38
38
|
"sentence_transformers": "5.3.0",
|
|
@@ -70,7 +70,13 @@ def _load_embedding_model(name: str) -> tuple:
|
|
|
70
70
|
|
|
71
71
|
Returns (model, backend_name) or (None, "").
|
|
72
72
|
"""
|
|
73
|
-
|
|
73
|
+
try:
|
|
74
|
+
from sentence_transformers import SentenceTransformer
|
|
75
|
+
except Exception:
|
|
76
|
+
# Dependency/version errors must stay inside the JSON-lines protocol.
|
|
77
|
+
# Letting this import escape kills the worker before it can explain the
|
|
78
|
+
# failure, which the parent previously mislabeled as a long timeout.
|
|
79
|
+
return None, ""
|
|
74
80
|
|
|
75
81
|
for backend in _embedding_backend_order():
|
|
76
82
|
try:
|
|
@@ -295,6 +295,8 @@ class EmbeddingService:
|
|
|
295
295
|
never hangs indefinitely on cold model loads or network issues.
|
|
296
296
|
"""
|
|
297
297
|
with self._lock:
|
|
298
|
+
if not self._available:
|
|
299
|
+
return None
|
|
298
300
|
# Worker recycling: restart after N requests to prevent
|
|
299
301
|
# C++ allocator fragmentation over long-running sessions.
|
|
300
302
|
if self._request_count >= _WORKER_RECYCLE_AFTER and self._worker_proc is not None:
|
|
@@ -339,6 +341,12 @@ class EmbeddingService:
|
|
|
339
341
|
resp = json.loads(resp_line)
|
|
340
342
|
if not resp.get("ok"):
|
|
341
343
|
logger.warning("Worker error: %s", resp.get("error"))
|
|
344
|
+
# A well-formed worker error is a terminal local
|
|
345
|
+
# dependency/model failure, not a transient pipe race.
|
|
346
|
+
# Disable this service and terminate the child so every
|
|
347
|
+
# recall does not respawn a heavyweight failing process.
|
|
348
|
+
self._available = False
|
|
349
|
+
self._kill_worker()
|
|
342
350
|
return None
|
|
343
351
|
self._reset_idle_timer()
|
|
344
352
|
self._request_count += 1
|
|
@@ -24,6 +24,7 @@ from typing import Any
|
|
|
24
24
|
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT, SLMConfig
|
|
25
25
|
from superlocalmemory.core.engine_capabilities import Capabilities, CapabilityError
|
|
26
26
|
from superlocalmemory.core.modes import get_capabilities
|
|
27
|
+
from superlocalmemory.learning.outcome_queue import RecallEvent, enqueue_recall
|
|
27
28
|
from superlocalmemory.storage.models import (
|
|
28
29
|
AtomicFact, MemoryRecord, Mode, RecallResponse,
|
|
29
30
|
)
|
|
@@ -664,9 +665,6 @@ class MemoryEngine:
|
|
|
664
665
|
# recall correctness (LLD-02 §4.9, LLD-08 §4.1).
|
|
665
666
|
if session_id:
|
|
666
667
|
try:
|
|
667
|
-
from superlocalmemory.learning.outcome_queue import (
|
|
668
|
-
RecallEvent, enqueue_recall,
|
|
669
|
-
)
|
|
670
668
|
fact_ids = tuple(
|
|
671
669
|
getattr(r.fact, "fact_id", "") or ""
|
|
672
670
|
for r in getattr(response, "results", [])
|
|
@@ -388,11 +388,6 @@ def _init_spreading_activation(
|
|
|
388
388
|
vector_store: Any,
|
|
389
389
|
) -> Any | None:
|
|
390
390
|
"""Create SpreadingActivation for Phase 3 5th retrieval channel."""
|
|
391
|
-
# V3.3.21: Guard against None vector_store. Without embeddings, SA's
|
|
392
|
-
# search() crashes with "'NoneType' has no attribute 'search'".
|
|
393
|
-
if vector_store is None:
|
|
394
|
-
logger.debug("SpreadingActivation skipped: no vector_store")
|
|
395
|
-
return None
|
|
396
391
|
try:
|
|
397
392
|
from superlocalmemory.retrieval.spreading_activation import (
|
|
398
393
|
SpreadingActivation,
|
|
@@ -15,12 +15,13 @@ Design notes:
|
|
|
15
15
|
available before we even try — better to defer than thrash.
|
|
16
16
|
- Lock file lives at ``~/.superlocalmemory/ram_lock.sem`` by default,
|
|
17
17
|
tests may monkeypatch ``RAM_LOCK_PATH``.
|
|
18
|
-
-
|
|
18
|
+
- Uses a standard-library advisory lock on POSIX and Windows. The lock is
|
|
19
|
+
deliberately file-backed so independent daemon, CLI, and worker processes
|
|
20
|
+
share the same RAM reservation.
|
|
19
21
|
"""
|
|
20
22
|
|
|
21
23
|
from __future__ import annotations
|
|
22
24
|
|
|
23
|
-
import fcntl
|
|
24
25
|
import os
|
|
25
26
|
import time
|
|
26
27
|
from contextlib import contextmanager
|
|
@@ -29,6 +30,11 @@ from typing import Iterator
|
|
|
29
30
|
|
|
30
31
|
import psutil
|
|
31
32
|
|
|
33
|
+
try: # POSIX advisory locks (macOS/Linux)
|
|
34
|
+
import fcntl as _fcntl
|
|
35
|
+
except ImportError: # Windows uses the stdlib byte-range equivalent below.
|
|
36
|
+
_fcntl = None
|
|
37
|
+
|
|
32
38
|
# Public override retained for tests and embedders. ``None`` means resolve the
|
|
33
39
|
# lock dynamically so a long-lived process can switch canonical namespaces
|
|
34
40
|
# without retaining the first root it observed.
|
|
@@ -44,6 +50,38 @@ def _ram_lock_path() -> Path:
|
|
|
44
50
|
return state_path("ram_lock.sem")
|
|
45
51
|
|
|
46
52
|
|
|
53
|
+
def _acquire_lock(fd: int) -> None:
|
|
54
|
+
"""Acquire one non-blocking cross-process lock for the semaphore."""
|
|
55
|
+
if _fcntl is not None:
|
|
56
|
+
_fcntl.flock(fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
import msvcrt
|
|
60
|
+
|
|
61
|
+
# ``msvcrt.locking`` cannot reserve an empty file. The marker byte is
|
|
62
|
+
# purely structural; the human-readable audit marker is written after the
|
|
63
|
+
# reservation is acquired.
|
|
64
|
+
if os.fstat(fd).st_size == 0:
|
|
65
|
+
os.write(fd, b"\0")
|
|
66
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
67
|
+
try:
|
|
68
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
raise BlockingIOError("RAM reservation is already held") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _release_lock(fd: int) -> None:
|
|
74
|
+
"""Release a reservation acquired by :func:`_acquire_lock`."""
|
|
75
|
+
if _fcntl is not None:
|
|
76
|
+
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
import msvcrt
|
|
80
|
+
|
|
81
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
82
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
83
|
+
|
|
84
|
+
|
|
47
85
|
@contextmanager
|
|
48
86
|
def ram_reservation(
|
|
49
87
|
name: str,
|
|
@@ -96,7 +134,7 @@ def ram_reservation(
|
|
|
96
134
|
deadline = time.time() + timeout_s
|
|
97
135
|
while True:
|
|
98
136
|
try:
|
|
99
|
-
|
|
137
|
+
_acquire_lock(fd)
|
|
100
138
|
break
|
|
101
139
|
except BlockingIOError:
|
|
102
140
|
if time.time() >= deadline:
|
|
@@ -114,7 +152,7 @@ def ram_reservation(
|
|
|
114
152
|
yield
|
|
115
153
|
finally:
|
|
116
154
|
try:
|
|
117
|
-
|
|
155
|
+
_release_lock(fd)
|
|
118
156
|
finally:
|
|
119
157
|
os.close(fd)
|
|
120
158
|
|
|
@@ -20,7 +20,6 @@ Author: Varun Pratap Bhardwaj / Qualixar
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
-
import fcntl
|
|
24
23
|
import logging
|
|
25
24
|
import os
|
|
26
25
|
import sqlite3
|
|
@@ -35,8 +34,44 @@ from superlocalmemory.core.security_primitives import safe_resolve_identifier
|
|
|
35
34
|
|
|
36
35
|
logger = logging.getLogger(__name__)
|
|
37
36
|
|
|
37
|
+
try: # POSIX advisory locks (macOS/Linux)
|
|
38
|
+
import fcntl as _fcntl
|
|
39
|
+
except ImportError: # Windows has no fcntl; use its stdlib byte-range lock.
|
|
40
|
+
_fcntl = None
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
|
|
43
|
+
def _acquire_profile_lock(fd: int) -> None:
|
|
44
|
+
"""Acquire a non-blocking cross-process lock on every supported OS."""
|
|
45
|
+
if _fcntl is not None:
|
|
46
|
+
_fcntl.flock(fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
import msvcrt
|
|
50
|
+
|
|
51
|
+
# ``msvcrt.locking`` cannot lock an empty file. Reserve one marker byte;
|
|
52
|
+
# the file content is diagnostic only and never a correctness signal.
|
|
53
|
+
if os.fstat(fd).st_size == 0:
|
|
54
|
+
os.write(fd, b"\0")
|
|
55
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
56
|
+
try:
|
|
57
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise BlockingIOError("evolution profile lock is held") from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _release_profile_lock(fd: int) -> None:
|
|
63
|
+
"""Release the lock acquired by :func:`_acquire_profile_lock`."""
|
|
64
|
+
if _fcntl is not None:
|
|
65
|
+
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
import msvcrt
|
|
69
|
+
|
|
70
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
71
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# M-P-07: path-component hints for filesystems where advisory locking is
|
|
40
75
|
# known to degrade to a no-op. We do NOT abort — some users accept the
|
|
41
76
|
# risk — but we emit a one-shot warning so a double-cycle is at least
|
|
42
77
|
# attributable to a known root cause. If/when we ship a threading-only
|
|
@@ -127,7 +162,7 @@ class EvolutionBudget:
|
|
|
127
162
|
)
|
|
128
163
|
self._lock_path = safe_stem.with_suffix(".lock")
|
|
129
164
|
# M-P-07: warn once per lock-path when we detect the lock sits on
|
|
130
|
-
# a known sync-backed filesystem.
|
|
165
|
+
# a known sync-backed filesystem. Advisory locking silently
|
|
131
166
|
# degrades on iCloud/OneDrive/Dropbox — two concurrent cycles
|
|
132
167
|
# would each acquire and burn LLM budget. Documentation-only for
|
|
133
168
|
# now; a future release may refuse to run until the lock_dir is
|
|
@@ -141,7 +176,7 @@ class EvolutionBudget:
|
|
|
141
176
|
if key not in _WARNED_SYNC_PATHS:
|
|
142
177
|
_WARNED_SYNC_PATHS.add(key)
|
|
143
178
|
logger.warning(
|
|
144
|
-
"evolution lock at %s lives under %r —
|
|
179
|
+
"evolution lock at %s lives under %r — advisory locking "
|
|
145
180
|
"may silently no-op on sync-backed filesystems. "
|
|
146
181
|
"Concurrent cycles could double-bill LLM cost. Move "
|
|
147
182
|
"the lock_dir off the sync root to make single-flight "
|
|
@@ -258,12 +293,12 @@ class EvolutionBudget:
|
|
|
258
293
|
f"cap={MAX_CYCLES_PER_DAY}",
|
|
259
294
|
)
|
|
260
295
|
|
|
261
|
-
# Single-flight lock (non-blocking
|
|
296
|
+
# Single-flight lock (non-blocking advisory lock). A second concurrent
|
|
262
297
|
# acquire raises BlockingIOError — surface as BudgetExhausted.
|
|
263
298
|
fd = os.open(str(self._lock_path), os.O_CREAT | os.O_RDWR, 0o600)
|
|
264
299
|
try:
|
|
265
300
|
try:
|
|
266
|
-
|
|
301
|
+
_acquire_profile_lock(fd)
|
|
267
302
|
except BlockingIOError as e:
|
|
268
303
|
os.close(fd)
|
|
269
304
|
raise BudgetExhausted(
|
|
@@ -296,7 +331,7 @@ class EvolutionBudget:
|
|
|
296
331
|
# Release lock before propagating — a failed cycle-record should
|
|
297
332
|
# not leave the lock dangling.
|
|
298
333
|
try:
|
|
299
|
-
|
|
334
|
+
_release_profile_lock(fd)
|
|
300
335
|
finally:
|
|
301
336
|
os.close(fd)
|
|
302
337
|
self._lock_fd = None
|
|
@@ -312,7 +347,7 @@ class EvolutionBudget:
|
|
|
312
347
|
finally:
|
|
313
348
|
self._cycle_start_mono = None
|
|
314
349
|
try:
|
|
315
|
-
|
|
350
|
+
_release_profile_lock(fd)
|
|
316
351
|
finally:
|
|
317
352
|
try:
|
|
318
353
|
os.close(fd)
|
|
@@ -149,35 +149,52 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
149
149
|
# is_daemon_running() and daemon_request() both use blocking urllib
|
|
150
150
|
# against the same uvicorn server — run in threads so the MCP
|
|
151
151
|
# event loop stays unblocked (#34 class bug).
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
152
|
+
daemon_owned = await _asyncio.to_thread(is_daemon_running)
|
|
153
|
+
if daemon_owned:
|
|
154
|
+
# A positively identified daemon owns this database. Never
|
|
155
|
+
# spawn a WorkerPool writer after a transient daemon failure:
|
|
156
|
+
# that creates the competing writers which SQLite WAL cannot
|
|
157
|
+
# support. Retry the canonical path, then return an explicit
|
|
158
|
+
# retryable result to the MCP client.
|
|
159
|
+
for attempt in range(3):
|
|
160
|
+
resp = await _asyncio.to_thread(daemon_request, "POST", "/remember", {
|
|
161
|
+
"content": content, "tags": tags, "metadata": meta,
|
|
162
|
+
"scope": scope, "shared_with": _shared_list,
|
|
163
|
+
"session_id": session_id,
|
|
164
|
+
"idempotency_key": effective_idempotency_key or None,
|
|
165
|
+
})
|
|
166
|
+
if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
|
|
167
|
+
fids = resp.get("fact_ids") or []
|
|
168
|
+
materialization_state = resp.get("materialization_state")
|
|
169
|
+
if materialization_state is None:
|
|
170
|
+
materialization_state = (
|
|
171
|
+
"complete" if resp.get("status") == "stored" else "queryable"
|
|
172
|
+
)
|
|
173
|
+
pending = materialization_state != "complete"
|
|
174
|
+
return {
|
|
175
|
+
"success": True,
|
|
176
|
+
"fact_ids": fids,
|
|
177
|
+
"count": int(resp.get("count", len(fids))),
|
|
178
|
+
"pending": pending,
|
|
179
|
+
"pending_id": resp.get("pending_id") if pending else None,
|
|
180
|
+
"operation_id": resp.get("operation_id"),
|
|
181
|
+
"materialization_state": materialization_state,
|
|
182
|
+
"message": (
|
|
183
|
+
"Stored through canonical daemon ingestion."
|
|
184
|
+
if not pending
|
|
185
|
+
else "Queryable now; canonical enrichment is still running."
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
if attempt < 2:
|
|
189
|
+
await _asyncio.sleep(0.05 * (attempt + 1))
|
|
190
|
+
return {
|
|
191
|
+
"success": False,
|
|
192
|
+
"retryable": True,
|
|
193
|
+
"error": (
|
|
194
|
+
"Canonical daemon is temporarily unavailable; retry the "
|
|
195
|
+
"same remember operation without starting a second writer."
|
|
196
|
+
),
|
|
197
|
+
}
|
|
181
198
|
except Exception as dexc:
|
|
182
199
|
logger.debug("MCP remember via daemon failed, pending fallback: %s", dexc)
|
|
183
200
|
|
|
@@ -19,7 +19,7 @@ import time
|
|
|
19
19
|
import uuid
|
|
20
20
|
from datetime import datetime, timezone
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import Any
|
|
22
|
+
from typing import Any, Callable, TypeVar
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger("superlocalmemory.mesh")
|
|
25
25
|
import os as _os
|
|
@@ -38,6 +38,11 @@ MAX_MESSAGE_SIZE = 4096 # 4KB cap — mesh messages are notifications, not data
|
|
|
38
38
|
MESSAGE_TTL_HOURS = 48 # Offline messages expire after 48h
|
|
39
39
|
MAX_QUEUED_PER_TARGET = 50 # Max unread messages per broadcast/project target
|
|
40
40
|
|
|
41
|
+
_T = TypeVar("_T")
|
|
42
|
+
_WRITE_RETRY_ATTEMPTS = 6
|
|
43
|
+
_WRITE_RETRY_BASE_SECONDS = 0.025
|
|
44
|
+
_WRITE_BUSY_TIMEOUT_MS = 250
|
|
45
|
+
|
|
41
46
|
|
|
42
47
|
class MeshBroker:
|
|
43
48
|
"""Lightweight mesh broker for SLM's unified daemon.
|
|
@@ -109,22 +114,64 @@ class MeshBroker:
|
|
|
109
114
|
# -- Connection helper --
|
|
110
115
|
|
|
111
116
|
def _conn(self) -> sqlite3.Connection:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
# WAL is configured during database initialization and persists with the
|
|
118
|
+
# database. Reissuing journal_mode=WAL for every short-lived mesh
|
|
119
|
+
# connection is itself a schema-level write that can contend with the
|
|
120
|
+
# daemon. Mesh writes below use bounded whole-transaction retries.
|
|
121
|
+
conn = sqlite3.connect(
|
|
122
|
+
self._db_path,
|
|
123
|
+
timeout=_WRITE_BUSY_TIMEOUT_MS / 1000,
|
|
124
|
+
)
|
|
125
|
+
conn.execute(f"PRAGMA busy_timeout={_WRITE_BUSY_TIMEOUT_MS}")
|
|
115
126
|
conn.row_factory = sqlite3.Row
|
|
116
127
|
return conn
|
|
117
128
|
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _is_transient_lock(exc: sqlite3.OperationalError) -> bool:
|
|
131
|
+
message = str(exc).lower()
|
|
132
|
+
return "database is locked" in message or "database is busy" in message
|
|
133
|
+
|
|
134
|
+
def _write_with_retry(
|
|
135
|
+
self,
|
|
136
|
+
operation: Callable[[sqlite3.Connection], _T],
|
|
137
|
+
) -> _T:
|
|
138
|
+
"""Run one idempotent mesh mutation with a bounded SQLite retry budget.
|
|
139
|
+
|
|
140
|
+
SQLite WAL lets reads continue while a write is in progress, but it
|
|
141
|
+
still permits a single writer. Retrying the entire short transaction on
|
|
142
|
+
a fresh connection avoids leaking a transient writer collision to an
|
|
143
|
+
agent heartbeat or mesh command.
|
|
144
|
+
"""
|
|
145
|
+
last_error: sqlite3.OperationalError | None = None
|
|
146
|
+
for attempt in range(_WRITE_RETRY_ATTEMPTS):
|
|
147
|
+
conn = self._conn()
|
|
148
|
+
try:
|
|
149
|
+
return operation(conn)
|
|
150
|
+
except sqlite3.OperationalError as exc:
|
|
151
|
+
if not self._is_transient_lock(exc):
|
|
152
|
+
raise
|
|
153
|
+
last_error = exc
|
|
154
|
+
try:
|
|
155
|
+
conn.rollback()
|
|
156
|
+
except sqlite3.Error:
|
|
157
|
+
pass
|
|
158
|
+
finally:
|
|
159
|
+
conn.close()
|
|
160
|
+
|
|
161
|
+
if attempt < _WRITE_RETRY_ATTEMPTS - 1:
|
|
162
|
+
time.sleep(_WRITE_RETRY_BASE_SECONDS * (2 ** attempt))
|
|
163
|
+
|
|
164
|
+
assert last_error is not None
|
|
165
|
+
raise last_error
|
|
166
|
+
|
|
118
167
|
# -- Peers --
|
|
119
168
|
|
|
120
169
|
def register_peer(self, session_id: str, summary: str = "",
|
|
121
170
|
host: str = "", port: int = 0,
|
|
122
171
|
project_path: str = "", agent_type: str = "unknown") -> dict:
|
|
123
|
-
conn
|
|
124
|
-
try:
|
|
172
|
+
def _register(conn: sqlite3.Connection) -> dict:
|
|
125
173
|
now = datetime.now(timezone.utc).isoformat()
|
|
126
|
-
|
|
127
|
-
host = self._host
|
|
174
|
+
effective_host = host or self._host
|
|
128
175
|
# Idempotent: update if same session_id exists
|
|
129
176
|
existing = conn.execute(
|
|
130
177
|
"SELECT peer_id FROM mesh_peers WHERE session_id = ?",
|
|
@@ -135,7 +182,7 @@ class MeshBroker:
|
|
|
135
182
|
conn.execute(
|
|
136
183
|
"UPDATE mesh_peers SET summary=?, host=?, port=?, last_heartbeat=?, "
|
|
137
184
|
"status='active', project_path=?, agent_type=? WHERE peer_id=?",
|
|
138
|
-
(summary,
|
|
185
|
+
(summary, effective_host, port, now, project_path, agent_type, peer_id),
|
|
139
186
|
)
|
|
140
187
|
else:
|
|
141
188
|
peer_id = str(uuid.uuid4())[:12]
|
|
@@ -143,7 +190,7 @@ class MeshBroker:
|
|
|
143
190
|
"INSERT INTO mesh_peers (peer_id, session_id, summary, status, host, port, "
|
|
144
191
|
"registered_at, last_heartbeat, project_path, agent_type) "
|
|
145
192
|
"VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)",
|
|
146
|
-
(peer_id, session_id, summary,
|
|
193
|
+
(peer_id, session_id, summary, effective_host, port, now, now, project_path, agent_type),
|
|
147
194
|
)
|
|
148
195
|
self._log_event(conn, "peer_registered", peer_id, {
|
|
149
196
|
"session_id": session_id, "project_path": project_path,
|
|
@@ -153,12 +200,11 @@ class MeshBroker:
|
|
|
153
200
|
# v3.4.6: Deliver pending broadcast/project messages on registration
|
|
154
201
|
pending = self._get_pending_for_peer(conn, peer_id, project_path)
|
|
155
202
|
return {"peer_id": peer_id, "ok": True, "pending_messages": len(pending)}
|
|
156
|
-
|
|
157
|
-
|
|
203
|
+
|
|
204
|
+
return self._write_with_retry(_register)
|
|
158
205
|
|
|
159
206
|
def deregister_peer(self, peer_id: str) -> dict:
|
|
160
|
-
conn
|
|
161
|
-
try:
|
|
207
|
+
def _deregister(conn: sqlite3.Connection) -> dict:
|
|
162
208
|
row = conn.execute("SELECT 1 FROM mesh_peers WHERE peer_id=?", (peer_id,)).fetchone()
|
|
163
209
|
if not row:
|
|
164
210
|
return {"ok": False, "error": "peer not found"}
|
|
@@ -166,12 +212,11 @@ class MeshBroker:
|
|
|
166
212
|
self._log_event(conn, "peer_deregistered", peer_id)
|
|
167
213
|
conn.commit()
|
|
168
214
|
return {"ok": True}
|
|
169
|
-
|
|
170
|
-
|
|
215
|
+
|
|
216
|
+
return self._write_with_retry(_deregister)
|
|
171
217
|
|
|
172
218
|
def heartbeat(self, peer_id: str) -> dict:
|
|
173
|
-
conn
|
|
174
|
-
try:
|
|
219
|
+
def _heartbeat(conn: sqlite3.Connection) -> dict:
|
|
175
220
|
now = datetime.now(timezone.utc).isoformat()
|
|
176
221
|
cursor = conn.execute(
|
|
177
222
|
"UPDATE mesh_peers SET last_heartbeat=?, status='active' WHERE peer_id=?",
|
|
@@ -181,12 +226,11 @@ class MeshBroker:
|
|
|
181
226
|
return {"ok": False, "error": "peer not found"}
|
|
182
227
|
conn.commit()
|
|
183
228
|
return {"ok": True}
|
|
184
|
-
|
|
185
|
-
|
|
229
|
+
|
|
230
|
+
return self._write_with_retry(_heartbeat)
|
|
186
231
|
|
|
187
232
|
def update_summary(self, peer_id: str, summary: str) -> dict:
|
|
188
|
-
conn
|
|
189
|
-
try:
|
|
233
|
+
def _update_summary(conn: sqlite3.Connection) -> dict:
|
|
190
234
|
cursor = conn.execute(
|
|
191
235
|
"UPDATE mesh_peers SET summary=? WHERE peer_id=?",
|
|
192
236
|
(summary, peer_id),
|
|
@@ -195,8 +239,8 @@ class MeshBroker:
|
|
|
195
239
|
return {"ok": False, "error": "peer not found"}
|
|
196
240
|
conn.commit()
|
|
197
241
|
return {"ok": True}
|
|
198
|
-
|
|
199
|
-
|
|
242
|
+
|
|
243
|
+
return self._write_with_retry(_update_summary)
|
|
200
244
|
|
|
201
245
|
def list_peers(self) -> list[dict]:
|
|
202
246
|
conn = self._conn()
|
|
@@ -219,8 +263,19 @@ class MeshBroker:
|
|
|
219
263
|
return {"ok": False, "error": f"message too large ({len(content)} bytes, max {MAX_MESSAGE_SIZE}). "
|
|
220
264
|
"Mesh messages are notifications — reference a file path instead."}
|
|
221
265
|
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
# Remote delivery is an external side effect, so do it outside the
|
|
267
|
+
# retry envelope. Local writes below are retried as a whole short
|
|
268
|
+
# transaction when another daemon-owned operation has SQLite's writer.
|
|
269
|
+
if to_peer in self._remote_peers and self._sync_client:
|
|
270
|
+
return self._sync_client.send_to_remote(to_peer, {
|
|
271
|
+
"from_peer": from_peer,
|
|
272
|
+
"to": to_peer,
|
|
273
|
+
"content": content,
|
|
274
|
+
"type": msg_type,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
def _send(conn: sqlite3.Connection) -> dict:
|
|
278
|
+
nonlocal to_peer, project_path
|
|
224
279
|
now = datetime.now(timezone.utc).isoformat()
|
|
225
280
|
expires_at = self._compute_expires(now)
|
|
226
281
|
|
|
@@ -233,14 +288,6 @@ class MeshBroker:
|
|
|
233
288
|
to_peer = "project"
|
|
234
289
|
else:
|
|
235
290
|
target_type = "peer"
|
|
236
|
-
# Check if this is a remote peer — proxy to remote SLM
|
|
237
|
-
if to_peer in self._remote_peers and self._sync_client:
|
|
238
|
-
return self._sync_client.send_to_remote(to_peer, {
|
|
239
|
-
"from_peer": from_peer,
|
|
240
|
-
"to": to_peer,
|
|
241
|
-
"content": content,
|
|
242
|
-
"type": msg_type,
|
|
243
|
-
})
|
|
244
291
|
# Verify recipient exists for direct messages
|
|
245
292
|
if not conn.execute("SELECT 1 FROM mesh_peers WHERE peer_id=?", (to_peer,)).fetchone():
|
|
246
293
|
return {"ok": False, "error": "recipient peer not found"}
|
|
@@ -272,8 +319,8 @@ class MeshBroker:
|
|
|
272
319
|
conn.commit()
|
|
273
320
|
return {"ok": True, "id": cursor.lastrowid, "target_type": target_type,
|
|
274
321
|
"expires_at": expires_at}
|
|
275
|
-
|
|
276
|
-
|
|
322
|
+
|
|
323
|
+
return self._write_with_retry(_send)
|
|
277
324
|
|
|
278
325
|
def get_inbox(self, peer_id: str, project_path: str = "") -> list[dict]:
|
|
279
326
|
"""Get all messages for this peer: direct + broadcast + project."""
|
|
@@ -331,8 +378,7 @@ class MeshBroker:
|
|
|
331
378
|
conn.close()
|
|
332
379
|
|
|
333
380
|
def mark_read(self, peer_id: str, message_ids: list[int]) -> dict:
|
|
334
|
-
conn
|
|
335
|
-
try:
|
|
381
|
+
def _mark_read(conn: sqlite3.Connection) -> dict:
|
|
336
382
|
now = datetime.now(timezone.utc).isoformat()
|
|
337
383
|
for msg_id in message_ids:
|
|
338
384
|
# Check if this is a direct message or broadcast/project
|
|
@@ -356,8 +402,8 @@ class MeshBroker:
|
|
|
356
402
|
)
|
|
357
403
|
conn.commit()
|
|
358
404
|
return {"ok": True, "marked": len(message_ids)}
|
|
359
|
-
|
|
360
|
-
|
|
405
|
+
|
|
406
|
+
return self._write_with_retry(_mark_read)
|
|
361
407
|
|
|
362
408
|
# -- State --
|
|
363
409
|
|
|
@@ -370,8 +416,7 @@ class MeshBroker:
|
|
|
370
416
|
conn.close()
|
|
371
417
|
|
|
372
418
|
def set_state(self, key: str, value: str, set_by: str) -> dict:
|
|
373
|
-
conn
|
|
374
|
-
try:
|
|
419
|
+
def _set_state(conn: sqlite3.Connection) -> dict:
|
|
375
420
|
now = datetime.now(timezone.utc).isoformat()
|
|
376
421
|
conn.execute(
|
|
377
422
|
"INSERT INTO mesh_state (key, value, set_by, updated_at) VALUES (?, ?, ?, ?) "
|
|
@@ -380,8 +425,8 @@ class MeshBroker:
|
|
|
380
425
|
)
|
|
381
426
|
conn.commit()
|
|
382
427
|
return {"ok": True}
|
|
383
|
-
|
|
384
|
-
|
|
428
|
+
|
|
429
|
+
return self._write_with_retry(_set_state)
|
|
385
430
|
|
|
386
431
|
def get_state_key(self, key: str) -> dict | None:
|
|
387
432
|
conn = self._conn()
|
|
@@ -396,8 +441,23 @@ class MeshBroker:
|
|
|
396
441
|
# -- Locks --
|
|
397
442
|
|
|
398
443
|
def lock_action(self, file_path: str, locked_by: str, action: str) -> dict:
|
|
399
|
-
|
|
400
|
-
|
|
444
|
+
if action == "query":
|
|
445
|
+
conn = self._conn()
|
|
446
|
+
try:
|
|
447
|
+
row = conn.execute(
|
|
448
|
+
"SELECT locked_by, locked_at FROM mesh_locks WHERE file_path=?",
|
|
449
|
+
(file_path,),
|
|
450
|
+
).fetchone()
|
|
451
|
+
if row:
|
|
452
|
+
return {"locked": True, "by": row["locked_by"], "since": row["locked_at"]}
|
|
453
|
+
return {"locked": False}
|
|
454
|
+
finally:
|
|
455
|
+
conn.close()
|
|
456
|
+
|
|
457
|
+
if action not in {"acquire", "release"}:
|
|
458
|
+
return {"ok": False, "error": f"unknown action: {action}"}
|
|
459
|
+
|
|
460
|
+
def _lock_action(conn: sqlite3.Connection) -> dict:
|
|
401
461
|
now = datetime.now(timezone.utc).isoformat()
|
|
402
462
|
|
|
403
463
|
if action == "acquire":
|
|
@@ -429,18 +489,9 @@ class MeshBroker:
|
|
|
429
489
|
return {"ok": False, "action": "not_released",
|
|
430
490
|
"error": "no lock held by this peer for that file"}
|
|
431
491
|
|
|
432
|
-
|
|
433
|
-
row = conn.execute(
|
|
434
|
-
"SELECT locked_by, locked_at FROM mesh_locks WHERE file_path=?",
|
|
435
|
-
(file_path,),
|
|
436
|
-
).fetchone()
|
|
437
|
-
if row:
|
|
438
|
-
return {"locked": True, "by": row["locked_by"], "since": row["locked_at"]}
|
|
439
|
-
return {"locked": False}
|
|
492
|
+
raise AssertionError("validated action was not handled")
|
|
440
493
|
|
|
441
|
-
|
|
442
|
-
finally:
|
|
443
|
-
conn.close()
|
|
494
|
+
return self._write_with_retry(_lock_action)
|
|
444
495
|
|
|
445
496
|
# -- Helpers (v3.4.6) --
|
|
446
497
|
|
|
@@ -527,8 +578,7 @@ class MeshBroker:
|
|
|
527
578
|
logger.debug("Mesh cleanup error: %s", exc)
|
|
528
579
|
|
|
529
580
|
def _run_cleanup(self) -> None:
|
|
530
|
-
conn
|
|
531
|
-
try:
|
|
581
|
+
def _cleanup(conn: sqlite3.Connection) -> None:
|
|
532
582
|
now = datetime.now(timezone.utc)
|
|
533
583
|
now_iso = now.isoformat()
|
|
534
584
|
# Mark stale peers (no heartbeat for 5 min)
|
|
@@ -572,5 +622,5 @@ class MeshBroker:
|
|
|
572
622
|
(now_iso,),
|
|
573
623
|
)
|
|
574
624
|
conn.commit()
|
|
575
|
-
|
|
576
|
-
|
|
625
|
+
|
|
626
|
+
self._write_with_retry(_cleanup)
|
|
@@ -93,7 +93,7 @@ class SpreadingActivation:
|
|
|
93
93
|
def __init__(
|
|
94
94
|
self,
|
|
95
95
|
db: Any,
|
|
96
|
-
vector_store: Any,
|
|
96
|
+
vector_store: Any | None,
|
|
97
97
|
config: SpreadingActivationConfig | None = None,
|
|
98
98
|
) -> None:
|
|
99
99
|
self._db = db
|
|
@@ -122,8 +122,11 @@ class SpreadingActivation:
|
|
|
122
122
|
include_shared = bool(getattr(self, "include_shared", False))
|
|
123
123
|
try:
|
|
124
124
|
# Step 0: Get seed nodes from VectorStore KNN
|
|
125
|
-
seed_results = self.
|
|
126
|
-
query,
|
|
125
|
+
seed_results = self._seed_search(
|
|
126
|
+
query,
|
|
127
|
+
profile_id,
|
|
128
|
+
include_global=include_global,
|
|
129
|
+
include_shared=include_shared,
|
|
127
130
|
)
|
|
128
131
|
# Owner-partitioned vector indexes cannot discover opted-in peers.
|
|
129
132
|
# Add visible external embeddings with the same cosine seed signal.
|
|
@@ -216,6 +219,53 @@ class SpreadingActivation:
|
|
|
216
219
|
)
|
|
217
220
|
return []
|
|
218
221
|
|
|
222
|
+
def _seed_search(
|
|
223
|
+
self,
|
|
224
|
+
query: Any,
|
|
225
|
+
profile_id: str,
|
|
226
|
+
*,
|
|
227
|
+
include_global: bool,
|
|
228
|
+
include_shared: bool,
|
|
229
|
+
) -> list[tuple[str, float]]:
|
|
230
|
+
"""Return bounded semantic graph seeds from vec0 or canonical SQLite.
|
|
231
|
+
|
|
232
|
+
sqlite-vec is an acceleration projection, not a prerequisite for the
|
|
233
|
+
graph retrieval layer. When it is unavailable on a platform, use the
|
|
234
|
+
canonical stored embeddings and the same cosine signal as the
|
|
235
|
+
cross-profile supplement below. This keeps spreading activation
|
|
236
|
+
present and truthful rather than silently removing a retrieval layer.
|
|
237
|
+
"""
|
|
238
|
+
if self._vector_store is not None and getattr(
|
|
239
|
+
self._vector_store, "available", False,
|
|
240
|
+
):
|
|
241
|
+
return self._vector_store.search(
|
|
242
|
+
query, top_k=self._config.top_m, profile_id=profile_id,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
q_vec = np.array(query, dtype=np.float32)
|
|
246
|
+
q_norm = float(np.linalg.norm(q_vec))
|
|
247
|
+
if q_norm <= 1e-8:
|
|
248
|
+
return []
|
|
249
|
+
facts = self._db.get_all_facts(
|
|
250
|
+
profile_id,
|
|
251
|
+
include_global=include_global,
|
|
252
|
+
include_shared=include_shared,
|
|
253
|
+
)
|
|
254
|
+
scored: list[tuple[str, float]] = []
|
|
255
|
+
for fact in facts:
|
|
256
|
+
embedding = getattr(fact, "embedding", None)
|
|
257
|
+
if embedding is None:
|
|
258
|
+
continue
|
|
259
|
+
fact_vec = np.array(embedding, dtype=np.float32)
|
|
260
|
+
if fact_vec.shape != q_vec.shape:
|
|
261
|
+
continue
|
|
262
|
+
denominator = q_norm * float(np.linalg.norm(fact_vec))
|
|
263
|
+
if denominator <= 1e-8:
|
|
264
|
+
continue
|
|
265
|
+
score = (float(np.dot(q_vec, fact_vec) / denominator) + 1.0) / 2.0
|
|
266
|
+
scored.append((fact.fact_id, score))
|
|
267
|
+
return sorted(scored, key=lambda item: item[1], reverse=True)[:self._config.top_m]
|
|
268
|
+
|
|
219
269
|
def _propagate(
|
|
220
270
|
self,
|
|
221
271
|
seeds: list[tuple[str, float]],
|
|
@@ -2138,18 +2138,33 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2138
2138
|
))
|
|
2139
2139
|
|
|
2140
2140
|
result = command.materialize(receipt.operation_id) if wait else receipt
|
|
2141
|
-
if result.state is IngestionState.FAILED:
|
|
2142
|
-
raise RuntimeError(result.last_error or "materialization failed")
|
|
2143
|
-
|
|
2144
2141
|
fact_ids = list(result.fact_ids)
|
|
2142
|
+
# The queryable write is a separate durable transaction. A cold
|
|
2143
|
+
# optional enrichment dependency (most often the local embedding
|
|
2144
|
+
# worker) may need its bounded retry window, but must not turn an
|
|
2145
|
+
# already-admitted fact into an HTTP 500. Keep the operation's
|
|
2146
|
+
# failed state truthful so the daemon materializer retries it; the
|
|
2147
|
+
# response communicates that the fact is queryable, not complete.
|
|
2148
|
+
enrichment_deferred = (
|
|
2149
|
+
result.state is IngestionState.FAILED and bool(fact_ids)
|
|
2150
|
+
)
|
|
2151
|
+
if result.state is IngestionState.FAILED and not enrichment_deferred:
|
|
2152
|
+
raise RuntimeError(result.last_error or "materialization failed")
|
|
2153
|
+
completed = result.state is IngestionState.COMPLETE
|
|
2145
2154
|
_emit_event(
|
|
2146
|
-
"memory.stored" if
|
|
2155
|
+
"memory.stored" if completed else "memory.queued",
|
|
2147
2156
|
payload={
|
|
2148
2157
|
"operation_id": result.operation_id,
|
|
2149
2158
|
"fact_ids": fact_ids,
|
|
2150
2159
|
"tags": req.tags or "",
|
|
2151
2160
|
"content_preview": req.content[:120],
|
|
2152
|
-
"path":
|
|
2161
|
+
"path": (
|
|
2162
|
+
"remember_sync"
|
|
2163
|
+
if completed
|
|
2164
|
+
else "remember_sync_deferred"
|
|
2165
|
+
if enrichment_deferred
|
|
2166
|
+
else "remember_queryable"
|
|
2167
|
+
),
|
|
2153
2168
|
},
|
|
2154
2169
|
)
|
|
2155
2170
|
return {
|
|
@@ -2160,11 +2175,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2160
2175
|
# One-release compatibility alias. The durable operation ID is
|
|
2161
2176
|
# opaque and replaces the integer pending.db row identifier.
|
|
2162
2177
|
"pending_id": result.operation_id,
|
|
2163
|
-
"status": "stored" if
|
|
2178
|
+
"status": "stored" if completed else "queryable",
|
|
2164
2179
|
"materialization_state": result.state.value,
|
|
2165
2180
|
"note": (
|
|
2166
2181
|
"canonical ingestion complete"
|
|
2167
|
-
if
|
|
2182
|
+
if completed
|
|
2183
|
+
else "queryable now; canonical enrichment will retry"
|
|
2184
|
+
if enrichment_deferred
|
|
2168
2185
|
else "queryable now; canonical enrichment pending"
|
|
2169
2186
|
),
|
|
2170
2187
|
}
|