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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +3 -3
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +1 -1
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/agents/slm-memory-advisor.md +49 -0
  25. package/plugin-src/agents/slm-optimize-advisor.md +44 -0
  26. package/plugin-src/rules/AGENTS.md +1 -1
  27. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  38. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  39. package/pyproject.toml +5 -1
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/cli/host_upgrades.py +21 -7
  42. package/src/superlocalmemory/core/engine.py +7 -1
  43. package/src/superlocalmemory/core/recall_pipeline.py +13 -6
  44. package/src/superlocalmemory/core/session_identity.py +14 -1
  45. package/src/superlocalmemory/hooks/codex_assets.py +165 -45
  46. package/src/superlocalmemory/learning/bandit.py +22 -2
  47. package/src/superlocalmemory/learning/engagement_features.py +279 -0
  48. package/src/superlocalmemory/learning/outcome_queue.py +14 -0
  49. package/src/superlocalmemory/learning/propensity.py +131 -0
  50. package/src/superlocalmemory/learning/reward.py +42 -16
  51. package/src/superlocalmemory/learning/reward_model.py +144 -0
  52. package/src/superlocalmemory/learning/reward_proxy.py +148 -22
  53. 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
- _MEMORY_ADVISOR_TOML = (
28
- 'name = "slm-memory-advisor"\n'
29
- 'description = "Use SuperLocalMemory safely: initialize once, recall before remember, and store only durable atomic facts."\n'
30
- 'instructions = "Use SLM for memory discipline only. Check results before claiming success; preserve private scope unless the user explicitly asks to share."\n'
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 _optimize_advisor_toml() -> str:
53
- """Build the optimize-advisor TOML from the canonical advisor doc so Codex
54
- ships the FULL decision rules (the 8-rule tree), not a one-line stub. Falls
55
- back to a short instruction only if the source doc is unavailable.
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
- description = (
58
- "Apply SuperLocalMemory's no-proxy context-optimization rules — reversible "
59
- "compression of large tool output and KV-caching of repeated reads/searches."
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
- src = root / "slm-optimize-advisor.md"
65
- if src.exists():
66
- text = src.read_text(encoding="utf-8")
67
- if text.startswith("---"): # strip YAML frontmatter, keep the guidance body
68
- end = text.find("\n---", 3)
69
- if end != -1:
70
- text = text[end + 4:]
71
- body = text.strip()
72
- if not body:
73
- body = (
74
- "Reduce context-window pressure with the Surface-B tools (reversible CCR "
75
- "compression + a per-agent KV cache); fail-open never block the task."
76
- )
77
- # TOML literal multi-line string ('''...'''): no escape processing, and the
78
- # advisor body contains no ''' sequence.
79
- return (
80
- 'name = "slm-optimize-advisor"\n'
81
- f'description = "{description}"\n'
82
- f"instructions = '''\n{body}\n'''\n"
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
- "slm-memory-advisor.toml": _MEMORY_ADVISOR_TOML,
90
- "slm-optimize-advisor.toml": _optimize_advisor_toml(),
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
- def install_assets(*, home: Path | None = None, dry_run: bool = False) -> dict:
95
- """Copy only named SLM assets; never rewrite user-owned assets."""
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
- if dry_run:
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 _agent_files().items():
111
- (agents_root / filename).write_text(content, encoding="utf-8")
112
- return {"success": True, "skills": list(SKILLS), "agents": list(AGENTS), "dry_run": False}
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, now,
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")