superlocalmemory 3.8.9 → 3.8.11
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 +65 -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/commands.py +13 -3
- 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/core/reranker_worker.py +59 -17
- package/src/superlocalmemory/hooks/adapter_base.py +10 -3
- package/src/superlocalmemory/learning/feedback.py +46 -4
- package/src/superlocalmemory/learning/pattern_miner.py +31 -11
- package/src/superlocalmemory/mcp/tools_active.py +115 -4
- 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/retrieval/reranker.py +52 -5
- package/src/superlocalmemory/server/unified_daemon.py +12 -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/migration_runner.py +9 -0
- package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/write_coordinator.py +68 -21
|
@@ -74,7 +74,7 @@ def _windows_owner_dacl(
|
|
|
74
74
|
win32api: Any,
|
|
75
75
|
win32con: Any,
|
|
76
76
|
win32security: Any,
|
|
77
|
-
) -> Any:
|
|
77
|
+
) -> tuple[Any, Any]:
|
|
78
78
|
"""Build one protected owner-only DACL for a Windows capture file."""
|
|
79
79
|
import ntsecuritycon
|
|
80
80
|
|
|
@@ -95,7 +95,53 @@ def _windows_owner_dacl(
|
|
|
95
95
|
ntsecuritycon.FILE_ALL_ACCESS,
|
|
96
96
|
owner_sid,
|
|
97
97
|
)
|
|
98
|
-
return dacl
|
|
98
|
+
return owner_sid, dacl
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _windows_dacl_is_owner_only(
|
|
102
|
+
security_descriptor: Any,
|
|
103
|
+
owner_sid: Any,
|
|
104
|
+
ntsecuritycon: Any,
|
|
105
|
+
win32security: Any,
|
|
106
|
+
) -> bool:
|
|
107
|
+
"""Return whether a live descriptor already matches the capture policy."""
|
|
108
|
+
def _same_sid(left: Any, right: Any) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
return (
|
|
111
|
+
win32security.ConvertSidToStringSid(left)
|
|
112
|
+
== win32security.ConvertSidToStringSid(right)
|
|
113
|
+
)
|
|
114
|
+
except Exception:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
control, _revision = security_descriptor.GetSecurityDescriptorControl()
|
|
118
|
+
if not control & win32security.SE_DACL_PROTECTED:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
descriptor_owner = security_descriptor.GetSecurityDescriptorOwner()
|
|
122
|
+
if descriptor_owner is None or not _same_sid(
|
|
123
|
+
descriptor_owner,
|
|
124
|
+
owner_sid,
|
|
125
|
+
):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
dacl = security_descriptor.GetSecurityDescriptorDacl()
|
|
129
|
+
if dacl is None or dacl.GetAceCount() != 1:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
ace = dacl.GetAce(0)
|
|
133
|
+
if not isinstance(ace, tuple) or len(ace) != 3:
|
|
134
|
+
return False
|
|
135
|
+
ace_header, access_mask, ace_sid = ace
|
|
136
|
+
if (
|
|
137
|
+
not isinstance(ace_header, tuple)
|
|
138
|
+
or not ace_header
|
|
139
|
+
or ace_header[0] != win32security.ACCESS_ALLOWED_ACE_TYPE
|
|
140
|
+
):
|
|
141
|
+
return False
|
|
142
|
+
if access_mask & ntsecuritycon.FILE_ALL_ACCESS != ntsecuritycon.FILE_ALL_ACCESS:
|
|
143
|
+
return False
|
|
144
|
+
return _same_sid(ace_sid, owner_sid)
|
|
99
145
|
|
|
100
146
|
|
|
101
147
|
def _open_windows_capture_append(path: Path) -> int:
|
|
@@ -117,7 +163,11 @@ def _open_windows_capture_append(path: Path) -> int:
|
|
|
117
163
|
# ever inheriting a broader parent DACL. For an existing file Windows
|
|
118
164
|
# ignores this descriptor; _enforce_owner_only_permissions replaces
|
|
119
165
|
# that DACL through the same WRITE_DAC-capable handle before writing.
|
|
120
|
-
dacl = _windows_owner_dacl(
|
|
166
|
+
owner_sid, dacl = _windows_owner_dacl(
|
|
167
|
+
win32api,
|
|
168
|
+
win32con,
|
|
169
|
+
win32security,
|
|
170
|
+
)
|
|
121
171
|
security_attributes = win32security.SECURITY_ATTRIBUTES()
|
|
122
172
|
security_attributes.bInheritHandle = False
|
|
123
173
|
security_attributes.SECURITY_DESCRIPTOR.SetSecurityDescriptorDacl(
|
|
@@ -129,21 +179,57 @@ def _open_windows_capture_append(path: Path) -> int:
|
|
|
129
179
|
win32security.SE_DACL_PROTECTED,
|
|
130
180
|
win32security.SE_DACL_PROTECTED,
|
|
131
181
|
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
182
|
+
desired_access = (
|
|
183
|
+
ntsecuritycon.FILE_APPEND_DATA
|
|
184
|
+
| ntsecuritycon.WRITE_DAC
|
|
185
|
+
| ntsecuritycon.READ_CONTROL
|
|
186
|
+
)
|
|
187
|
+
share_mode = (
|
|
135
188
|
win32con.FILE_SHARE_READ
|
|
136
189
|
| win32con.FILE_SHARE_WRITE
|
|
137
|
-
| win32con.FILE_SHARE_DELETE
|
|
138
|
-
|
|
139
|
-
|
|
190
|
+
| win32con.FILE_SHARE_DELETE
|
|
191
|
+
)
|
|
192
|
+
file_flags = (
|
|
140
193
|
win32con.FILE_ATTRIBUTE_NORMAL
|
|
141
194
|
# pywin32 does not export this SDK constant from win32con on
|
|
142
195
|
# every supported Python build. Keep the Microsoft-defined value
|
|
143
196
|
# as a named fallback rather than silently following a reparse.
|
|
144
|
-
| getattr(win32con, "FILE_FLAG_OPEN_REPARSE_POINT", 0x00200000)
|
|
145
|
-
None,
|
|
197
|
+
| getattr(win32con, "FILE_FLAG_OPEN_REPARSE_POINT", 0x00200000)
|
|
146
198
|
)
|
|
199
|
+
try:
|
|
200
|
+
# CREATE_NEW is the only race-safe proof that the protected
|
|
201
|
+
# SECURITY_ATTRIBUTES were applied to this exact file. OPEN_ALWAYS
|
|
202
|
+
# would require trusting GetLastError after the pywin32 wrapper has
|
|
203
|
+
# returned, which is not a documented preservation boundary.
|
|
204
|
+
handle = win32file.CreateFile(
|
|
205
|
+
os.fspath(path),
|
|
206
|
+
desired_access,
|
|
207
|
+
share_mode,
|
|
208
|
+
security_attributes,
|
|
209
|
+
getattr(win32con, "CREATE_NEW", 1),
|
|
210
|
+
file_flags,
|
|
211
|
+
None,
|
|
212
|
+
)
|
|
213
|
+
created_new = True
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
winerror = getattr(exc, "winerror", None)
|
|
216
|
+
if winerror is None and exc.args:
|
|
217
|
+
winerror = exc.args[0]
|
|
218
|
+
if winerror != getattr(win32con, "ERROR_FILE_EXISTS", 80):
|
|
219
|
+
raise
|
|
220
|
+
# The creation descriptor is ignored for existing files. Reopen
|
|
221
|
+
# the exact path without following a reparse point, then replace
|
|
222
|
+
# its DACL through this WRITE_DAC-capable handle before appending.
|
|
223
|
+
handle = win32file.CreateFile(
|
|
224
|
+
os.fspath(path),
|
|
225
|
+
desired_access,
|
|
226
|
+
share_mode,
|
|
227
|
+
None,
|
|
228
|
+
getattr(win32con, "OPEN_EXISTING", 3),
|
|
229
|
+
file_flags,
|
|
230
|
+
None,
|
|
231
|
+
)
|
|
232
|
+
created_new = False
|
|
147
233
|
file_info = win32file.GetFileInformationByHandle(handle)
|
|
148
234
|
if file_info[0] & win32con.FILE_ATTRIBUTE_REPARSE_POINT:
|
|
149
235
|
raise OSError(
|
|
@@ -151,25 +237,57 @@ def _open_windows_capture_append(path: Path) -> int:
|
|
|
151
237
|
"capture path is a Windows reparse point",
|
|
152
238
|
path,
|
|
153
239
|
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
win32security.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
240
|
+
if not created_new:
|
|
241
|
+
try:
|
|
242
|
+
query_flags = (
|
|
243
|
+
win32security.OWNER_SECURITY_INFORMATION
|
|
244
|
+
| win32security.DACL_SECURITY_INFORMATION
|
|
245
|
+
)
|
|
246
|
+
descriptor = win32security.GetSecurityInfo(
|
|
247
|
+
handle,
|
|
248
|
+
win32security.SE_FILE_OBJECT,
|
|
249
|
+
query_flags,
|
|
250
|
+
)
|
|
251
|
+
if not _windows_dacl_is_owner_only(
|
|
252
|
+
descriptor,
|
|
253
|
+
owner_sid,
|
|
254
|
+
ntsecuritycon,
|
|
255
|
+
win32security,
|
|
256
|
+
):
|
|
257
|
+
# Existing files ignore the creation security descriptor,
|
|
258
|
+
# so repair an unsafe DACL while this is still the original
|
|
259
|
+
# CreateFile handle carrying WRITE_DAC. Avoid rewriting an
|
|
260
|
+
# already-protected DACL: Windows may correctly deny that
|
|
261
|
+
# redundant mutation even though append access is allowed.
|
|
262
|
+
win32security.SetSecurityInfo(
|
|
263
|
+
handle,
|
|
264
|
+
win32security.SE_FILE_OBJECT,
|
|
265
|
+
win32security.DACL_SECURITY_INFORMATION
|
|
266
|
+
| win32security.PROTECTED_DACL_SECURITY_INFORMATION,
|
|
267
|
+
None,
|
|
268
|
+
None,
|
|
269
|
+
dacl,
|
|
270
|
+
None,
|
|
271
|
+
)
|
|
272
|
+
descriptor = win32security.GetSecurityInfo(
|
|
273
|
+
handle,
|
|
274
|
+
win32security.SE_FILE_OBJECT,
|
|
275
|
+
query_flags,
|
|
276
|
+
)
|
|
277
|
+
if not _windows_dacl_is_owner_only(
|
|
278
|
+
descriptor,
|
|
279
|
+
owner_sid,
|
|
280
|
+
ntsecuritycon,
|
|
281
|
+
win32security,
|
|
282
|
+
):
|
|
283
|
+
raise OSError(
|
|
284
|
+
"Windows capture ACL verification failed after repair"
|
|
285
|
+
)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
raise OSError(
|
|
288
|
+
"Windows capture ACL could not be enforced "
|
|
289
|
+
f"({type(exc).__name__}: {exc})"
|
|
290
|
+
) from exc
|
|
173
291
|
|
|
174
292
|
# Transfer the native handle to Python's CRT descriptor exactly once.
|
|
175
293
|
raw_handle = handle.Detach()
|
|
@@ -212,8 +212,12 @@ class CacheDB:
|
|
|
212
212
|
try:
|
|
213
213
|
import sqlite3 as _sq
|
|
214
214
|
test_conn = _sq.connect(str(self._db_path))
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
try:
|
|
216
|
+
test_conn.execute("PRAGMA schema_version")
|
|
217
|
+
finally:
|
|
218
|
+
# Windows will not rename an open SQLite file. Always
|
|
219
|
+
# release the probe before corrupt-file recovery runs.
|
|
220
|
+
test_conn.close()
|
|
217
221
|
except Exception as exc:
|
|
218
222
|
corrupt_sidecar = self._db_path.with_suffix(
|
|
219
223
|
self._db_path.suffix + ".corrupt"
|
|
@@ -84,6 +84,21 @@ _WARMUP_MAX_ATTEMPTS = int(os.environ.get("SLM_RERANKER_WARMUP_ATTEMPTS", "5"))
|
|
|
84
84
|
_WARMUP_RETRY_BACKOFF_S = float(os.environ.get("SLM_RERANKER_WARMUP_BACKOFF", "3"))
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
# Substrings that mark a load failure as a configuration problem rather than a
|
|
88
|
+
# transient one. Retrying these can never succeed, so the warmup aborts on the
|
|
89
|
+
# first occurrence instead of spending _WARMUP_MAX_ATTEMPTS × backoff on them.
|
|
90
|
+
_PERMANENT_LOAD_ERROR_MARKERS = (
|
|
91
|
+
"unknown backend",
|
|
92
|
+
"sentence-transformers is not installed",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_permanent_load_error(error: str) -> bool:
|
|
97
|
+
"""True when a worker load error cannot be fixed by retrying."""
|
|
98
|
+
lowered = (error or "").lower()
|
|
99
|
+
return any(m in lowered for m in _PERMANENT_LOAD_ERROR_MARKERS)
|
|
100
|
+
|
|
101
|
+
|
|
87
102
|
class CrossEncoderReranker:
|
|
88
103
|
"""Rerank candidate facts using a local cross-encoder model.
|
|
89
104
|
|
|
@@ -201,11 +216,43 @@ class CrossEncoderReranker:
|
|
|
201
216
|
resp.get("warmup_inference", False),
|
|
202
217
|
)
|
|
203
218
|
return
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
219
|
+
# v3.8.11 (issue #103): this used to report
|
|
220
|
+
# "(timeout=90s)" for EVERY failure, including loads
|
|
221
|
+
# that failed instantly. A user whose retries were 3s
|
|
222
|
+
# apart was told each one timed out after 90s, and the
|
|
223
|
+
# worker's actual error was never printed at all.
|
|
224
|
+
# Distinguish the two cases and surface the real cause.
|
|
225
|
+
if resp is None:
|
|
226
|
+
logger.warning(
|
|
227
|
+
"Reranker warmup attempt %d/%d: no response "
|
|
228
|
+
"from worker within %ds; retrying",
|
|
229
|
+
attempt, _WARMUP_MAX_ATTEMPTS,
|
|
230
|
+
_WARMUP_LOAD_TIMEOUT,
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
load_error = (
|
|
234
|
+
resp.get("error")
|
|
235
|
+
or "worker reported not-ready without an error"
|
|
236
|
+
)
|
|
237
|
+
# A misconfiguration cannot fix itself. Retrying a
|
|
238
|
+
# bad backend name or a missing dependency four
|
|
239
|
+
# more times burns ~7.5 minutes of daemon startup
|
|
240
|
+
# to reach the same answer (issue #103). Fail fast
|
|
241
|
+
# and say exactly what to change.
|
|
242
|
+
if _is_permanent_load_error(load_error):
|
|
243
|
+
logger.error(
|
|
244
|
+
"Reranker disabled — configuration error: "
|
|
245
|
+
"%s. Not retrying. Fix the config or set "
|
|
246
|
+
"retrieval.use_cross_encoder=false; recall "
|
|
247
|
+
"continues with fusion scores.",
|
|
248
|
+
load_error,
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
logger.warning(
|
|
252
|
+
"Reranker warmup attempt %d/%d failed: %s; "
|
|
253
|
+
"retrying",
|
|
254
|
+
attempt, _WARMUP_MAX_ATTEMPTS, load_error,
|
|
255
|
+
)
|
|
209
256
|
|
|
210
257
|
if attempt < _WARMUP_MAX_ATTEMPTS and not self._model_loaded:
|
|
211
258
|
if self._shutdown_event.wait(
|
|
@@ -534,6 +534,10 @@ def _recall_keyword_fallback(engine, query: str, limit: int) -> dict:
|
|
|
534
534
|
"results": results,
|
|
535
535
|
"count": len(results),
|
|
536
536
|
"no_confident_match": True,
|
|
537
|
+
# PR #101: every other recall path returns this key, so clients format
|
|
538
|
+
# it unconditionally. Omitting it here made the degraded path — the one
|
|
539
|
+
# that fires when recall is ALREADY struggling — crash the CLI.
|
|
540
|
+
"retrieval_time_ms": 0,
|
|
537
541
|
}
|
|
538
542
|
|
|
539
543
|
# v3.4.52: Embedding model warm state. Set to True by the async pre-warm
|
|
@@ -1244,6 +1248,7 @@ async def lifespan(application: FastAPI):
|
|
|
1244
1248
|
engine = None
|
|
1245
1249
|
config = None
|
|
1246
1250
|
canonical_remember_runtime = None
|
|
1251
|
+
profile_runtime = None
|
|
1247
1252
|
|
|
1248
1253
|
# The local dashboard obtains its short-lived browser credential from
|
|
1249
1254
|
# ``/internal/token`` before its first write or token-gated read. A
|
|
@@ -1916,11 +1921,17 @@ async def lifespan(application: FastAPI):
|
|
|
1916
1921
|
|
|
1917
1922
|
except Exception:
|
|
1918
1923
|
logger.exception("Engine init failed") # auto-includes traceback
|
|
1919
|
-
_release_canonical_remember_runtime(
|
|
1924
|
+
writer_released = _release_canonical_remember_runtime(
|
|
1920
1925
|
application, canonical_remember_runtime,
|
|
1921
1926
|
)
|
|
1922
1927
|
application.state.engine = None
|
|
1923
1928
|
application.state.config = None
|
|
1929
|
+
if engine is not None and writer_released:
|
|
1930
|
+
try:
|
|
1931
|
+
engine.close()
|
|
1932
|
+
except Exception:
|
|
1933
|
+
logger.debug("partially initialized engine cleanup failed", exc_info=True)
|
|
1934
|
+
raise
|
|
1924
1935
|
|
|
1925
1936
|
application.state.observe_buffer = _observe_buffer
|
|
1926
1937
|
|
|
@@ -74,6 +74,16 @@ def _load_or_create_key(path: Path) -> bytes:
|
|
|
74
74
|
)
|
|
75
75
|
except FileExistsError:
|
|
76
76
|
fd = -1
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
try:
|
|
79
|
+
info = path.lstat()
|
|
80
|
+
except OSError:
|
|
81
|
+
raise AdmissionKeyError("admission key cannot be created") from exc
|
|
82
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
83
|
+
raise AdmissionKeyError(
|
|
84
|
+
"admission key path must be a regular file"
|
|
85
|
+
) from exc
|
|
86
|
+
raise AdmissionKeyError("admission key cannot be created") from exc
|
|
77
87
|
else:
|
|
78
88
|
try:
|
|
79
89
|
key = os.urandom(_KEY_BYTES)
|