superlocalmemory 4.1.9 → 4.1.12

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 (60) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +15 -4
  4. package/package.json +1 -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 +1 -1
  25. package/plugin-src/agents/slm-optimize-advisor.md +1 -1
  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 +1 -1
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/cli/daemon.py +18 -0
  42. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +229 -0
  43. package/src/superlocalmemory/learning/database.py +2 -1
  44. package/src/superlocalmemory/loops/__init__.py +2 -0
  45. package/src/superlocalmemory/loops/ledger.py +35 -0
  46. package/src/superlocalmemory/mcp/_daemon_proxy.py +27 -8
  47. package/src/superlocalmemory/mcp/server.py +6 -1
  48. package/src/superlocalmemory/mcp/tools_active.py +20 -5
  49. package/src/superlocalmemory/mcp/tools_brain.py +38 -1
  50. package/src/superlocalmemory/mcp/tools_learning.py +123 -4
  51. package/src/superlocalmemory/mcp/tools_loops.py +51 -17
  52. package/src/superlocalmemory/server/unified_daemon.py +17 -0
  53. package/src/superlocalmemory/storage/_migration_internals.py +2 -0
  54. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  55. package/src/superlocalmemory/storage/agent_experience.py +38 -4
  56. package/src/superlocalmemory/storage/execution_learning.py +285 -0
  57. package/src/superlocalmemory/storage/migration_runner.py +3 -0
  58. package/src/superlocalmemory/storage/migrations/M050_execution_learning_v2.py +70 -0
  59. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  60. package/src/superlocalmemory/ui/index.html +2 -2
@@ -15,11 +15,14 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import asyncio
18
19
  import json
19
20
  import logging
20
21
  import os
22
+ import sqlite3
21
23
  import uuid
22
24
  from datetime import datetime, timezone
25
+ from pathlib import Path
23
26
  from typing import Callable
24
27
 
25
28
  from mcp.types import ToolAnnotations
@@ -33,6 +36,72 @@ logger = logging.getLogger(__name__)
33
36
  _MAX_SUMMARY_LEN = 500 # Truncate input/output summaries
34
37
 
35
38
 
39
+ def settle_pending_session_outcomes(
40
+ memory_db_path: str | Path,
41
+ *,
42
+ profile_id: str,
43
+ session_id: str,
44
+ evidence_only: bool = False,
45
+ ) -> dict[str, int]:
46
+ """Finalize real pending recalls for one host-owned session.
47
+
48
+ This is deliberately a narrow adapter around ``EngagementRewardModel``:
49
+ it selects only pending rows belonging to the supplied active profile and
50
+ exact host session, then delegates every state transition and reward
51
+ calculation to the established finalizer. No feedback is inferred here.
52
+ Repeating the call is a no-op because settled rows are not selected.
53
+ """
54
+ session_id = session_id.strip()
55
+ if not session_id:
56
+ return {"selected": 0, "settled": 0}
57
+
58
+ path = Path(memory_db_path)
59
+ try:
60
+ with sqlite3.connect(str(path), timeout=2.0) as conn:
61
+ rows = conn.execute(
62
+ "SELECT outcome_id, signals_json FROM pending_outcomes "
63
+ "WHERE profile_id=? AND session_id=? AND status='pending'",
64
+ (profile_id, session_id),
65
+ ).fetchall()
66
+ except sqlite3.Error as exc:
67
+ raise RuntimeError("pending outcome lookup failed") from exc
68
+
69
+ if evidence_only:
70
+ from superlocalmemory.learning.reward import _carries_evidence
71
+
72
+ rows = [
73
+ row for row in rows
74
+ if _carries_evidence(json.loads(row[1] or "{}"))
75
+ ]
76
+
77
+ if not rows:
78
+ return {"selected": 0, "settled": 0}
79
+
80
+ from superlocalmemory.learning.reward import EngagementRewardModel
81
+
82
+ model = EngagementRewardModel(memory_db_path=str(path))
83
+ try:
84
+ for outcome_id, _signals_json in rows:
85
+ # Preserve the established keyword-only finalization contract.
86
+ model.finalize_outcome(outcome_id=outcome_id)
87
+ finally:
88
+ model.close()
89
+
90
+ outcome_ids = [row[0] for row in rows]
91
+ placeholders = ",".join("?" for _ in outcome_ids)
92
+ try:
93
+ with sqlite3.connect(str(path), timeout=2.0) as conn:
94
+ settled = conn.execute(
95
+ "SELECT COUNT(*) FROM pending_outcomes "
96
+ "WHERE profile_id=? AND session_id=? AND status='settled' "
97
+ f"AND outcome_id IN ({placeholders})",
98
+ (profile_id, session_id, *outcome_ids),
99
+ ).fetchone()[0]
100
+ except sqlite3.Error as exc:
101
+ raise RuntimeError("pending outcome settlement verification failed") from exc
102
+ return {"selected": len(outcome_ids), "settled": int(settled)}
103
+
104
+
36
105
  def register_learning_tools(server, get_engine: Callable) -> None:
37
106
  """Register learning MCP tools for two-way intelligence."""
38
107
 
@@ -45,6 +114,9 @@ def register_learning_tools(server, get_engine: Callable) -> None:
45
114
  output_summary: str = "",
46
115
  duration_ms: int = 0,
47
116
  metadata: str = "{}",
117
+ session_id: str = "",
118
+ agent_id: str = "",
119
+ project_path: str = "",
48
120
  ) -> dict:
49
121
  """Log a tool usage event for behavioral learning.
50
122
 
@@ -62,8 +134,11 @@ def register_learning_tools(server, get_engine: Callable) -> None:
62
134
  """
63
135
  engine = get_engine()
64
136
  now = datetime.now(timezone.utc).isoformat()
65
- session_id = os.environ.get("CLAUDE_SESSION_ID", "unknown")
66
- project_path = (
137
+ from superlocalmemory.mcp.session_binding import resolve_session_id
138
+ effective_session_id = resolve_session_id(
139
+ session_id, agent_id=agent_id or "mcp_client", allow_agent_fallback=True,
140
+ )
141
+ effective_project_path = project_path or (
67
142
  os.environ.get("CLAUDE_PROJECT_DIR")
68
143
  or os.environ.get("PROJECT_PATH")
69
144
  or os.getcwd()
@@ -86,15 +161,59 @@ def register_learning_tools(server, get_engine: Callable) -> None:
86
161
  "(session_id, profile_id, project_path, tool_name, event_type, "
87
162
  " input_summary, output_summary, duration_ms, metadata, created_at) "
88
163
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
89
- (session_id, engine.profile_id, project_path, tool_name,
164
+ (effective_session_id, engine.profile_id, effective_project_path, tool_name,
90
165
  event_type, input_clean, output_clean, duration_ms, metadata, now),
91
166
  )
92
167
  authorization.complete()
93
- return {"success": True, "tool": tool_name, "event": event_type}
168
+ return {"success": True, "tool": tool_name, "event": event_type,
169
+ "session_id": effective_session_id}
94
170
  except Exception as exc:
95
171
  logger.debug("log_tool_event failed: %s", exc)
96
172
  return {"success": False, "error": str(exc)}
97
173
 
174
+ @server.tool()
175
+ @admits(OperationKind.REMEMBER)
176
+ async def settle_session_outcomes(
177
+ session_id: str, agent_id: str = "", finalize: bool = False,
178
+ ) -> dict:
179
+ """Settle pending recall outcomes for one exact host session.
180
+
181
+ Native hosts call this after each turn and at finalization. Per-turn
182
+ calls settle only recalls that have actual evidence; finalization also
183
+ clears evidence-free pending rows. The operation never treats a hook
184
+ boundary itself as feedback. ``session_id`` must be explicit.
185
+ """
186
+ if not session_id or not session_id.strip():
187
+ return {"success": False, "error": "session_id is required"}
188
+ engine = get_engine()
189
+ try:
190
+ authorization = authorize_mcp_mutation(
191
+ engine,
192
+ "update",
193
+ mutation_source="mcp-settle-session-outcomes",
194
+ profile_id=engine.profile_id,
195
+ content_preview=agent_id[:100],
196
+ )
197
+ from superlocalmemory.hooks._outcome_common import memory_db_path
198
+
199
+ result = await asyncio.to_thread(
200
+ settle_pending_session_outcomes,
201
+ memory_db_path(),
202
+ profile_id=engine.profile_id,
203
+ session_id=session_id,
204
+ evidence_only=not finalize,
205
+ )
206
+ authorization.complete()
207
+ return {
208
+ "success": True,
209
+ "session_id": session_id.strip(),
210
+ "agent_id": agent_id.strip(),
211
+ **result,
212
+ }
213
+ except Exception as exc:
214
+ logger.debug("settle_session_outcomes failed: %s", exc)
215
+ return {"success": False, "error": str(exc)}
216
+
98
217
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
99
218
  async def get_assertions(
100
219
  min_confidence: float = 0.0,
@@ -47,6 +47,7 @@ from superlocalmemory.loops import (
47
47
  LedgerEntry,
48
48
  Verdict,
49
49
  engine_backed_ledger,
50
+ pool_backed_ledger,
50
51
  run_bounded_loop,
51
52
  )
52
53
 
@@ -69,7 +70,11 @@ _MAX_NAME_CHARS = 128
69
70
  _MAX_QUERY_CHARS = 2000
70
71
 
71
72
 
72
- def register_loop_tools(server, get_engine: Callable) -> None:
73
+ def register_loop_tools(
74
+ server,
75
+ get_engine: Callable,
76
+ get_pool: Callable | None = None,
77
+ ) -> None:
73
78
  """Register the 3 bounded-loop tools on *server*.
74
79
 
75
80
  *server* is duck-typed: must support the ``@server.tool()`` decorator.
@@ -144,10 +149,46 @@ def register_loop_tools(server, get_engine: Callable) -> None:
144
149
  min_score = float(gate_min_score)
145
150
 
146
151
  engine = get_engine()
152
+ pool = get_pool() if get_pool is not None else None
147
153
 
148
154
  def gate(lap: int) -> Verdict:
149
- resp = engine.recall(gate_query, limit=3, fast=True)
150
- all_results = getattr(resp, "results", None) or []
155
+ if pool is not None:
156
+ resp = pool.recall(gate_query, limit=3, fast=True)
157
+ if not isinstance(resp, dict) or resp.get("ok") is False:
158
+ raise RuntimeError(
159
+ (resp or {}).get("error", "owned SLM reader rejected recall")
160
+ if isinstance(resp, dict)
161
+ else "owned SLM reader returned an invalid response"
162
+ )
163
+ all_results = resp.get("results", []) or []
164
+ floored = bool(resp.get("no_confident_match", False))
165
+ else:
166
+ resp = engine.recall(gate_query, limit=3, fast=True)
167
+ all_results = getattr(resp, "results", None) or []
168
+ floored = bool(getattr(resp, "no_confident_match", False))
169
+
170
+ def _content(result: Any) -> str:
171
+ if isinstance(result, dict):
172
+ fact = result.get("fact")
173
+ if isinstance(fact, dict):
174
+ return str(fact.get("content", "") or "")
175
+ return str(result.get("content", "") or "")
176
+ return str(
177
+ getattr(getattr(result, "fact", None), "content", "") or ""
178
+ )
179
+
180
+ def _score(result: Any) -> float:
181
+ if isinstance(result, dict):
182
+ value = result.get("score", result.get("relevance_score", 0.0))
183
+ else:
184
+ value = getattr(result, "score", None)
185
+ if value is None:
186
+ value = getattr(result, "relevance_score", 0.0) or 0.0
187
+ try:
188
+ return float(value)
189
+ except (TypeError, ValueError):
190
+ return 0.0
191
+
151
192
  # Exclude the loop's own audit records from the gate evaluation.
152
193
  # Each lap writes a LedgerEntry (JSON with "run_id" + "lap") into
153
194
  # SLM via store_fast; the FTS5 trigger indexes every such insert.
@@ -157,20 +198,9 @@ def register_loop_tools(server, get_engine: Callable) -> None:
157
198
  # can satisfy an independent gate.
158
199
  results = [
159
200
  r for r in all_results
160
- if LedgerEntry.from_json(
161
- getattr(getattr(r, "fact", None), "content", "") or ""
162
- ) is None
201
+ if LedgerEntry.from_json(_content(r)) is None
163
202
  ]
164
- floored = bool(getattr(resp, "no_confident_match", False))
165
- best: float = 0.0
166
- for r in results:
167
- s = getattr(r, "score", None)
168
- if s is None:
169
- s = getattr(r, "relevance_score", 0.0) or 0.0
170
- try:
171
- best = max(best, float(s))
172
- except (TypeError, ValueError):
173
- pass
203
+ best = max((_score(result) for result in results), default=0.0)
174
204
  passed = bool(results) and not floored and best >= min_score
175
205
  return Verdict(
176
206
  passed,
@@ -187,7 +217,11 @@ def register_loop_tools(server, get_engine: Callable) -> None:
187
217
  time.sleep(poll)
188
218
  return LapResult(changed=False, tokens=0)
189
219
 
190
- ledger = engine_backed_ledger(engine)
220
+ ledger = (
221
+ pool_backed_ledger(pool, engine)
222
+ if pool is not None
223
+ else engine_backed_ledger(engine)
224
+ )
191
225
 
192
226
  # The loop blocks (sleeps between laps); run it off the event loop.
193
227
  # The engine's per-call WAL connection model makes this thread-safe.
@@ -630,6 +630,10 @@ class RememberRequest(BaseModel):
630
630
  metadata: dict | None = None # v3.4.26: pass-through from MCP pool_store
631
631
  idempotency_key: str | None = None
632
632
  session_id: str = ""
633
+ # Optional compare-and-write guard for profile-sensitive clients. The
634
+ # daemon remains single-profile, so a stale MCP client must fail instead
635
+ # of silently writing into whichever profile another client selected.
636
+ profile_id: str = ""
633
637
  #: WHEN this memory is about, as distinct from when it was written.
634
638
  #:
635
639
  #: The internal admission record has carried this field all along and this
@@ -693,6 +697,18 @@ class RememberRequest(BaseModel):
693
697
  shared_with: list[str] | None = None
694
698
 
695
699
 
700
+ def _require_remember_profile(requested: str, active: str) -> None:
701
+ """Reject a stale profile-bound write before any durable mutation."""
702
+ if requested and requested != active:
703
+ raise HTTPException(
704
+ status_code=409,
705
+ detail=(
706
+ f"profile mismatch: request is bound to '{requested}' but "
707
+ f"the daemon currently serves '{active}'"
708
+ ),
709
+ )
710
+
711
+
696
712
  class SessionOpenRequest(BaseModel):
697
713
  # #49: local session-open warm (no model roundtrip needed)
698
714
  project_path: str = ""
@@ -4671,6 +4687,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4671
4687
  trusted_actor_id = _require_write_actor(request)
4672
4688
  _update_activity()
4673
4689
  engine = _get_engine_or_503()
4690
+ _require_remember_profile(req.profile_id, engine._profile_id)
4674
4691
 
4675
4692
  # v3.6.15 multi-scope: resolve the write scope. ``None`` (not specified
4676
4693
  # by the caller) → the configured default_scope (personal). Shared
@@ -165,6 +165,7 @@ from superlocalmemory.storage.migrations import (
165
165
  M047_fisher_vectors_are_stored_like_every_other_vector as _M047,
166
166
  M048_upcoming_holds_only_what_is_upcoming as _M048,
167
167
  M049_a_schema_version_marker_is_one_row as _M049,
168
+ M050_execution_learning_v2 as _M050,
168
169
  )
169
170
 
170
171
  # Emit under the runner's logger name so operational log filters that key on
@@ -223,6 +224,7 @@ _MODULES = {
223
224
  _M047.NAME: _M047,
224
225
  _M048.NAME: _M048,
225
226
  _M049.NAME: _M049,
227
+ _M050.NAME: _M050,
226
228
  }
227
229
 
228
230
  # Exact historical DDL fingerprints whose resulting schema is intentionally
@@ -21,7 +21,7 @@ import sqlite3
21
21
  from pathlib import Path
22
22
 
23
23
  #: Highest schema_version this runner can write. Matches the trailing serial of
24
- #: the latest migration (M049). Increment when adding new migrations or
24
+ #: the latest migration (M050). Increment when adding new migrations or
25
25
  #: table-level breaking changes.
26
26
  #:
27
27
  #: This sat at 42 while M043, M044 and M045 shipped, so for three migrations the
@@ -44,7 +44,7 @@ from pathlib import Path
44
44
  #: be additive is a silent bad write. Those are not comparable, and judging
45
45
  #: additivity per migration is exactly the judgement that let it fall three
46
46
  #: behind.
47
- SUPPORTED_SCHEMA_VERSION: int = 49
47
+ SUPPORTED_SCHEMA_VERSION: int = 50
48
48
 
49
49
 
50
50
  class SchemaVersionError(RuntimeError):
@@ -254,9 +254,33 @@ class AgentExperienceStore:
254
254
  external_count = conn.execute(
255
255
  "DELETE FROM external_evidence_receipts WHERE profile_id=?", (profile_id,)
256
256
  ).rowcount
257
+ execution_count = 0
258
+ execution_tables = {
259
+ row[0]
260
+ for row in conn.execute(
261
+ "SELECT name FROM sqlite_master WHERE type='table' "
262
+ "AND name IN ('execution_learning_receipts', 'execution_learning_events')"
263
+ )
264
+ }
265
+ if execution_tables and execution_tables != {
266
+ "execution_learning_receipts", "execution_learning_events"
267
+ }:
268
+ raise sqlite3.OperationalError("incomplete execution-learning receipt schema")
269
+ has_execution = bool(execution_tables)
270
+ if has_execution:
271
+ execution_count += conn.execute(
272
+ "DELETE FROM execution_learning_events WHERE profile_id=?", (profile_id,)
273
+ ).rowcount
274
+ execution_count += conn.execute(
275
+ "DELETE FROM execution_learning_receipts WHERE profile_id=?", (profile_id,)
276
+ ).rowcount
257
277
  receipt_tables = ["agent_experiences", "cognitive_turn_receipts"]
258
278
  if has_external:
259
279
  receipt_tables.append("external_evidence_receipts")
280
+ if has_execution:
281
+ receipt_tables.extend([
282
+ "execution_learning_receipts", "execution_learning_events",
283
+ ])
260
284
  residue = sum(
261
285
  int(
262
286
  conn.execute(
@@ -267,7 +291,7 @@ class AgentExperienceStore:
267
291
  )
268
292
  if residue:
269
293
  raise RuntimeError("learning receipt erasure left profile residue")
270
- return experience_count + turn_count + external_count
294
+ return experience_count + turn_count + external_count + execution_count
271
295
 
272
296
  return self._write(erase)
273
297
 
@@ -434,7 +458,8 @@ def purge_profile_receipts(
434
458
  for row in conn.execute(
435
459
  "SELECT name FROM sqlite_master WHERE type='table' "
436
460
  "AND name IN ('agent_experiences', 'cognitive_turn_receipts', "
437
- "'agent_receipt_profile_closures', 'external_evidence_receipts')"
461
+ "'agent_receipt_profile_closures', 'external_evidence_receipts', "
462
+ "'execution_learning_receipts', 'execution_learning_events')"
438
463
  )
439
464
  }
440
465
  if not tables:
@@ -443,13 +468,22 @@ def purge_profile_receipts(
443
468
  "agent_experiences", "cognitive_turn_receipts", "agent_receipt_profile_closures"
444
469
  }
445
470
  if tables != expected:
446
- if tables == expected | {"external_evidence_receipts"}:
471
+ optional = {"external_evidence_receipts"}
472
+ execution = {"execution_learning_receipts", "execution_learning_events"}
473
+ if tables in (expected | optional, expected | execution, expected | optional | execution):
447
474
  from superlocalmemory.storage.migrations import M041_external_evidence_receipts as m041
448
475
 
449
476
  with sqlite3.connect(path) as conn:
450
477
  # Erasure needs a valid table, not its optional performance indexes.
451
478
  # A damaged index must never strand profile-scoped evidence.
452
- if m041._table_is_valid(conn):
479
+ external_ok = (
480
+ "external_evidence_receipts" not in tables or m041._table_is_valid(conn)
481
+ )
482
+ execution_ok = (
483
+ not execution.intersection(tables)
484
+ or execution <= tables
485
+ )
486
+ if external_ok and execution_ok:
453
487
  return AgentExperienceStore(
454
488
  path, is_profile_active=lambda _: True
455
489
  ).erase_profile(profile_id, close_profile=close_profile)