superlocalmemory 3.6.16 → 3.6.18

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 (57) hide show
  1. package/CHANGELOG.md +25 -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-memory-advisor.md +1 -1
  7. package/plugin/agents/slm-optimize-advisor.md +1 -1
  8. package/plugin/requirements.txt +1 -1
  9. package/plugin/skills/slm-cache/SKILL.md +1 -1
  10. package/plugin/skills/slm-compress/SKILL.md +1 -1
  11. package/plugin/skills/slm-graph/SKILL.md +1 -1
  12. package/plugin/skills/slm-recall/SKILL.md +1 -1
  13. package/plugin/skills/slm-remember/SKILL.md +1 -1
  14. package/plugin/skills/slm-session/SKILL.md +1 -1
  15. package/plugin/skills/slm-status/SKILL.md +1 -1
  16. package/plugin-src/agents/slm-memory-advisor.md +1 -1
  17. package/plugin-src/agents/slm-optimize-advisor.md +1 -1
  18. package/plugin-src/manifest.json +1 -1
  19. package/plugin-src/requirements.txt +1 -1
  20. package/plugin-src/rules/AGENTS.md +1 -1
  21. package/plugin-src/rules/CLAUDE.md.fragment +3 -3
  22. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  23. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  24. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  29. package/pyproject.toml +3 -2
  30. package/scripts/build-plugin.js +1 -1
  31. package/scripts/postinstall-interactive.js +94 -7
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/setup_wizard.py +34 -0
  34. package/src/superlocalmemory/core/embeddings.py +5 -0
  35. package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +4 -0
  36. package/src/superlocalmemory/hooks/adapter_base.py +10 -3
  37. package/src/superlocalmemory/hooks/claude_code_hooks.py +40 -4
  38. package/src/superlocalmemory/hooks/copilot_adapter.py +78 -9
  39. package/src/superlocalmemory/hooks/hook_handlers.py +59 -1
  40. package/src/superlocalmemory/hooks/memory_protocol.py +102 -0
  41. package/src/superlocalmemory/hooks/post_tool_async_hook.py +23 -5
  42. package/src/superlocalmemory/infra/event_bus.py +4 -0
  43. package/src/superlocalmemory/learning/feedback.py +59 -0
  44. package/src/superlocalmemory/learning/outcome_queue.py +10 -2
  45. package/src/superlocalmemory/llm/backbone.py +8 -1
  46. package/src/superlocalmemory/retrieval/reranker.py +5 -0
  47. package/src/superlocalmemory/server/routes/learning.py +2 -0
  48. package/src/superlocalmemory/server/routes/v3_api.py +11 -0
  49. package/src/superlocalmemory/server/unified_daemon.py +78 -0
  50. package/src/superlocalmemory/storage/database.py +34 -7
  51. package/src/superlocalmemory/storage/migrations/M017_ccq_scope_column.py +79 -0
  52. package/src/superlocalmemory.egg-info/PKG-INFO +4 -3
  53. package/src/superlocalmemory.egg-info/SOURCES.txt +2 -0
  54. package/plugin-src/commands/slm-optimize.md +0 -22
  55. package/plugin-src/commands/slm-recall.md +0 -16
  56. package/plugin-src/commands/slm-remember.md +0 -16
  57. package/plugin-src/commands/slm-status.md +0 -15
@@ -8,9 +8,15 @@ LLD-05 §6. Verified (verification-2026-04-17.md claim 5): plain markdown,
8
8
  no frontmatter, soft 2 KB / hard 4 KB cap. Adapter is INACTIVE when the
9
9
  project has no ``.github/`` directory — we do not create it ourselves.
10
10
 
11
+ v3.4.23 fix: the SLM-managed content is wrapped in
12
+ ``<!-- SLM-START -->`` / ``<!-- SLM-END -->`` markers and merged into the
13
+ host file rather than overwriting it. ``.github/copilot-instructions.md``
14
+ is typically a curated, project-specific document; destructive rewrites
15
+ deleted the user's prose.
16
+
11
17
  Hard rules covered here:
12
18
  - A1 / A2 / A3 / A7: via ``adapter_base.atomic_write``.
13
- - A4: soft 2 KB + hard 4 KB cap enforcement.
19
+ - A4: soft 2 KB + hard 4 KB cap enforcement on the SLM section.
14
20
  """
15
21
 
16
22
  from __future__ import annotations
@@ -39,6 +45,12 @@ from superlocalmemory.hooks.context_payload import (
39
45
  format_topics,
40
46
  truncate_payload_for_cap,
41
47
  )
48
+ from superlocalmemory.hooks.memory_protocol import (
49
+ SLM_MARKER_END,
50
+ SLM_MARKER_START,
51
+ memory_protocol_markdown,
52
+ strip_slm_block as _strip_existing_block,
53
+ )
42
54
 
43
55
  logger = logging.getLogger(__name__)
44
56
 
@@ -52,20 +64,37 @@ _BODY_TEMPLATE = (
52
64
  "## Entities\n{entities}\n\n"
53
65
  "## Never do\n"
54
66
  "- Do not modify files under `.slm/`\n"
55
- "- Do not commit `*.slm-cache.db`\n"
67
+ "- Do not commit `*.slm-cache.db`\n\n"
56
68
  )
57
69
 
58
70
 
59
71
  def render_copilot(payload: ContextPayload) -> bytes:
60
- return _BODY_TEMPLATE.format(
72
+ # Two-stage assembly: format the dynamic header (which contains {}
73
+ # placeholders), then concatenate the static memory-protocol block
74
+ # verbatim. The memory-protocol block legitimately contains literal
75
+ # braces (JSON-shaped argument examples for the agent) which must not
76
+ # be interpreted as format fields.
77
+ header = _BODY_TEMPLATE.format(
61
78
  version=payload.version,
62
79
  topics=format_topics(payload),
63
80
  entities=format_entities(payload),
64
- ).encode("utf-8")
81
+ )
82
+ return (header + memory_protocol_markdown()).encode("utf-8")
83
+
84
+
85
+ def _wrap_managed(rendered: bytes) -> str:
86
+ """Wrap rendered SLM content in ``<!-- SLM-START -->`` markers."""
87
+ return (
88
+ f"{SLM_MARKER_START}\n"
89
+ "<!-- Managed by SuperLocalMemory. Edits between SLM-START and "
90
+ "SLM-END will be overwritten. -->\n\n"
91
+ f"{rendered.decode('utf-8')}\n"
92
+ f"{SLM_MARKER_END}\n"
93
+ )
65
94
 
66
95
 
67
96
  class CopilotAdapter:
68
- """Project-scope Copilot adapter."""
97
+ """Project-scope Copilot adapter (marker-bounded merge)."""
69
98
 
70
99
  def __init__(
71
100
  self,
@@ -124,8 +153,39 @@ class CopilotAdapter:
124
153
  )
125
154
  rendered = truncate_to_cap(rendered, cap=self._hard_cap)
126
155
 
156
+ # Marker-bounded merge — preserve any user-curated content in the
157
+ # host file. Strip any prior SLM block(s) and re-append a fresh one.
158
+ existing = ""
159
+ if resolved.exists():
160
+ try:
161
+ existing = resolved.read_text(encoding="utf-8")
162
+ except OSError as exc:
163
+ logger.warning(
164
+ "copilot: cannot read %s: %s", resolved, exc,
165
+ )
166
+ return False
167
+
168
+ # Orphaned start marker — refuse to write rather than corrupt.
169
+ if (SLM_MARKER_START in existing
170
+ and SLM_MARKER_END not in existing):
171
+ logger.warning(
172
+ "copilot: %s present but %s missing in %s; refusing to write",
173
+ SLM_MARKER_START, SLM_MARKER_END, resolved,
174
+ )
175
+ return False
176
+
177
+ stripped = _strip_existing_block(existing)
178
+ section = _wrap_managed(rendered)
179
+ if stripped:
180
+ if not stripped.endswith("\n"):
181
+ stripped += "\n"
182
+ # One blank line between user content and the managed section.
183
+ new_content = stripped + "\n" + section
184
+ else:
185
+ new_content = section
186
+
127
187
  result: WriteResult = atomic_write(
128
- resolved, rendered,
188
+ resolved, new_content.encode("utf-8"),
129
189
  adapter_name=self.name,
130
190
  profile_id=self._profile_id,
131
191
  sync_log_db=self._sync_log_db,
@@ -137,11 +197,20 @@ class CopilotAdapter:
137
197
  resolved = self.target_path
138
198
  except PathTraversalError:
139
199
  return
200
+ # Marker-bounded strip — never delete the host file (user-owned).
140
201
  if resolved.exists():
141
202
  try:
142
- resolved.unlink()
143
- except OSError: # pragma: no cover
144
- pass
203
+ existing = resolved.read_text(encoding="utf-8")
204
+ except OSError:
205
+ existing = ""
206
+ stripped = _strip_existing_block(existing)
207
+ if stripped != existing:
208
+ try:
209
+ resolved.write_text(stripped, encoding="utf-8")
210
+ except OSError as exc: # pragma: no cover
211
+ logger.warning(
212
+ "copilot: failed to strip on disable: %s", exc,
213
+ )
145
214
  record_disable(
146
215
  resolved,
147
216
  adapter_name=self.name,
@@ -36,7 +36,32 @@ _LAST_CONSOLIDATION = os.path.join(
36
36
  )
37
37
 
38
38
 
39
- _DAEMON_URL = "http://127.0.0.1:8765"
39
+ _DEFAULT_DAEMON_PORT = 8765
40
+
41
+
42
+ def _daemon_url() -> str:
43
+ """Resolve the daemon base URL, preferring the per-user port file.
44
+
45
+ On a shared host each user runs their own daemon bound to a different
46
+ port; the active port is written to ``~/.superlocalmemory/daemon.port``
47
+ at startup. Reading it here keeps lifecycle hooks pointed at the
48
+ caller's own daemon instead of a hard-coded ``8765`` that may belong to
49
+ another user's instance. Falls back to the default port when the file is
50
+ absent or unreadable. Stdlib only — no SLM imports in the hot path.
51
+ """
52
+ port = _DEFAULT_DAEMON_PORT
53
+ try:
54
+ port_file = os.path.join(
55
+ os.path.expanduser("~"), ".superlocalmemory", "daemon.port",
56
+ )
57
+ with open(port_file) as fh:
58
+ port = int(fh.read().strip())
59
+ except Exception:
60
+ pass
61
+ return f"http://127.0.0.1:{port}"
62
+
63
+
64
+ _DAEMON_URL = _daemon_url()
40
65
 
41
66
 
42
67
  def _daemon_post(path: str, body: dict, timeout: float = 3.0) -> bool:
@@ -95,6 +120,7 @@ def handle_hook(action: str) -> None:
95
120
  sys.exit(_main())
96
121
 
97
122
  handlers = {
123
+ "mandate": _hook_mandate,
98
124
  "start": _hook_start,
99
125
  "gate": _hook_gate,
100
126
  "init-done": _hook_init_done,
@@ -142,6 +168,38 @@ def _launch_post_session_evolution(
142
168
  )
143
169
 
144
170
 
171
+ # ---------------------------------------------------------------------------
172
+ # 0. MANDATE — first SessionStart hook; fires before start
173
+ # ---------------------------------------------------------------------------
174
+
175
+ _MANDATE_TEXT = """<MANDATORY_SESSION_INIT>
176
+ BEFORE writing any text or calling any other tool, you MUST execute these two steps in order:
177
+
178
+ STEP 1 — Load schema:
179
+ Call ToolSearch with query: "select:mcp__superlocalmemory__session_init"
180
+
181
+ STEP 2 — Initialize memory brain:
182
+ Call mcp__superlocalmemory__session_init with:
183
+ project_path = your current working directory
184
+ query = a short description of the session topic (derive from user's first message)
185
+
186
+ This is non-negotiable. SLM is the brain. Without session_init, you have no 6-channel memory context (Ebbinghaus decay, entity graph, FSRS, spreading activation all go cold).
187
+ DO NOT output any text until session_init has completed.
188
+ </MANDATORY_SESSION_INIT>"""
189
+
190
+
191
+ def _hook_mandate() -> None:
192
+ """Print the session_init mandate. MUST be the FIRST SessionStart hook.
193
+
194
+ mcp__superlocalmemory__session_init is DEFERRED at session start — Claude
195
+ cannot call it without ToolSearch first loading the schema. This hook
196
+ fires before `slm hook start` and injects the two-step protocol into the
197
+ system-reminder block so Claude's first action is always ToolSearch →
198
+ session_init, not a text response with no memory context.
199
+ """
200
+ print(_MANDATE_TEXT)
201
+
202
+
145
203
  # ---------------------------------------------------------------------------
146
204
  # 1. SESSION START — SessionStart hook
147
205
  # ---------------------------------------------------------------------------
@@ -0,0 +1,102 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Shared utilities for marker-bounded writes into agent instruction files.
6
+
7
+ Adapters that inject SLM content into IDE/agent instruction files (e.g.
8
+ ``.github/copilot-instructions.md``) use the constants and helpers here to
9
+ demarcate the SLM-managed section so user-curated content outside the
10
+ markers is preserved on every sync.
11
+
12
+ Marker contract
13
+ ---------------
14
+ SLM wraps its content in a pair of HTML comments::
15
+
16
+ <!-- SLM-START -->
17
+ ... managed content ...
18
+ <!-- SLM-END -->
19
+
20
+ ``strip_slm_block`` removes all such pairs idempotently; adapters call it
21
+ before re-writing so a fresh block replaces the old one in place.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ #: Opening marker for the SLM-managed section.
31
+ SLM_MARKER_START = "<!-- SLM-START -->"
32
+ #: Closing marker for the SLM-managed section.
33
+ SLM_MARKER_END = "<!-- SLM-END -->"
34
+
35
+
36
+ def strip_slm_block(text: str) -> str:
37
+ """Remove all SLM-managed sections from *text*.
38
+
39
+ Idempotent — returns *text* unchanged when no markers are present.
40
+ Strips every ``SLM-START``/``SLM-END`` pair to handle files that
41
+ accumulated duplicates from a previous bug or a competing writer.
42
+
43
+ If a ``SLM-START`` marker has no matching ``SLM-END``, the file is
44
+ returned unchanged to avoid eating user content; the caller should
45
+ treat this as an orphaned-marker error and skip the write.
46
+ """
47
+ out = text
48
+ while True:
49
+ start_idx = out.find(SLM_MARKER_START)
50
+ if start_idx == -1:
51
+ return out
52
+ end_idx = out.find(SLM_MARKER_END, start_idx)
53
+ if end_idx == -1:
54
+ logger.warning(
55
+ "memory_protocol: %s found but %s missing; leaving file unchanged",
56
+ SLM_MARKER_START,
57
+ SLM_MARKER_END,
58
+ )
59
+ return text
60
+ cut_end = end_idx + len(SLM_MARKER_END)
61
+ if cut_end < len(out) and out[cut_end] == "\n":
62
+ cut_end += 1
63
+ # Pull back up to two leading newlines added as a boundary separator.
64
+ cut_start = start_idx
65
+ while cut_start > 0 and out[cut_start - 1] == "\n":
66
+ cut_start -= 1
67
+ if start_idx - cut_start >= 2:
68
+ break
69
+ out = out[:cut_start] + out[cut_end:]
70
+
71
+
72
+ def memory_protocol_markdown() -> str:
73
+ """Return the agent-facing Markdown memory protocol block.
74
+
75
+ Embedded verbatim into Markdown instruction files such as
76
+ ``.github/copilot-instructions.md``. Trailing newline included so
77
+ callers can concatenate without worrying about boundary whitespace.
78
+ """
79
+ return (
80
+ "## Memory protocol\n"
81
+ "SLM tools are available via the `slm-hub` MCP gateway. Use them to "
82
+ "make this brain context grow across sessions.\n\n"
83
+ "- **At the start of work on an unfamiliar area**, call "
84
+ "`hub__call_tool` with `tool=\"slm__recall\"` and "
85
+ "`arguments={\"query\": \"<topic>\"}` to surface prior decisions "
86
+ "and patterns.\n"
87
+ "- **At the end of a substantial task** (a fix, a decision, a "
88
+ "non-trivial change, a session conclusion), call `hub__call_tool` "
89
+ "with `tool=\"slm__remember\"` and `arguments={\"content\": "
90
+ "\"<one-paragraph summary of what was decided / changed / "
91
+ "learned>\", \"tags\": \"<comma-separated kebab-case keywords>\"}`.\n"
92
+ "- A \"substantial task\" is anything you would write a commit "
93
+ "message or handoff note about — not every tool call.\n"
94
+ )
95
+
96
+
97
+ __all__ = (
98
+ "SLM_MARKER_START",
99
+ "SLM_MARKER_END",
100
+ "strip_slm_block",
101
+ "memory_protocol_markdown",
102
+ )
@@ -30,6 +30,24 @@ _ALLOWED_DAEMON_HOSTS: frozenset[str] = frozenset({
30
30
  "127.0.0.1", "localhost", "::1", "[::1]",
31
31
  })
32
32
 
33
+ _DEFAULT_DAEMON_PORT = 8765
34
+
35
+
36
+ def _port_file_url() -> str:
37
+ """Loopback daemon URL from the per-user port file (default 8765).
38
+
39
+ On a shared host each user runs their own daemon on a different port,
40
+ written to ``~/.superlocalmemory/daemon.port`` at startup. Falling back
41
+ to this instead of a hard-coded ``8765`` keeps the hook pointed at the
42
+ caller's own daemon. Stdlib only.
43
+ """
44
+ port = _DEFAULT_DAEMON_PORT
45
+ try:
46
+ port = int((Path.home() / ".superlocalmemory" / "daemon.port").read_text().strip())
47
+ except Exception:
48
+ pass
49
+ return f"http://127.0.0.1:{port}"
50
+
33
51
 
34
52
  def _sanitised_daemon_url() -> str:
35
53
  """Return the configured daemon URL only if it's loopback-scoped.
@@ -38,21 +56,21 @@ def _sanitised_daemon_url() -> str:
38
56
  shell profile) could set ``SLM_HOOK_DAEMON_URL`` to a remote host
39
57
  and exfiltrate the install token via the ``X-SLM-Hook-Token``
40
58
  header. We refuse any non-loopback URL and fall back to the local
41
- daemon.
59
+ daemon (resolved via the per-user port file, not a hard-coded port).
42
60
  """
43
61
  raw = os.environ.get("SLM_HOOK_DAEMON_URL", "").strip()
44
62
  if not raw:
45
- return "http://127.0.0.1:8765"
63
+ return _port_file_url()
46
64
  try:
47
65
  from urllib.parse import urlparse
48
66
  parsed = urlparse(raw)
49
67
  except Exception: # pragma: no cover — urllib always importable
50
- return "http://127.0.0.1:8765"
68
+ return _port_file_url()
51
69
  if parsed.scheme not in ("http", "https"):
52
- return "http://127.0.0.1:8765"
70
+ return _port_file_url()
53
71
  host = (parsed.hostname or "").lower()
54
72
  if host not in _ALLOWED_DAEMON_HOSTS:
55
- return "http://127.0.0.1:8765"
73
+ return _port_file_url()
56
74
  # Preserve the scheme + port (user may bind daemon on a non-default port).
57
75
  port = f":{parsed.port}" if parsed.port else ""
58
76
  return f"{parsed.scheme}://{host}{port}"
@@ -32,6 +32,10 @@ VALID_EVENT_TYPES = frozenset([
32
32
  "memory.updated", # Existing memory modified
33
33
  "memory.deleted", # Memory removed
34
34
  "memory.recalled", # Memory retrieved by an agent
35
+ "memory.observed", # /observe accepted content into the debounce buffer
36
+ "memory.captured", # AutoCapture matched a buffered observation
37
+ "memory.dropped", # AutoCapture rejected a buffered observation
38
+ "memory.queued", # /remember accepted content into pending.db (async)
35
39
  "graph.updated", # Knowledge graph rebuilt
36
40
  "pattern.learned", # New pattern detected
37
41
  "agent.connected", # New agent connects
@@ -41,6 +41,18 @@ SIGNAL_VALUES: Dict[str, float] = {
41
41
  "access_pattern": 0.6,
42
42
  }
43
43
 
44
+ # Dashboard UI vocabulary -> (signal_type, signal_value). The dashboard speaks
45
+ # thumbs_up/thumbs_down/pin (explicit) and dwell_positive/dwell_negative
46
+ # (derived from modal dwell time). Unknown types fall back to a neutral
47
+ # user_correction signal rather than being dropped.
48
+ _DASHBOARD_SIGNAL_MAP: Dict[str, tuple[str, float]] = {
49
+ "thumbs_up": ("user_positive", 1.0),
50
+ "thumbs_down": ("user_negative", 0.0),
51
+ "pin": ("user_pin", 1.0),
52
+ "dwell_positive": ("dwell_positive", 0.6),
53
+ "dwell_negative": ("dwell_negative", 0.2),
54
+ }
55
+
44
56
  _CREATE_TABLE = """
45
57
  CREATE TABLE IF NOT EXISTS learning_feedback (
46
58
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -219,6 +231,53 @@ class FeedbackCollector:
219
231
  finally:
220
232
  conn.close()
221
233
 
234
+ # ------------------------------------------------------------------
235
+ # Public API: record dashboard feedback
236
+ # ------------------------------------------------------------------
237
+
238
+ def record_dashboard_feedback(
239
+ self,
240
+ memory_id: str,
241
+ query: str = "",
242
+ feedback_type: str = "",
243
+ profile_id: str = "default",
244
+ ) -> Optional[int]:
245
+ """Record an explicit feedback signal raised from the dashboard UI.
246
+
247
+ Maps the dashboard's vocabulary (``thumbs_up``/``thumbs_down``/``pin``
248
+ and the dwell-derived ``dwell_positive``/``dwell_negative``) onto a
249
+ stored ``(signal_type, signal_value)`` pair. ``memory_id`` is the fact
250
+ id; the raw ``query`` is hashed and never stored. Returns the inserted
251
+ row id, or ``None`` on missing ``memory_id``.
252
+
253
+ This method restores the dashboard feedback path: the HTTP routes in
254
+ ``server/routes/learning.py`` called it before it existed, so every
255
+ thumbs/pin/dwell write raised ``AttributeError`` (issues #53/#59).
256
+ """
257
+ if not memory_id:
258
+ return None
259
+ signal_type, value = _DASHBOARD_SIGNAL_MAP.get(
260
+ feedback_type, ("user_correction", 0.5),
261
+ )
262
+ qhash = _hash_query(query) if query else None
263
+ now = _utcnow_iso()
264
+
265
+ with self._lock:
266
+ conn = self._connect()
267
+ try:
268
+ cursor = conn.execute(
269
+ "INSERT INTO learning_feedback "
270
+ "(profile_id, fact_id, signal_type, signal_value, "
271
+ "query_hash, created_at, metadata) "
272
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
273
+ (profile_id or "default", str(memory_id), signal_type,
274
+ value, qhash, now, None),
275
+ )
276
+ conn.commit()
277
+ return cursor.lastrowid
278
+ finally:
279
+ conn.close()
280
+
222
281
  # ------------------------------------------------------------------
223
282
  # Public API: read feedback
224
283
  # ------------------------------------------------------------------
@@ -207,11 +207,19 @@ def _worker_loop(memory_db_path: Path, interval_s: float) -> None:
207
207
  )
208
208
  import time as _time
209
209
  next_reap = _time.monotonic() + _REAP_INTERVAL_S
210
- while not _stop_event.wait(interval_s):
210
+ # Adaptive idle back-off: poll at interval_s under load, but relax the wait
211
+ # (doubling, capped) when the queue drains empty so an idle daemon stops
212
+ # contending on the shared SQLite file every 0.25s (issue #53). Snaps back
213
+ # to interval_s the instant there is work again.
214
+ _idle_cap = max(interval_s, 2.0)
215
+ cur_wait = interval_s
216
+ while not _stop_event.wait(cur_wait):
211
217
  try:
212
- _drain_once(memory_db_path)
218
+ drained = _drain_once(memory_db_path)
213
219
  except Exception as exc: # pragma: no cover — defensive
214
220
  logger.warning("outcome_queue drain crashed: %s", exc)
221
+ drained = 0
222
+ cur_wait = interval_s if drained else min(cur_wait * 2.0, _idle_cap)
215
223
  # Periodic reaper for CLI/dashboard outcomes that no Stop hook
216
224
  # will ever finalize. Runs OFF the drain path so a busy queue
217
225
  # doesn't starve the reaper.
@@ -295,7 +295,14 @@ class LLMBackbone:
295
295
  }
296
296
  if system:
297
297
  payload["system"] = system
298
- return _ANTHROPIC_URL, headers, payload
298
+ # Respect custom base_url (e.g. Anthropic-compatible proxy).
299
+ # Append /v1/messages to the root URL, mirroring how _build_openai
300
+ # handles api_base. Falls back to the official Anthropic endpoint.
301
+ url = (
302
+ self._base_url.rstrip("/") + "/v1/messages"
303
+ if self._base_url else _ANTHROPIC_URL
304
+ )
305
+ return url, headers, payload
299
306
 
300
307
  def _build_azure(
301
308
  self, prompt: str, system: str, max_tokens: int, temperature: float,
@@ -197,6 +197,11 @@ class CrossEncoderReranker:
197
197
  "TOKENIZERS_PARALLELISM": "false",
198
198
  "TORCH_DEVICE": "cpu",
199
199
  "ORT_DISABLE_COREML": "1",
200
+ # Restore parallel OpenMP. The package caps OMP_NUM_THREADS
201
+ # globally to avoid a torch+lightgbm libomp SIGSEGV in the
202
+ # main process. This worker loads torch but never lightgbm,
203
+ # so there is no collision risk and full parallelism is safe.
204
+ "OMP_NUM_THREADS": str(os.cpu_count() or 4),
200
205
  }
201
206
  from superlocalmemory.core.platform_utils import popen_platform_kwargs
202
207
  self._worker_proc = subprocess.Popen(
@@ -325,6 +325,7 @@ async def record_feedback(data: dict):
325
325
 
326
326
  row_id = feedback.record_dashboard_feedback(
327
327
  memory_id=str(memory_id), query=query, feedback_type=feedback_type,
328
+ profile_id=get_active_profile() or "default",
328
329
  )
329
330
 
330
331
  return {
@@ -369,6 +370,7 @@ async def record_dwell(data: dict):
369
370
 
370
371
  row_id = feedback.record_dashboard_feedback(
371
372
  memory_id=str(memory_id), query=query, feedback_type=feedback_type,
373
+ profile_id=get_active_profile() or "default",
372
374
  )
373
375
 
374
376
  return {
@@ -441,6 +441,17 @@ def _validate_provider_url(url: str, client_host: str) -> str | None:
441
441
  return "Cloud metadata endpoints are not allowed"
442
442
  if client_host in ("127.0.0.1", "::1", "localhost"):
443
443
  return None # local dashboard may target its own local/LAN endpoints
444
+ # SLM_REMOTE residue (#40): an allowlisted LAN dashboard is trusted exactly
445
+ # like the loopback one and may probe its own LAN LLM endpoint. This does
446
+ # NOT relax the SSRF guard for arbitrary remote callers —
447
+ # is_lan_client_allowed is False unless remote mode is ON *and* the client
448
+ # IP is in SLM_MCP_ALLOWED_HOSTS.
449
+ try:
450
+ from superlocalmemory.core.remote_mode import is_lan_client_allowed
451
+ if is_lan_client_allowed(client_host):
452
+ return None
453
+ except Exception: # pragma: no cover — defensive, never weaken on import error
454
+ pass
444
455
  try:
445
456
  ip = ipaddress.ip_address(host)
446
457
  except ValueError:
@@ -171,6 +171,33 @@ from superlocalmemory.core.recall_gate import (
171
171
  # daemon startup via engine._process_pending_memories().
172
172
  _engine = None
173
173
 
174
+
175
+ def _emit_event(
176
+ event_type: str,
177
+ payload: dict | None = None,
178
+ *,
179
+ source_agent: str = "http_client",
180
+ ) -> None:
181
+ """Emit a best-effort EventBus event from an HTTP write path.
182
+
183
+ Mirrors mcp.shared.emit_event but tags source_protocol="http" so the
184
+ dashboard can distinguish HTTP traffic from MCP tool calls. Never raises
185
+ — a bus failure must not affect the caller's response.
186
+ """
187
+ try:
188
+ from superlocalmemory.infra.event_bus import EventBus
189
+ from superlocalmemory.server.routes.helpers import DB_PATH
190
+ bus = EventBus.get_instance(DB_PATH)
191
+ bus.emit(
192
+ event_type,
193
+ payload=payload,
194
+ source_agent=source_agent,
195
+ source_protocol="http",
196
+ )
197
+ except Exception as exc:
198
+ logger.debug("EventBus emit failed (%s): %s", event_type, exc)
199
+
200
+
174
201
  # v3.4.53: Limit concurrent full (non-fast) recalls. Without this, N parallel
175
202
  # /recall calls spawn N × 6-channel threads → Ollama serialises, reranker
176
203
  # lock queues, and total wall time is N × single-recall-time. 3 concurrent
@@ -240,6 +267,14 @@ class ObserveBuffer:
240
267
  self._timer = threading.Timer(self._debounce_sec, self._flush)
241
268
  self._timer.daemon = True
242
269
  self._timer.start()
270
+ _emit_event(
271
+ "memory.observed",
272
+ payload={
273
+ "content_hash": content_hash,
274
+ "content_preview": content[:120],
275
+ "buffer_size": buf_size,
276
+ },
277
+ )
243
278
  return {"captured": True, "queued": True, "buffer_size": buf_size}
244
279
 
245
280
  def _flush(self) -> None:
@@ -268,6 +303,22 @@ class ObserveBuffer:
268
303
  # The prior 'processed N' counted skipped (capture=False)
269
304
  # items as successes — a false-positive write count.
270
305
  captured_count += 1
306
+ _emit_event(
307
+ "memory.captured",
308
+ payload={
309
+ "category": decision.category,
310
+ "confidence": getattr(decision, "confidence", None),
311
+ "content_preview": content[:120],
312
+ },
313
+ )
314
+ else:
315
+ _emit_event(
316
+ "memory.dropped",
317
+ payload={
318
+ "reason": getattr(decision, "reason", "no patterns matched"),
319
+ "content_preview": content[:120],
320
+ },
321
+ )
271
322
  except Exception as exc:
272
323
  failed_count += 1
273
324
  logger.warning(
@@ -1784,6 +1835,15 @@ def _register_daemon_routes(application: FastAPI) -> None:
1784
1835
  req.content, metadata=metadata,
1785
1836
  scope=scope, shared_with=shared_with,
1786
1837
  )
1838
+ _emit_event(
1839
+ "memory.stored",
1840
+ payload={
1841
+ "fact_ids": list(fact_ids) if fact_ids else [],
1842
+ "count": len(fact_ids) if fact_ids else 0,
1843
+ "path": "remember_sync",
1844
+ "content_preview": req.content[:120],
1845
+ },
1846
+ )
1787
1847
  return {"ok": True, "fact_ids": fact_ids, "count": len(fact_ids)}
1788
1848
  except Exception as exc:
1789
1849
  raise HTTPException(500, detail=str(exc))
@@ -1823,6 +1883,14 @@ def _register_daemon_routes(application: FastAPI) -> None:
1823
1883
  pending_id = store_pending(
1824
1884
  req.content, tags=req.tags or "", metadata=meta,
1825
1885
  )
1886
+ _emit_event(
1887
+ "memory.queued",
1888
+ payload={
1889
+ "pending_id": pending_id,
1890
+ "tags": req.tags or "",
1891
+ "content_preview": req.content[:120],
1892
+ },
1893
+ )
1826
1894
  return {
1827
1895
  "ok": True,
1828
1896
  "fact_ids": fact_ids,
@@ -2196,6 +2264,16 @@ def _start_pending_materializer() -> None:
2196
2264
  )
2197
2265
  engine.store_fact_direct(fact)
2198
2266
  mark_done(item["id"])
2267
+ _emit_event(
2268
+ "memory.stored",
2269
+ payload={
2270
+ "pending_id": item["id"],
2271
+ "memory_id": mem_id,
2272
+ "path": "materializer_drain",
2273
+ "content_preview": content[:120],
2274
+ },
2275
+ source_agent="materializer",
2276
+ )
2199
2277
  except Exception as exc:
2200
2278
  logger.warning(
2201
2279
  "Pending %d failed: %s", item["id"], exc,