superlocalmemory 4.1.6 → 4.1.8

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +46 -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 +15 -5
  44. package/src/superlocalmemory/core/session_identity.py +14 -1
  45. package/src/superlocalmemory/hooks/codex_assets.py +165 -45
  46. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +122 -0
  47. package/src/superlocalmemory/learning/bandit.py +22 -2
  48. package/src/superlocalmemory/learning/engagement_features.py +279 -0
  49. package/src/superlocalmemory/learning/outcome_queue.py +14 -0
  50. package/src/superlocalmemory/learning/propensity.py +131 -0
  51. package/src/superlocalmemory/learning/reward.py +42 -16
  52. package/src/superlocalmemory/learning/reward_model.py +144 -0
  53. package/src/superlocalmemory/learning/reward_proxy.py +148 -22
  54. package/src/superlocalmemory/server/routes/v3_api.py +4 -3
  55. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +22 -0
@@ -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:
@@ -83,6 +83,122 @@ def _validate(marker: str) -> str | None:
83
83
  return None
84
84
 
85
85
 
86
+ # --- Behavioural telemetry -------------------------------------------------
87
+ # ``tool_events`` is the record of what the agent did, and several readers
88
+ # depend on it: assertion mining, skill-performance mining, and the engagement
89
+ # features that settle a recall against the actions which followed it. Each of
90
+ # those reads the table; none of them writes it. The only writers were an
91
+ # explicit ``log_tool_event`` call and a bulk importer, so on an ordinary
92
+ # install the table simply stopped receiving invocations and every reader
93
+ # quietly aged out with it.
94
+ #
95
+ # This hook already runs on exactly the right edge -- after each tool completes,
96
+ # with the session that ran it -- so it records the invocation here. It happens
97
+ # before the marker scan and independently of it: whether a recalled memory was
98
+ # named in the output decides what a *reward* is worth, not whether the action
99
+ # occurred at all.
100
+ _MAX_SUMMARY_LEN = 500
101
+
102
+ # Compiled once at import. This path now runs on every tool call rather than
103
+ # only on the ~1-in-5 that carry a marker, so per-call ``re`` compilation or a
104
+ # module import would be paid on the hot path for no benefit.
105
+ _SECRET_PATTERNS = (
106
+ (re.compile(r"\b(?:sk-|pk-|api[_-]?key[_-]?)[A-Za-z0-9_-]{10,}\b"),
107
+ "[REDACTED]"),
108
+ (re.compile(r"\b[A-Za-z0-9+/]{40,}={0,2}\b"), "[REDACTED]"),
109
+ (re.compile(r"password\s*[=:]\s*\S+", re.IGNORECASE),
110
+ "password=[REDACTED]"),
111
+ )
112
+
113
+
114
+ def _scrub(text: str) -> str:
115
+ """Strip credential-shaped substrings from telemetry text."""
116
+ for pattern, replacement in _SECRET_PATTERNS:
117
+ text = pattern.sub(replacement, text)
118
+ return text
119
+
120
+
121
+ def _summary(raw: object) -> str:
122
+ """Truncate-then-scrub ``raw`` for a summary column.
123
+
124
+ Truncation comes first so the regex cost is bounded by
125
+ ``_MAX_SUMMARY_LEN`` and not by the size of a tool response.
126
+ """
127
+ if raw is None:
128
+ return ""
129
+ if not isinstance(raw, str):
130
+ try:
131
+ import json as _json
132
+ raw = _json.dumps(raw, default=str)
133
+ except Exception:
134
+ try:
135
+ raw = str(raw)
136
+ except Exception:
137
+ return ""
138
+ return _scrub(raw[:_MAX_SUMMARY_LEN])
139
+
140
+
141
+ def _record_tool_event(
142
+ session_id: str,
143
+ tool_name: str,
144
+ payload: dict,
145
+ response_text: str,
146
+ ) -> bool:
147
+ """Append one ``tool_events`` row. Never raises; returns whether it wrote.
148
+
149
+ Failure is silent by design: telemetry must never be the reason a tool call
150
+ reports a problem to the user, and the hook contract is to exit 0 whatever
151
+ happens.
152
+ """
153
+ if not tool_name:
154
+ return False
155
+ try:
156
+ import sqlite3
157
+ from datetime import datetime, timezone
158
+
159
+ try:
160
+ from superlocalmemory.hooks.session_registry import (
161
+ resolve_active_profile,
162
+ )
163
+ profile_id = resolve_active_profile() or "default"
164
+ except Exception:
165
+ profile_id = "default"
166
+
167
+ project_path = ""
168
+ raw_cwd = payload.get("cwd")
169
+ if isinstance(raw_cwd, str):
170
+ project_path = raw_cwd[:_MAX_SUMMARY_LEN]
171
+
172
+ conn = sqlite3.connect(str(_memory_db_path()), timeout=0.05)
173
+ try:
174
+ conn.execute("PRAGMA busy_timeout=50")
175
+ conn.execute(
176
+ "INSERT INTO tool_events "
177
+ "(session_id, profile_id, project_path, tool_name, event_type,"
178
+ " input_summary, output_summary, duration_ms, metadata,"
179
+ " created_at) "
180
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
181
+ (
182
+ session_id,
183
+ profile_id,
184
+ project_path,
185
+ tool_name,
186
+ "complete",
187
+ _summary(payload.get("tool_input")),
188
+ _summary(response_text),
189
+ 0,
190
+ "{}",
191
+ datetime.now(timezone.utc).isoformat(),
192
+ ),
193
+ )
194
+ conn.commit()
195
+ finally:
196
+ conn.close()
197
+ return True
198
+ except Exception:
199
+ return False
200
+
201
+
86
202
  def _inner_main() -> str:
87
203
  """Return an ``outcome`` string (for perf log); never raises."""
88
204
  payload = read_stdin_json()
@@ -119,6 +235,12 @@ def _inner_main() -> str:
119
235
 
120
236
  # Response scan — capped BEFORE regex (bound O(cap)).
121
237
  response_text = summarize_response(payload.get("tool_response"))
238
+
239
+ # The invocation is recorded before anything is decided about markers. An
240
+ # action the agent took is a fact about the session on its own terms; the
241
+ # marker only decides whether it also settles a pending recall.
242
+ _record_tool_event(session_id, tool_name, payload, response_text)
243
+
122
244
  if not response_text:
123
245
  return "no_response"
124
246
 
@@ -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 "