superlocalmemory 4.1.6 → 4.1.7
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/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +19 -0
- package/README.md +3 -3
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +49 -0
- package/plugin-src/agents/slm-optimize-advisor.md +44 -0
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/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-scope/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 +5 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/host_upgrades.py +21 -7
- package/src/superlocalmemory/core/engine.py +7 -1
- package/src/superlocalmemory/core/recall_pipeline.py +13 -6
- package/src/superlocalmemory/core/session_identity.py +14 -1
- package/src/superlocalmemory/hooks/codex_assets.py +165 -45
- package/src/superlocalmemory/learning/bandit.py +22 -2
- package/src/superlocalmemory/learning/engagement_features.py +279 -0
- package/src/superlocalmemory/learning/outcome_queue.py +14 -0
- package/src/superlocalmemory/learning/propensity.py +131 -0
- package/src/superlocalmemory/learning/reward.py +42 -16
- package/src/superlocalmemory/learning/reward_model.py +144 -0
- package/src/superlocalmemory/learning/reward_proxy.py +148 -22
- package/src/superlocalmemory/server/routes/v3_api.py +4 -3
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
5
8
|
import shutil
|
|
6
9
|
import sysconfig
|
|
7
10
|
from pathlib import Path
|
|
@@ -24,11 +27,31 @@ SKILLS = (
|
|
|
24
27
|
# Codex subagent files written to ~/.codex/agents (content built by _agent_files()).
|
|
25
28
|
AGENTS = ("slm-memory-advisor.toml", "slm-optimize-advisor.toml")
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
#: Digests of the agent files this installer last wrote, kept beside them. An
|
|
31
|
+
#: agent file whose current content still matches its recorded digest is ours to
|
|
32
|
+
#: refresh; anything else belongs to whoever changed it and is left alone. A
|
|
33
|
+
#: file with no recorded digest predates this manifest and is treated as theirs,
|
|
34
|
+
#: which is the safe reading: this installer once replaced two hand-maintained
|
|
35
|
+
#: 4.9 KB advisors with its own one-line stubs and there was nothing to restore
|
|
36
|
+
#: from.
|
|
37
|
+
MANIFEST_NAME = ".slm-managed.json"
|
|
38
|
+
|
|
39
|
+
#: Used only when the advisor source document is unavailable — an installation
|
|
40
|
+
#: that ships no agent sources still gets a usable, if terse, subagent.
|
|
41
|
+
_FALLBACKS = {
|
|
42
|
+
"slm-memory-advisor.toml": (
|
|
43
|
+
"Use SuperLocalMemory safely: initialize once, recall before remember, "
|
|
44
|
+
"and store only durable atomic facts.",
|
|
45
|
+
"Use SLM for memory discipline only. Check results before claiming success; "
|
|
46
|
+
"preserve private scope unless the user explicitly asks to share.",
|
|
47
|
+
),
|
|
48
|
+
"slm-optimize-advisor.toml": (
|
|
49
|
+
"Apply SuperLocalMemory's no-proxy context-optimization rules — reversible "
|
|
50
|
+
"compression of large tool output and KV-caching of repeated reads/searches.",
|
|
51
|
+
"Reduce context-window pressure with the Surface-B tools (reversible CCR "
|
|
52
|
+
"compression + a per-agent KV cache); fail-open — never block the task.",
|
|
53
|
+
),
|
|
54
|
+
}
|
|
32
55
|
|
|
33
56
|
|
|
34
57
|
def _source_root() -> Path:
|
|
@@ -49,67 +72,164 @@ def _agents_source_root() -> Path | None:
|
|
|
49
72
|
return installed if installed.exists() else None
|
|
50
73
|
|
|
51
74
|
|
|
52
|
-
def
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
def _split_frontmatter(text: str) -> tuple[str, str]:
|
|
76
|
+
"""Return (frontmatter, body); frontmatter is "" when the doc has none."""
|
|
77
|
+
if not text.startswith("---"):
|
|
78
|
+
return "", text.strip()
|
|
79
|
+
end = text.find("\n---", 3)
|
|
80
|
+
if end == -1:
|
|
81
|
+
return "", text.strip()
|
|
82
|
+
return text[3:end], text[end + 4:].strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _description(frontmatter: str, default: str) -> str:
|
|
86
|
+
"""Read `description:`, joining YAML folded (`>`) blocks onto one line."""
|
|
87
|
+
folded = re.search(r"^description:\s*>[-+]?\s*\n((?:[ \t]+\S.*\n?)+)", frontmatter, re.M)
|
|
88
|
+
if folded:
|
|
89
|
+
return " ".join(line.strip() for line in folded.group(1).splitlines() if line.strip())
|
|
90
|
+
inline = re.search(r"^description:\s*(\S.*)$", frontmatter, re.M)
|
|
91
|
+
return inline.group(1).strip() if inline else default
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _agent_toml(filename: str) -> str:
|
|
95
|
+
"""Build one subagent's TOML, preferring the canonical advisor document so
|
|
96
|
+
Codex ships the advisor's full decision rules rather than a summary of them.
|
|
56
97
|
"""
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
body = ""
|
|
98
|
+
name = filename.removesuffix(".toml")
|
|
99
|
+
default_description, default_body = _FALLBACKS[filename]
|
|
100
|
+
description, body = default_description, default_body
|
|
101
|
+
|
|
62
102
|
root = _agents_source_root()
|
|
63
103
|
if root is not None:
|
|
64
|
-
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
source = root / f"{name}.md"
|
|
105
|
+
if source.exists():
|
|
106
|
+
frontmatter, source_body = _split_frontmatter(source.read_text(encoding="utf-8"))
|
|
107
|
+
if source_body:
|
|
108
|
+
description = _description(frontmatter, default_description)
|
|
109
|
+
body = source_body
|
|
110
|
+
|
|
111
|
+
# description is a TOML basic string, so quotes and backslashes must be
|
|
112
|
+
# escaped — the advisor descriptions really do contain quoted questions.
|
|
113
|
+
escaped = description.replace("\\", "\\\\").replace('"', '\\"')
|
|
114
|
+
# A literal multi-line string ('''...''') performs no escape processing,
|
|
115
|
+
# which keeps the advisor markdown byte-exact. It cannot contain the
|
|
116
|
+
# sequence that closes it, so fall back to an escaped basic string in that
|
|
117
|
+
# case rather than editing the advisor's own text.
|
|
118
|
+
if "'''" in body:
|
|
119
|
+
basic = body.replace("\\", "\\\\").replace('"', '\\"')
|
|
120
|
+
instructions = '"""\n' + basic + '\n"""'
|
|
121
|
+
else:
|
|
122
|
+
instructions = "'''\n" + body + "\n'''"
|
|
123
|
+
return f'name = "{name}"\ndescription = "{escaped}"\ninstructions = {instructions}\n'
|
|
84
124
|
|
|
85
125
|
|
|
86
126
|
def _agent_files() -> dict:
|
|
87
127
|
"""Return {filename: TOML content} for the Codex subagents."""
|
|
88
|
-
return {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
128
|
+
return {filename: _agent_toml(filename) for filename in AGENTS}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _digest(text: str) -> str:
|
|
132
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
133
|
+
|
|
92
134
|
|
|
135
|
+
def _read_manifest(agents_root: Path) -> dict:
|
|
136
|
+
try:
|
|
137
|
+
loaded = json.loads((agents_root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
|
138
|
+
except (OSError, ValueError):
|
|
139
|
+
return {}
|
|
140
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
93
141
|
|
|
94
|
-
|
|
95
|
-
|
|
142
|
+
|
|
143
|
+
def _is_ours(target: Path, manifest: dict) -> bool:
|
|
144
|
+
"""True when this installer may overwrite `target`.
|
|
145
|
+
|
|
146
|
+
Either it does not exist yet, or its content is byte-identical to what this
|
|
147
|
+
installer last recorded writing there.
|
|
148
|
+
"""
|
|
149
|
+
if not target.exists():
|
|
150
|
+
return True
|
|
151
|
+
recorded = manifest.get(target.name)
|
|
152
|
+
if not recorded:
|
|
153
|
+
return False
|
|
154
|
+
try:
|
|
155
|
+
return _digest(target.read_text(encoding="utf-8")) == recorded
|
|
156
|
+
except OSError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _skills_read_elsewhere(home: Path, skills_root: Path) -> list[str]:
|
|
161
|
+
"""Skill paths Codex may read that this installer does not write.
|
|
162
|
+
|
|
163
|
+
Some setups point ~/.codex/skills/<name> at a checkout instead of using the
|
|
164
|
+
copies under ~/.agents/skills, in which case writing the copies refreshes
|
|
165
|
+
nothing Codex will load. Report those paths so the caller can say so rather
|
|
166
|
+
than claiming a refresh it did not perform.
|
|
167
|
+
"""
|
|
168
|
+
elsewhere = []
|
|
169
|
+
for skill in SKILLS:
|
|
170
|
+
candidate = home / ".codex" / "skills" / skill
|
|
171
|
+
if not candidate.exists() and not candidate.is_symlink():
|
|
172
|
+
continue
|
|
173
|
+
resolved = candidate.resolve() if candidate.is_symlink() else candidate
|
|
174
|
+
if resolved != (skills_root / skill).resolve():
|
|
175
|
+
elsewhere.append(str(candidate))
|
|
176
|
+
return elsewhere
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def install_assets(*, home: Path | None = None, dry_run: bool = False, force: bool = False) -> dict:
|
|
180
|
+
"""Copy only named SLM assets; never rewrite user-owned assets.
|
|
181
|
+
|
|
182
|
+
An agent file that this installer did not write, or that has been edited
|
|
183
|
+
since it did, is preserved and reported under ``agents_preserved`` rather
|
|
184
|
+
than overwritten. Pass ``force=True`` to overwrite anyway, which first
|
|
185
|
+
copies the existing file aside with a ``.bak`` suffix.
|
|
186
|
+
"""
|
|
96
187
|
home = home or Path.home()
|
|
97
188
|
source = _source_root()
|
|
98
189
|
missing = [skill for skill in SKILLS if not (source / skill / "SKILL.md").exists()]
|
|
99
190
|
if missing:
|
|
100
191
|
return {"success": False, "errors": [f"missing bundled skills: {', '.join(missing)}"]}
|
|
101
|
-
|
|
102
|
-
return {"success": True, "skills": list(SKILLS), "agents": list(AGENTS), "dry_run": True}
|
|
192
|
+
|
|
103
193
|
skills_root, agents_root = home / ".agents" / "skills", home / ".codex" / "agents"
|
|
194
|
+
manifest = _read_manifest(agents_root)
|
|
195
|
+
planned = _agent_files()
|
|
196
|
+
|
|
197
|
+
writable, preserved = [], []
|
|
198
|
+
for filename, content in planned.items():
|
|
199
|
+
target = agents_root / filename
|
|
200
|
+
if force or _is_ours(target, manifest):
|
|
201
|
+
writable.append((filename, content, target))
|
|
202
|
+
else:
|
|
203
|
+
preserved.append(str(target))
|
|
204
|
+
|
|
205
|
+
skill_targets = [skills_root / skill / "SKILL.md" for skill in SKILLS]
|
|
206
|
+
result = {
|
|
207
|
+
"success": True,
|
|
208
|
+
"dry_run": dry_run,
|
|
209
|
+
"skills": list(SKILLS),
|
|
210
|
+
"agents": [filename for filename, _, _ in writable],
|
|
211
|
+
"skills_written": [str(path) for path in skill_targets],
|
|
212
|
+
"agents_written": [str(target) for _, _, target in writable],
|
|
213
|
+
"agents_preserved": preserved,
|
|
214
|
+
"skills_read_elsewhere": _skills_read_elsewhere(home, skills_root),
|
|
215
|
+
}
|
|
216
|
+
if dry_run:
|
|
217
|
+
return result
|
|
218
|
+
|
|
104
219
|
skills_root.mkdir(parents=True, exist_ok=True)
|
|
105
220
|
agents_root.mkdir(parents=True, exist_ok=True)
|
|
106
221
|
for skill in SKILLS:
|
|
107
222
|
target = skills_root / skill
|
|
108
223
|
target.mkdir(parents=True, exist_ok=True)
|
|
109
224
|
shutil.copy2(source / skill / "SKILL.md", target / "SKILL.md")
|
|
110
|
-
for filename, content in
|
|
111
|
-
|
|
112
|
-
|
|
225
|
+
for filename, content, target in writable:
|
|
226
|
+
if force and target.exists():
|
|
227
|
+
shutil.copy2(target, target.with_suffix(target.suffix + ".bak"))
|
|
228
|
+
target.write_text(content, encoding="utf-8")
|
|
229
|
+
manifest[filename] = _digest(content)
|
|
230
|
+
if writable:
|
|
231
|
+
(agents_root / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
232
|
+
return result
|
|
113
233
|
|
|
114
234
|
|
|
115
235
|
def remove_assets(*, home: Path | None = None, dry_run: bool = False) -> dict:
|
|
@@ -326,9 +326,20 @@ class ContextualBandit:
|
|
|
326
326
|
play_id: int,
|
|
327
327
|
reward: float,
|
|
328
328
|
kind: str = "proxy_position",
|
|
329
|
+
weight: float = 1.0,
|
|
329
330
|
) -> bool:
|
|
330
331
|
"""Apply the reward to the (profile, stratum, arm) posterior.
|
|
331
332
|
|
|
333
|
+
``weight`` scales how much this one observation counts, and exists for
|
|
334
|
+
inverse-propensity correction: the policy chose what was shown, so an
|
|
335
|
+
arm it almost always shows produces weak evidence whatever happens to
|
|
336
|
+
it, while one it rarely shows produces strong evidence. Weighting by
|
|
337
|
+
the inverse of that probability is what keeps the posterior from
|
|
338
|
+
recording popularity the policy manufactured. See ``propensity.py``.
|
|
339
|
+
|
|
340
|
+
A weight of 1.0 is the uncorrected update and the default, so a caller
|
|
341
|
+
with no competitor posteriors to estimate against changes nothing.
|
|
342
|
+
|
|
332
343
|
Returns True on success. Never raises — DB failures logged at WARN.
|
|
333
344
|
Cache invalidated on success (B5).
|
|
334
345
|
"""
|
|
@@ -343,6 +354,15 @@ class ContextualBandit:
|
|
|
343
354
|
elif reward_f > 1.0:
|
|
344
355
|
reward_f = 1.0
|
|
345
356
|
|
|
357
|
+
try:
|
|
358
|
+
weight_f = float(weight)
|
|
359
|
+
except (TypeError, ValueError):
|
|
360
|
+
weight_f = 1.0
|
|
361
|
+
# A non-positive weight would either freeze the arm or subtract
|
|
362
|
+
# evidence; neither is a meaningful observation.
|
|
363
|
+
if weight_f <= 0.0:
|
|
364
|
+
weight_f = 1.0
|
|
365
|
+
|
|
346
366
|
try:
|
|
347
367
|
conn = _conn_for(self._db_path)
|
|
348
368
|
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
@@ -389,8 +409,8 @@ class ContextualBandit:
|
|
|
389
409
|
" plays = plays + 1, "
|
|
390
410
|
" last_played_at = ? "
|
|
391
411
|
"WHERE profile_id = ? AND stratum = ? AND arm_id = ?",
|
|
392
|
-
(cap, reward_f, cap, 1.0 - reward_f,
|
|
393
|
-
profile_id, stratum, arm_id),
|
|
412
|
+
(cap, weight_f * reward_f, cap, weight_f * (1.0 - reward_f),
|
|
413
|
+
now, profile_id, stratum, arm_id),
|
|
394
414
|
)
|
|
395
415
|
conn.execute(
|
|
396
416
|
"UPDATE bandit_plays "
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Engagement observed from what an agent did, not from what it was asked to say.
|
|
5
|
+
|
|
6
|
+
WHY THIS EXISTS
|
|
7
|
+
---------------
|
|
8
|
+
The reward ladder in ``reward_proxy`` asks one question — did a recalled
|
|
9
|
+
``fact_id`` appear verbatim in a later tool event — and defaults to ``0.5`` when
|
|
10
|
+
the answer is no. Both halves of that are broken.
|
|
11
|
+
|
|
12
|
+
The question requires the caller to copy an opaque marker into its next tool
|
|
13
|
+
call. Nothing makes it: no tool description asks for it and no rule requires
|
|
14
|
+
it. A design that depends on a behaviour nothing produces has no signal, and
|
|
15
|
+
in practice no signal was ever registered.
|
|
16
|
+
|
|
17
|
+
The default is worse than no answer. ``alpha += 0.5`` and ``beta += 0.5`` move
|
|
18
|
+
together, so a Beta posterior keeps its mean at exactly 0.5 while its variance
|
|
19
|
+
*shrinks*. Every neutral settlement makes an arm more confident that it is
|
|
20
|
+
average and harder for real evidence to move later. Neutral is not a safe
|
|
21
|
+
default; it is a slow commitment to knowing nothing.
|
|
22
|
+
|
|
23
|
+
WHAT REPLACES IT
|
|
24
|
+
----------------
|
|
25
|
+
Features computed from rows the system already writes. An agent that uses a
|
|
26
|
+
recalled memory leaves traces whether or not it cooperates: it reads or edits
|
|
27
|
+
the files the memory names, its next actions stay on the memory's subject, it
|
|
28
|
+
writes a follow-up memory that overlaps. None of that requires it to quote an
|
|
29
|
+
identifier.
|
|
30
|
+
|
|
31
|
+
Every feature here is observable, and each is reported separately so a reward
|
|
32
|
+
can say *why*. When nothing is observable the answer is ``None`` — abstain —
|
|
33
|
+
never a number.
|
|
34
|
+
|
|
35
|
+
NOT SELF-REFERENTIAL
|
|
36
|
+
--------------------
|
|
37
|
+
A signal derived from the mechanism it evaluates cannot detect that mechanism's
|
|
38
|
+
failure. Ranking position is therefore not a feature: the bandit chose the
|
|
39
|
+
ranking, so scoring it by what the bandit ranked first would confirm the bandit
|
|
40
|
+
to itself. Position enters only in ``propensity.py``, as a correction applied
|
|
41
|
+
*against* the observation, never as evidence for it.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import re
|
|
48
|
+
import sqlite3
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from datetime import datetime, timedelta
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"EngagementFeatures",
|
|
54
|
+
"OBSERVATION_WINDOW_SEC",
|
|
55
|
+
"extract_features",
|
|
56
|
+
"tokenize",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
#: How long after a recall an action may still be counted as caused by it.
|
|
60
|
+
#: Wider than the old 30 s hit window: an agent reads a memory, then thinks,
|
|
61
|
+
#: then acts, and 30 s discarded most of the acting.
|
|
62
|
+
OBSERVATION_WINDOW_SEC = 300
|
|
63
|
+
|
|
64
|
+
#: Tools whose payload naming a recalled memory's content is strong evidence
|
|
65
|
+
#: the memory was actually used, not merely returned.
|
|
66
|
+
_ARTIFACT_TOOLS = frozenset({"Write", "Edit", "NotebookEdit", "MultiEdit"})
|
|
67
|
+
|
|
68
|
+
#: Words carrying no topical signal; overlap on these is noise, and without
|
|
69
|
+
#: this filter every pair of payloads overlaps.
|
|
70
|
+
_STOPWORDS = frozenset("""
|
|
71
|
+
a an the and or but if then than that this these those is are was were be been
|
|
72
|
+
being to of in on at by for with from as it its into over under about after
|
|
73
|
+
before not no nor so such can will would should could may might must do does
|
|
74
|
+
did done have has had i you he she we they them his her their our your my me
|
|
75
|
+
""".split())
|
|
76
|
+
|
|
77
|
+
_TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.\-/]{2,}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def tokenize(text: str) -> set[str]:
|
|
81
|
+
"""Content-bearing lowercase tokens, stopwords and short words removed."""
|
|
82
|
+
if not text:
|
|
83
|
+
return set()
|
|
84
|
+
return {
|
|
85
|
+
tok for tok in (m.group(0).lower() for m in _TOKEN_RE.finditer(text))
|
|
86
|
+
if tok not in _STOPWORDS and len(tok) > 2
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class EngagementFeatures:
|
|
92
|
+
"""What was observed after one recall. Every field is measured, not inferred.
|
|
93
|
+
|
|
94
|
+
``observed`` is the honest summary: False means nothing happened that this
|
|
95
|
+
module can see, which is a reason to abstain rather than a reason to
|
|
96
|
+
penalise. An agent may have used a memory perfectly and left no trace.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
#: Best Jaccard-style overlap between any recalled fact and any following
|
|
100
|
+
#: tool payload, in [0, 1].
|
|
101
|
+
peak_overlap: float = 0.0
|
|
102
|
+
#: Overlap restricted to file-writing tools — the memory reached an artifact.
|
|
103
|
+
artifact_overlap: float = 0.0
|
|
104
|
+
#: A later remember/update_memory overlapping a recalled fact.
|
|
105
|
+
followup_write_overlap: float = 0.0
|
|
106
|
+
#: The same question asked again inside the requery window: the answer did
|
|
107
|
+
#: not satisfy. The one unambiguous negative available.
|
|
108
|
+
requeried: bool = False
|
|
109
|
+
#: Seconds from recall to the first following action, when there was one.
|
|
110
|
+
dwell_sec: float | None = None
|
|
111
|
+
#: Tool events seen in the window at all.
|
|
112
|
+
action_count: int = 0
|
|
113
|
+
#: Which fact ids the overlap landed on, so a reward can be explained.
|
|
114
|
+
matched_fact_ids: list[str] = field(default_factory=list)
|
|
115
|
+
#: A recalled fact's own id appeared verbatim in a later tool event. Rare,
|
|
116
|
+
#: because nothing makes an agent echo it — but unambiguous when it does
|
|
117
|
+
#: happen, so it is kept as the strongest single piece of evidence rather
|
|
118
|
+
#: than discarded along with the ladder that relied on it alone.
|
|
119
|
+
marker_hit: bool = False
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def observed(self) -> bool:
|
|
123
|
+
"""Whether anything at all was seen. Drives abstention."""
|
|
124
|
+
return bool(
|
|
125
|
+
self.requeried
|
|
126
|
+
or self.marker_hit
|
|
127
|
+
or self.action_count > 0
|
|
128
|
+
and (
|
|
129
|
+
self.peak_overlap > 0.0
|
|
130
|
+
or self.artifact_overlap > 0.0
|
|
131
|
+
or self.followup_write_overlap > 0.0
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
|
137
|
+
try:
|
|
138
|
+
return conn.execute(
|
|
139
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,),
|
|
140
|
+
).fetchone() is not None
|
|
141
|
+
except sqlite3.Error:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _fact_tokens(conn: sqlite3.Connection, fact_ids: list[str]) -> dict[str, set[str]]:
|
|
146
|
+
"""Content tokens per fact. Entities are folded in when the column exists,
|
|
147
|
+
because a memory's entities are what a later action is most likely to name.
|
|
148
|
+
"""
|
|
149
|
+
if not fact_ids or not _table_exists(conn, "atomic_facts"):
|
|
150
|
+
return {}
|
|
151
|
+
placeholders = ",".join("?" * len(fact_ids))
|
|
152
|
+
try:
|
|
153
|
+
rows = conn.execute(
|
|
154
|
+
f"SELECT fact_id, content, COALESCE(entities_json,'') " # noqa: S608
|
|
155
|
+
f"FROM atomic_facts WHERE fact_id IN ({placeholders})",
|
|
156
|
+
tuple(fact_ids),
|
|
157
|
+
).fetchall()
|
|
158
|
+
except sqlite3.Error:
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
out: dict[str, set[str]] = {}
|
|
162
|
+
for fact_id, content, entities_json in rows:
|
|
163
|
+
tokens = tokenize(content or "")
|
|
164
|
+
if entities_json:
|
|
165
|
+
try:
|
|
166
|
+
parsed = json.loads(entities_json)
|
|
167
|
+
except (ValueError, TypeError):
|
|
168
|
+
parsed = None
|
|
169
|
+
if isinstance(parsed, list):
|
|
170
|
+
for ent in parsed:
|
|
171
|
+
tokens |= tokenize(ent if isinstance(ent, str) else str(ent))
|
|
172
|
+
if tokens:
|
|
173
|
+
out[str(fact_id)] = tokens
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _following_events(
|
|
178
|
+
conn: sqlite3.Connection,
|
|
179
|
+
session_id: str,
|
|
180
|
+
profile_id: str,
|
|
181
|
+
recalled_at: datetime,
|
|
182
|
+
) -> list[tuple[str, str]]:
|
|
183
|
+
"""(tool_name, payload) for actions in this conversation after the recall.
|
|
184
|
+
|
|
185
|
+
Scoped to the conversation. Without that predicate a busy machine's
|
|
186
|
+
unrelated activity in the same five minutes would read as engagement.
|
|
187
|
+
"""
|
|
188
|
+
if not _table_exists(conn, "tool_events"):
|
|
189
|
+
return []
|
|
190
|
+
start = recalled_at.isoformat()
|
|
191
|
+
end = (recalled_at + timedelta(seconds=OBSERVATION_WINDOW_SEC)).isoformat()
|
|
192
|
+
try:
|
|
193
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(tool_events)")}
|
|
194
|
+
except sqlite3.Error:
|
|
195
|
+
return []
|
|
196
|
+
if not {"session_id", "created_at", "tool_name"} <= cols:
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
sql = (
|
|
200
|
+
"SELECT tool_name, COALESCE(input_summary,'') || ' ' || "
|
|
201
|
+
"COALESCE(output_summary,'') FROM tool_events "
|
|
202
|
+
"WHERE session_id = ? AND created_at > ? AND created_at <= ?"
|
|
203
|
+
)
|
|
204
|
+
params: tuple = (session_id, start, end)
|
|
205
|
+
if "profile_id" in cols:
|
|
206
|
+
sql += " AND (profile_id = ? OR profile_id IS NULL)"
|
|
207
|
+
params += (profile_id,)
|
|
208
|
+
sql += " ORDER BY created_at LIMIT 200"
|
|
209
|
+
try:
|
|
210
|
+
return [(str(r[0]), str(r[1])) for r in conn.execute(sql, params)]
|
|
211
|
+
except sqlite3.Error:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
#: A single word in common is coincidence, not evidence. Any two texts about
|
|
216
|
+
#: software share one token eventually, and containment makes that worse: a
|
|
217
|
+
#: memory that reduces to one content word scores a perfect 1.0 against every
|
|
218
|
+
#: payload containing it. Two independent tokens is the cheapest threshold that
|
|
219
|
+
#: distinguishes a shared subject from a shared word.
|
|
220
|
+
_MIN_OVERLAP_TOKENS = 2
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _overlap(fact_tokens: set[str], payload_tokens: set[str]) -> float:
|
|
224
|
+
"""Containment of the memory in the action, not symmetric Jaccard.
|
|
225
|
+
|
|
226
|
+
A tool payload is often far larger than a fact, and Jaccard would divide
|
|
227
|
+
that signal away precisely when the evidence is strongest.
|
|
228
|
+
"""
|
|
229
|
+
if not fact_tokens or not payload_tokens:
|
|
230
|
+
return 0.0
|
|
231
|
+
shared = fact_tokens & payload_tokens
|
|
232
|
+
if len(shared) < _MIN_OVERLAP_TOKENS:
|
|
233
|
+
return 0.0
|
|
234
|
+
return len(shared) / len(fact_tokens)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def extract_features(
|
|
238
|
+
memory_conn: sqlite3.Connection,
|
|
239
|
+
*,
|
|
240
|
+
session_id: str,
|
|
241
|
+
profile_id: str,
|
|
242
|
+
fact_ids: list[str],
|
|
243
|
+
recalled_at: datetime,
|
|
244
|
+
requeried: bool = False,
|
|
245
|
+
marker_hit: bool = False,
|
|
246
|
+
) -> EngagementFeatures:
|
|
247
|
+
"""Observe what followed one recall. Never raises; returns empty on error."""
|
|
248
|
+
features = EngagementFeatures(
|
|
249
|
+
requeried=bool(requeried), marker_hit=bool(marker_hit),
|
|
250
|
+
)
|
|
251
|
+
try:
|
|
252
|
+
by_fact = _fact_tokens(memory_conn, [str(f) for f in fact_ids])
|
|
253
|
+
events = _following_events(memory_conn, session_id, profile_id, recalled_at)
|
|
254
|
+
except sqlite3.Error:
|
|
255
|
+
return features
|
|
256
|
+
|
|
257
|
+
features.action_count = len(events)
|
|
258
|
+
if not by_fact or not events:
|
|
259
|
+
return features
|
|
260
|
+
|
|
261
|
+
matched: set[str] = set()
|
|
262
|
+
for tool_name, payload in events:
|
|
263
|
+
payload_tokens = tokenize(payload)
|
|
264
|
+
if not payload_tokens:
|
|
265
|
+
continue
|
|
266
|
+
for fact_id, tokens in by_fact.items():
|
|
267
|
+
score = _overlap(tokens, payload_tokens)
|
|
268
|
+
if score <= 0.0:
|
|
269
|
+
continue
|
|
270
|
+
matched.add(fact_id)
|
|
271
|
+
features.peak_overlap = max(features.peak_overlap, score)
|
|
272
|
+
if tool_name in _ARTIFACT_TOOLS:
|
|
273
|
+
features.artifact_overlap = max(features.artifact_overlap, score)
|
|
274
|
+
if tool_name.endswith(("remember", "update_memory")):
|
|
275
|
+
features.followup_write_overlap = max(
|
|
276
|
+
features.followup_write_overlap, score,
|
|
277
|
+
)
|
|
278
|
+
features.matched_fact_ids = sorted(matched)
|
|
279
|
+
return features
|
|
@@ -26,6 +26,8 @@ by ``unified_daemon.py``'s lifespan hook.
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
|
|
30
|
+
from superlocalmemory.core.session_identity import is_conversation
|
|
29
31
|
import logging
|
|
30
32
|
import queue
|
|
31
33
|
import threading
|
|
@@ -112,6 +114,18 @@ def enqueue_recall(event: RecallEvent) -> None:
|
|
|
112
114
|
# If the caller can't name a session, we silently drop: this
|
|
113
115
|
# is a recall whose outcome cannot match to a signal anyway.
|
|
114
116
|
return
|
|
117
|
+
if not is_conversation(event.session_id, event.profile_id):
|
|
118
|
+
# Same reason, one step further. A front that invents an id names
|
|
119
|
+
# itself, not a caller: ``engine:<pid>`` is the daemon every client
|
|
120
|
+
# shares, ``cli:<pid>`` a process that exits before any follow-on
|
|
121
|
+
# action, ``http:<ms>`` a single request. No tool event can ever
|
|
122
|
+
# carry one, so the outcome is unmatchable the moment it is written.
|
|
123
|
+
# Recording it anyway is not free: a play that is never settled from
|
|
124
|
+
# evidence is eventually settled from nothing, and a neutral update
|
|
125
|
+
# tightens a Beta posterior around its prior instead of leaving it
|
|
126
|
+
# movable.
|
|
127
|
+
_bump("recall_dropped_synthetic_session")
|
|
128
|
+
return
|
|
115
129
|
try:
|
|
116
130
|
_queue.put_nowait(event)
|
|
117
131
|
_bump("recall_enqueued")
|