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.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- 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-graph/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-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +1 -1
- package/plugin-src/agents/slm-optimize-advisor.md +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/rules/CLAUDE.md.fragment +3 -3
- 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 +3 -2
- package/scripts/build-plugin.js +1 -1
- package/scripts/postinstall-interactive.js +94 -7
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/setup_wizard.py +34 -0
- package/src/superlocalmemory/core/embeddings.py +5 -0
- package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +4 -0
- package/src/superlocalmemory/hooks/adapter_base.py +10 -3
- package/src/superlocalmemory/hooks/claude_code_hooks.py +40 -4
- package/src/superlocalmemory/hooks/copilot_adapter.py +78 -9
- package/src/superlocalmemory/hooks/hook_handlers.py +59 -1
- package/src/superlocalmemory/hooks/memory_protocol.py +102 -0
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +23 -5
- package/src/superlocalmemory/infra/event_bus.py +4 -0
- package/src/superlocalmemory/learning/feedback.py +59 -0
- package/src/superlocalmemory/learning/outcome_queue.py +10 -2
- package/src/superlocalmemory/llm/backbone.py +8 -1
- package/src/superlocalmemory/retrieval/reranker.py +5 -0
- package/src/superlocalmemory/server/routes/learning.py +2 -0
- package/src/superlocalmemory/server/routes/v3_api.py +11 -0
- package/src/superlocalmemory/server/unified_daemon.py +78 -0
- package/src/superlocalmemory/storage/database.py +34 -7
- package/src/superlocalmemory/storage/migrations/M017_ccq_scope_column.py +79 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +4 -3
- package/src/superlocalmemory.egg-info/SOURCES.txt +2 -0
- package/plugin-src/commands/slm-optimize.md +0 -22
- package/plugin-src/commands/slm-recall.md +0 -16
- package/plugin-src/commands/slm-remember.md +0 -16
- 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
|
-
|
|
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
|
-
)
|
|
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,
|
|
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.
|
|
143
|
-
except OSError:
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
+
return _port_file_url()
|
|
51
69
|
if parsed.scheme not in ("http", "https"):
|
|
52
|
-
return
|
|
70
|
+
return _port_file_url()
|
|
53
71
|
host = (parsed.hostname or "").lower()
|
|
54
72
|
if host not in _ALLOWED_DAEMON_HOSTS:
|
|
55
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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,
|