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.
Files changed (54) hide show
  1. package/CHANGELOG.md +65 -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/commands.py +13 -3
  34. package/src/superlocalmemory/cli/daemon.py +89 -16
  35. package/src/superlocalmemory/core/embeddings.py +35 -4
  36. package/src/superlocalmemory/core/ollama_embedder.py +11 -2
  37. package/src/superlocalmemory/core/remember_admission.py +14 -5
  38. package/src/superlocalmemory/core/reranker_worker.py +59 -17
  39. package/src/superlocalmemory/hooks/adapter_base.py +10 -3
  40. package/src/superlocalmemory/learning/feedback.py +46 -4
  41. package/src/superlocalmemory/learning/pattern_miner.py +31 -11
  42. package/src/superlocalmemory/mcp/tools_active.py +115 -4
  43. package/src/superlocalmemory/mcp/tools_core.py +16 -5
  44. package/src/superlocalmemory/optimize/proxy/capture.py +148 -30
  45. package/src/superlocalmemory/optimize/storage/db.py +6 -2
  46. package/src/superlocalmemory/retrieval/reranker.py +52 -5
  47. package/src/superlocalmemory/server/unified_daemon.py +12 -1
  48. package/src/superlocalmemory/storage/admission_codec.py +10 -0
  49. package/src/superlocalmemory/storage/admission_journal.py +182 -67
  50. package/src/superlocalmemory/storage/embedding_migrator.py +27 -13
  51. package/src/superlocalmemory/storage/migration_runner.py +9 -0
  52. package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
  53. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  54. 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(win32api, win32con, win32security)
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
- handle = win32file.CreateFile(
133
- os.fspath(path),
134
- ntsecuritycon.FILE_APPEND_DATA | ntsecuritycon.WRITE_DAC,
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
- security_attributes,
139
- win32con.OPEN_ALWAYS,
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
- try:
155
- # Enforce the DACL while this is still the original CreateFile
156
- # handle carrying WRITE_DAC. Transferring it into Python's CRT
157
- # first can lose the authority SetSecurityInfo needs on Windows.
158
- win32security.SetSecurityInfo(
159
- handle,
160
- win32security.SE_FILE_OBJECT,
161
- win32security.DACL_SECURITY_INFORMATION
162
- | win32security.PROTECTED_DACL_SECURITY_INFORMATION,
163
- None,
164
- None,
165
- dacl,
166
- None,
167
- )
168
- except Exception as exc:
169
- raise OSError(
170
- "Windows capture ACL could not be enforced "
171
- f"({type(exc).__name__}: {exc})"
172
- ) from exc
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
- test_conn.execute("PRAGMA schema_version")
216
- test_conn.close()
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
- logger.warning(
205
- "Reranker warmup attempt %d/%d did not confirm "
206
- "ready (timeout=%ds); retrying",
207
- attempt, _WARMUP_MAX_ATTEMPTS, _WARMUP_LOAD_TIMEOUT,
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)