superlocalmemory 3.8.3 → 3.8.6

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 (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  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 +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -20,7 +20,7 @@ from typing import Callable
20
20
  from mcp.types import ToolAnnotations
21
21
 
22
22
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
23
- from superlocalmemory.infra.data_root import canonical_data_root, state_path
23
+ from superlocalmemory.infra.data_root import state_path
24
24
  from superlocalmemory.mcp.shared import authorize_mcp_mutation
25
25
 
26
26
  logger = logging.getLogger(__name__)
@@ -58,67 +58,6 @@ def _emit_event(event_type: str, payload: dict | None = None,
58
58
  pass
59
59
 
60
60
 
61
- def _record_recall_hits(
62
- get_engine: Callable,
63
- query: str,
64
- results: list[dict],
65
- *,
66
- profile_id: str = "",
67
- query_id: str = "",
68
- fact_ids_candidates: list[str] | None = None,
69
- ) -> None:
70
- """Record honest shown-state signals (LLD-02 §4.9).
71
-
72
- v3.4.22: No more fake positives. For every candidate we enqueue a
73
- ``shown`` / ``not_shown`` flip based on whether it was returned in the
74
- top-K presented to the user. Outcome/reward arrives in v3.4.22 via the
75
- action-outcomes pipeline.
76
-
77
- Non-blocking: all work funnels through ``signals.enqueue_shown_flip``
78
- (module-level queue + background drain). Failures are swallowed —
79
- signal quality is never load-bearing on recall correctness.
80
- """
81
- try:
82
- from superlocalmemory.learning.signals import (
83
- LearningSignals,
84
- enqueue_shown_flip,
85
- )
86
-
87
- pid = profile_id
88
- if not pid:
89
- pid = get_engine().profile_id
90
- slm_dir = canonical_data_root()
91
-
92
- shown_ids = [r.get("fact_id", "") for r in results[:10]
93
- if r.get("fact_id")]
94
- candidates = (fact_ids_candidates
95
- if fact_ids_candidates is not None
96
- else shown_ids)
97
- if not candidates:
98
- return
99
-
100
- # Shown-flip enqueue per §4.9. No synthetic positives.
101
- shown_set = set(shown_ids)
102
- if query_id:
103
- for fid in candidates:
104
- enqueue_shown_flip(query_id, fid, shown=(fid in shown_set))
105
-
106
- # Legacy zero-cost signals — unchanged (co-retrieval + confidence).
107
- try:
108
- signals = LearningSignals(slm_dir / "learning.db")
109
- signals.record_co_retrieval(pid, shown_ids)
110
- except Exception:
111
- pass
112
- try:
113
- mem_db = str(slm_dir / "memory.db")
114
- for fid in shown_ids[:5]:
115
- LearningSignals.boost_confidence(mem_db, fid)
116
- except Exception:
117
- pass
118
- except Exception:
119
- pass
120
-
121
-
122
61
  def register_core_tools(server, get_engine: Callable) -> None:
123
62
  """Register the 13 core MCP tools on *server*."""
124
63
 
@@ -212,10 +151,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
212
151
  await _asyncio.sleep(0.05 * (attempt + 1))
213
152
  return {
214
153
  "success": False,
154
+ "code": "DAEMON_UNAVAILABLE",
215
155
  "retryable": True,
216
156
  "error": (
217
- "Canonical daemon is temporarily unavailable; retry the "
218
- "same remember operation without starting a second writer."
157
+ "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later."
219
158
  ),
220
159
  }
221
160
  except Exception as dexc:
@@ -241,11 +180,22 @@ def register_core_tools(server, get_engine: Callable) -> None:
241
180
  worker_meta,
242
181
  )
243
182
  if not isinstance(stored, dict) or not stored.get("ok"):
244
- raise RuntimeError(
245
- (stored or {}).get("error", "canonical worker store failed")
246
- if isinstance(stored, dict)
247
- else "canonical worker returned an invalid response"
248
- )
183
+ if isinstance(stored, dict) and stored.get("code") == "DAEMON_UNAVAILABLE":
184
+ return {
185
+ "success": False,
186
+ "code": "DAEMON_UNAVAILABLE",
187
+ "retryable": True,
188
+ "error": stored.get(
189
+ "error",
190
+ "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
191
+ ),
192
+ }
193
+ return {
194
+ "success": False,
195
+ "code": "DAEMON_UNAVAILABLE",
196
+ "retryable": True,
197
+ "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
198
+ }
249
199
  fact_ids = list(stored.get("fact_ids") or [])
250
200
  materialization_state = str(
251
201
  stored.get("materialization_state") or "complete"
@@ -275,9 +225,14 @@ def register_core_tools(server, get_engine: Callable) -> None:
275
225
  else "Queryable now; canonical enrichment is still running."
276
226
  ),
277
227
  }
278
- except Exception as exc:
228
+ except Exception:
279
229
  logger.exception("remember failed")
280
- return {"success": False, "error": str(exc)}
230
+ return {
231
+ "success": False,
232
+ "code": "DAEMON_UNAVAILABLE",
233
+ "retryable": True,
234
+ "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
235
+ }
281
236
 
282
237
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
283
238
  async def recall(
@@ -378,22 +333,6 @@ def register_core_tools(server, get_engine: Callable) -> None:
378
333
  window=window or None,
379
334
  )
380
335
  if result.get("ok"):
381
- # Record implicit feedback: every returned result is a recall_hit
382
- try:
383
- _record_recall_hits(
384
- get_engine,
385
- query,
386
- result.get("results", []),
387
- profile_id=str(result.get("profile", "")),
388
- )
389
- except Exception:
390
- pass # Feedback is non-critical, never block recall
391
- _emit_event("memory.recalled", {
392
- "query": query[:80],
393
- "result_count": result.get("result_count", 0),
394
- "query_type": result.get("query_type", "unknown"),
395
- "agent_id": agent_id,
396
- }, source_agent=agent_id)
397
336
  return {
398
337
  "success": True,
399
338
  "results": result.get("results", []),
@@ -16,13 +16,11 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import logging
19
- import sqlite3
20
- from datetime import datetime, timezone
21
- from pathlib import Path
22
19
  from typing import Callable
23
20
 
24
21
  from mcp.types import ToolAnnotations
25
22
  from superlocalmemory.infra.data_root import state_path
23
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
26
24
 
27
25
  logger = logging.getLogger(__name__)
28
26
 
@@ -146,10 +144,8 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
146
144
  try:
147
145
  engine = get_engine()
148
146
  profile_id = engine.profile_id if engine else "default"
149
- db_path = str(state_path("memory.db"))
150
-
151
- conn = sqlite3.connect(db_path, timeout=10)
152
- conn.row_factory = sqlite3.Row
147
+ db_path = state_path("memory.db")
148
+ conn = ReadConnectionFactory(db_path).open()
153
149
 
154
150
  # Gather per-skill invocation stats from tool_events
155
151
  # Skills are logged as tool_name='Skill' with actual skill name in input_summary
@@ -274,9 +270,8 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
274
270
  skill_name: Specific skill name (empty = all skills)
275
271
  """
276
272
  try:
277
- db_path = str(state_path("memory.db"))
278
- conn = sqlite3.connect(db_path, timeout=10)
279
- conn.row_factory = sqlite3.Row
273
+ db_path = state_path("memory.db")
274
+ conn = ReadConnectionFactory(db_path).open()
280
275
 
281
276
  if skill_name:
282
277
  rows = conn.execute(
@@ -9,7 +9,8 @@ Activation: set ``SLM_OPTIMIZE_CAPTURE=1`` in the daemon's environment. When on:
9
9
  at load time (see server._load_hooks), so capture never observes a mutated
10
10
  request or a cache hit; every line is a genuine upstream exchange.
11
11
  * each completed exchange is appended as one JSON line to
12
- ``~/.superlocalmemory/optimize_capture.jsonl`` (0600, gitignored).
12
+ ``~/.superlocalmemory/optimize_capture.jsonl`` (owner-only: POSIX 0600 or
13
+ Windows owner DACL, gitignored).
13
14
 
14
15
  ISOLATION GUARANTEE: this module writes ONLY to optimize_capture.jsonl. It never
15
16
  opens memory.db, llmcache.db, or any SLM memory store. (Plan §9 hard rule.)
@@ -21,9 +22,11 @@ swallowed — it MUST NOT break the proxied request the user is waiting on.
21
22
  from __future__ import annotations
22
23
 
23
24
  import asyncio
25
+ import errno
24
26
  import json
25
27
  import logging
26
28
  import os
29
+ import stat
27
30
  import threading
28
31
  from pathlib import Path
29
32
  from typing import Any
@@ -55,6 +58,180 @@ def _capture_path() -> Path:
55
58
  return state_path(_CAPTURE_FILENAME)
56
59
 
57
60
 
61
+ def _is_windows() -> bool:
62
+ """Return whether the running platform uses Windows DACLs."""
63
+ return os.name == "nt"
64
+
65
+
66
+ def _is_link_or_reparse(info: os.stat_result) -> bool:
67
+ """Reject POSIX symlinks and Windows reparse points before capture."""
68
+ reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
69
+ attributes = getattr(info, "st_file_attributes", 0)
70
+ return stat.S_ISLNK(info.st_mode) or bool(reparse and attributes & reparse)
71
+
72
+
73
+ def _windows_owner_dacl(
74
+ win32api: Any,
75
+ win32con: Any,
76
+ win32security: Any,
77
+ ) -> Any:
78
+ """Build one protected owner-only DACL for a Windows capture file."""
79
+ import ntsecuritycon
80
+
81
+ token = win32security.OpenProcessToken(
82
+ win32api.GetCurrentProcess(),
83
+ win32con.TOKEN_QUERY,
84
+ )
85
+ try:
86
+ owner_sid = win32security.GetTokenInformation(
87
+ token,
88
+ win32security.TokenUser,
89
+ )[0]
90
+ finally:
91
+ token.Close()
92
+ dacl = win32security.ACL()
93
+ dacl.AddAccessAllowedAce(
94
+ win32security.ACL_REVISION,
95
+ ntsecuritycon.FILE_ALL_ACCESS,
96
+ owner_sid,
97
+ )
98
+ return dacl
99
+
100
+
101
+ def _open_windows_capture_append(path: Path) -> int:
102
+ """Create/open a Windows file with append + WRITE_DAC on one handle."""
103
+ try:
104
+ import msvcrt
105
+
106
+ import ntsecuritycon
107
+ import win32api
108
+ import win32con
109
+ import win32file
110
+ import win32security
111
+ except ImportError as exc:
112
+ raise OSError("Windows capture ACL support is unavailable") from exc
113
+
114
+ handle = None
115
+ try:
116
+ # A creation-time descriptor prevents a newly created capture file from
117
+ # ever inheriting a broader parent DACL. For an existing file Windows
118
+ # ignores this descriptor; _enforce_owner_only_permissions replaces
119
+ # that DACL through the same WRITE_DAC-capable handle before writing.
120
+ dacl = _windows_owner_dacl(win32api, win32con, win32security)
121
+ security_attributes = win32security.SECURITY_ATTRIBUTES()
122
+ security_attributes.bInheritHandle = False
123
+ security_attributes.SECURITY_DESCRIPTOR.SetSecurityDescriptorDacl(
124
+ 1,
125
+ dacl,
126
+ 0,
127
+ )
128
+ security_attributes.SECURITY_DESCRIPTOR.SetSecurityDescriptorControl(
129
+ win32security.SE_DACL_PROTECTED,
130
+ win32security.SE_DACL_PROTECTED,
131
+ )
132
+ handle = win32file.CreateFile(
133
+ os.fspath(path),
134
+ ntsecuritycon.FILE_APPEND_DATA | ntsecuritycon.WRITE_DAC,
135
+ win32con.FILE_SHARE_READ
136
+ | win32con.FILE_SHARE_WRITE
137
+ | win32con.FILE_SHARE_DELETE,
138
+ security_attributes,
139
+ win32con.OPEN_ALWAYS,
140
+ win32con.FILE_ATTRIBUTE_NORMAL
141
+ # pywin32 does not export this SDK constant from win32con on
142
+ # every supported Python build. Keep the Microsoft-defined value
143
+ # as a named fallback rather than silently following a reparse.
144
+ | getattr(win32con, "FILE_FLAG_OPEN_REPARSE_POINT", 0x00200000),
145
+ None,
146
+ )
147
+ file_info = win32file.GetFileInformationByHandle(handle)
148
+ if file_info[0] & win32con.FILE_ATTRIBUTE_REPARSE_POINT:
149
+ raise OSError(
150
+ errno.ELOOP,
151
+ "capture path is a Windows reparse point",
152
+ path,
153
+ )
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
173
+
174
+ # Transfer the native handle to Python's CRT descriptor exactly once.
175
+ raw_handle = handle.Detach()
176
+ handle = None
177
+ try:
178
+ return msvcrt.open_osfhandle(
179
+ raw_handle,
180
+ os.O_WRONLY | os.O_APPEND | getattr(os, "O_BINARY", 0),
181
+ )
182
+ except BaseException:
183
+ win32api.CloseHandle(raw_handle)
184
+ raise
185
+ except OSError:
186
+ raise
187
+ except Exception as exc:
188
+ raise OSError(f"Windows secure capture open failed: {exc}") from exc
189
+ finally:
190
+ if handle is not None:
191
+ handle.Close()
192
+
193
+
194
+ def _open_capture_append(path: Path) -> int:
195
+ """Open one append descriptor without following or racing a link target."""
196
+ try:
197
+ before = path.lstat()
198
+ except FileNotFoundError:
199
+ before = None
200
+ if before is not None and _is_link_or_reparse(before):
201
+ raise OSError(errno.ELOOP, "capture path is a link or reparse point", path)
202
+
203
+ if _is_windows():
204
+ fd = _open_windows_capture_append(path)
205
+ else:
206
+ flags = os.O_CREAT | os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
207
+ fd = os.open(path, flags, 0o600)
208
+ try:
209
+ opened = os.fstat(fd)
210
+ current = path.lstat()
211
+ if (
212
+ _is_link_or_reparse(current)
213
+ or (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino)
214
+ ):
215
+ raise OSError(
216
+ errno.ELOOP,
217
+ "capture path changed during secure open",
218
+ path,
219
+ )
220
+ except BaseException:
221
+ os.close(fd)
222
+ raise
223
+ return fd
224
+
225
+
226
+ def _enforce_owner_only_permissions(fd: int) -> None:
227
+ """Apply owner-only access control through the already-verified handle."""
228
+ if not _is_windows():
229
+ os.fchmod(fd, 0o600)
230
+ return
231
+ # _open_windows_capture_append applies the protected DACL on the original
232
+ # WRITE_DAC-capable CreateFile handle before CRT descriptor transfer.
233
+
234
+
58
235
  class ShadowCapture:
59
236
  """Thread-safe append-only JSONL writer for proxy exchanges (singleton)."""
60
237
 
@@ -95,10 +272,10 @@ class ShadowCapture:
95
272
  Fail-open: any error is logged and False is returned; never raised.
96
273
 
97
274
  Security: opens with a single ``os.open`` carrying ``O_CREAT |
98
- O_APPEND | O_NOFOLLOW`` and mode ``0o600`` on EVERY write. O_NOFOLLOW
99
- refuses a symlink pre-placed at the path (symlink-append attack), and
100
- the unconditional 0600-on-create removes the stat/exists TOCTOU that
101
- could otherwise drop the file to the process umask.
275
+ O_APPEND`` and mode ``0o600`` on every write. POSIX adds O_NOFOLLOW;
276
+ all platforms reject link/reparse metadata and verify that the opened
277
+ descriptor still identifies the current path. Permissions are then
278
+ enforced through that verified descriptor before any data is written.
102
279
  """
103
280
  try:
104
281
  line = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
@@ -109,9 +286,20 @@ class ShadowCapture:
109
286
  try:
110
287
  with self._write_lock:
111
288
  self._path.parent.mkdir(parents=True, exist_ok=True)
112
- flags = os.O_CREAT | os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
113
- fd = os.open(self._path, flags, 0o600)
114
- with os.fdopen(fd, "a", encoding="utf-8") as fh:
289
+ fd = _open_capture_append(self._path)
290
+ try:
291
+ # Enforce the ACL through the verified descriptor so a
292
+ # pathname swap cannot redirect chmod/icacls elsewhere.
293
+ _enforce_owner_only_permissions(fd)
294
+ # fdopen transfers descriptor ownership only after it
295
+ # returns successfully. Keep this conversion inside the
296
+ # explicit-close boundary so an allocation/codec failure
297
+ # cannot leak one descriptor per captured request.
298
+ fh = os.fdopen(fd, "a", encoding="utf-8")
299
+ except BaseException:
300
+ os.close(fd)
301
+ raise
302
+ with fh:
115
303
  fh.write(line + "\n")
116
304
  self._count += 1
117
305
  return True
@@ -904,13 +904,18 @@ class RetrievalEngine:
904
904
 
905
905
  return out
906
906
 
907
- def close(self) -> None:
908
- """Release the channel workers owned by this retrieval engine."""
907
+ def close(self, *, wait: bool = False) -> None:
908
+ """Release owned channel workers without blocking daemon shutdown.
909
+
910
+ Active channel calls have their own response deadline. Waiting here
911
+ can still deadlock shutdown when an extension ignores that deadline,
912
+ so the daemon uses the executor's non-blocking cancellation path.
913
+ """
909
914
  with self._close_lock:
910
915
  if self._closed:
911
916
  return
912
917
  self._closed = True
913
- self._channel_executor.shutdown(wait=True, cancel_futures=True)
918
+ self._channel_executor.shutdown(wait=wait, cancel_futures=True)
914
919
 
915
920
  # -- Fact loading -------------------------------------------------------
916
921
 
@@ -955,7 +960,13 @@ class RetrievalEngine:
955
960
  Blended: alpha * sigmoid(CE_score) + (1 - alpha) * rrf_score.
956
961
  Speaker tags stripped before scoring (Bug 3 fix).
957
962
  """
958
- # Bug 2 fix: score ALL candidates, not just top_k
963
+ # Bug 2 fix: score ALL candidates, not just top_k. v3.8.5: verified on
964
+ # the real DB that bounding the CE to the top-N fusion candidates both
965
+ # (a) gave NO latency win (the cross-encoder batches all pairs in one
966
+ # forward pass, so 60 vs 184 pairs is within noise) and (b) CHANGED the
967
+ # top-5 on 4/8 queries — the CE legitimately promotes items ranked below
968
+ # the fusion top-N into the answer. So exhaustive reranking stays: it is
969
+ # a quality feature, not the latency bottleneck.
959
970
  candidates = [
960
971
  (fact_map[fr.fact_id], fr.fused_score)
961
972
  for fr in fused if fr.fact_id in fact_map
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import logging
17
+ import os
17
18
  import re
18
19
  import threading
19
20
  from collections import defaultdict
@@ -31,6 +32,24 @@ if TYPE_CHECKING:
31
32
 
32
33
  logger = logging.getLogger(__name__)
33
34
 
35
+
36
+ def _adj_ttl_seconds() -> float:
37
+ """In-memory adjacency-cache TTL (seconds), env-overridable.
38
+
39
+ v3.8.5: raised from a hard-coded 300s to 3600s. The TTL only exists to
40
+ catch edge-WEIGHT mutations (pruning / MAX-merge) that leave the edge COUNT
41
+ unchanged — new memories already force a reload via the count check. At 300s
42
+ a 208K-edge graph rebuilt on the recall hot path every 5 idle minutes,
43
+ causing a recurring multi-second latency spike. Weight drift is a minor
44
+ ranking refinement, so a longer TTL trades negligible staleness for a big
45
+ latency win. Set SLM_ENTITY_ADJ_TTL_S to tune (0 disables time-based reload;
46
+ the count-based correctness reload always remains).
47
+ """
48
+ try:
49
+ return max(0.0, float(os.environ.get("SLM_ENTITY_ADJ_TTL_S", "3600")))
50
+ except (TypeError, ValueError):
51
+ return 3600.0
52
+
34
53
  _PROPER_NOUN_RE = re.compile(r"\b[A-Z][a-z]{1,}\b")
35
54
 
36
55
  _ENTITY_STOP: frozenset[str] = frozenset({
@@ -156,7 +175,12 @@ class EntityGraphChannel:
156
175
  # count-stable window would otherwise serve a stale adjacency map.
157
176
  import time as _t_ec
158
177
  _now_ec = _t_ec.monotonic()
159
- _fresh = (_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < 300.0
178
+ _ttl = _adj_ttl_seconds()
179
+ # TTL=0 disables the time-based reload entirely (count-based correctness
180
+ # reload still applies); otherwise the cache is fresh within the TTL.
181
+ _fresh = _ttl <= 0.0 or (
182
+ (_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < _ttl
183
+ )
160
184
  if (self._adj_scope_key == scope_key
161
185
  and (self._adj or self._visible_fact_ids)
162
186
  and self._adj_edge_count == current_count