superlocalmemory 4.0.1 → 4.0.2

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 (81) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +10 -11
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  36. package/pyproject.toml +3 -2
  37. package/src/superlocalmemory/__init__.py +1 -1
  38. package/src/superlocalmemory/cli/commands.py +60 -1
  39. package/src/superlocalmemory/cli/main.py +24 -1
  40. package/src/superlocalmemory/compliance/gdpr.py +104 -73
  41. package/src/superlocalmemory/contracts/__init__.py +1 -0
  42. package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
  43. package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
  44. package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
  45. package/src/superlocalmemory/contracts/v402.py +62 -0
  46. package/src/superlocalmemory/core/engine.py +10 -0
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -0
  48. package/src/superlocalmemory/core/recall_worker.py +12 -0
  49. package/src/superlocalmemory/core/worker_pool.py +12 -0
  50. package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
  51. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
  52. package/src/superlocalmemory/hooks/session_registry.py +136 -3
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
  54. package/src/superlocalmemory/integrations/__init__.py +1 -0
  55. package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
  56. package/src/superlocalmemory/learning/database.py +21 -14
  57. package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
  58. package/src/superlocalmemory/mcp/server.py +5 -0
  59. package/src/superlocalmemory/mcp/tools_brain.py +132 -0
  60. package/src/superlocalmemory/mcp/tools_core.py +25 -6
  61. package/src/superlocalmemory/mcp/tools_v3.py +16 -2
  62. package/src/superlocalmemory/retrieval/engine.py +43 -1
  63. package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
  64. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
  65. package/src/superlocalmemory/server/routes/brain.py +206 -1
  66. package/src/superlocalmemory/server/routes/helpers.py +53 -35
  67. package/src/superlocalmemory/server/routes/v3_api.py +118 -11
  68. package/src/superlocalmemory/server/unified_daemon.py +25 -0
  69. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  70. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  71. package/src/superlocalmemory/storage/agent_experience.py +490 -0
  72. package/src/superlocalmemory/storage/database.py +189 -34
  73. package/src/superlocalmemory/storage/migration_runner.py +8 -0
  74. package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
  75. package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
  76. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  77. package/src/superlocalmemory/storage/schema.py +4 -0
  78. package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
  79. package/src/superlocalmemory/ui/js/brain.js +57 -1
  80. package/src/superlocalmemory/ui/js/od-brain.js +114 -40
  81. package/src/superlocalmemory/ui/js/od-settings.js +8 -1
@@ -63,6 +63,10 @@ def _handle_recall(
63
63
  query: str, limit: int, session_id: str = "", fast: bool = False,
64
64
  include_global: bool | None = None, include_shared: bool | None = None,
65
65
  window: str | None = None,
66
+ as_of: str | None = None,
67
+ known_as_of: str | None = None,
68
+ valid_at: str | None = None,
69
+ include_unknown: bool = False,
66
70
  ) -> dict:
67
71
  engine = _get_engine()
68
72
  # v3.6.15 multi-scope: None flags let engine.recall resolve the configured
@@ -72,6 +76,10 @@ def _handle_recall(
72
76
  query, limit=limit, session_id=session_id or None, fast=bool(fast),
73
77
  include_global=include_global, include_shared=include_shared,
74
78
  window=window or None,
79
+ as_of=as_of or None,
80
+ known_as_of=known_as_of or None,
81
+ valid_at=valid_at or None,
82
+ include_unknown=include_unknown,
75
83
  )
76
84
 
77
85
  # Batch-fetch original memory text for all results. Retrieval already
@@ -322,6 +330,10 @@ def _worker_main() -> None:
322
330
  include_global=req.get("include_global"),
323
331
  include_shared=req.get("include_shared"),
324
332
  window=req.get("window"),
333
+ as_of=req.get("as_of"),
334
+ known_as_of=req.get("known_as_of"),
335
+ valid_at=req.get("valid_at"),
336
+ include_unknown=bool(req.get("include_unknown", False)),
325
337
  )
326
338
  _respond(result)
327
339
  elif cmd == "store":
@@ -71,6 +71,10 @@ class WorkerPool:
71
71
  include_global: bool | None = None,
72
72
  include_shared: bool | None = None,
73
73
  window: str | None = None,
74
+ as_of: str | None = None,
75
+ known_as_of: str | None = None,
76
+ valid_at: str | None = None,
77
+ include_unknown: bool = False,
74
78
  ) -> dict:
75
79
  """Run recall in worker subprocess. Returns result dict.
76
80
 
@@ -96,6 +100,14 @@ class WorkerPool:
96
100
  msg["include_shared"] = bool(include_shared)
97
101
  if window:
98
102
  msg["window"] = window
103
+ if as_of:
104
+ msg["as_of"] = as_of
105
+ if known_as_of:
106
+ msg["known_as_of"] = known_as_of
107
+ if valid_at:
108
+ msg["valid_at"] = valid_at
109
+ if include_unknown:
110
+ msg["include_unknown"] = True
99
111
  return self._send(msg)
100
112
 
101
113
  def store(self, content: str, metadata: dict | None = None) -> dict:
@@ -200,6 +200,22 @@ def _apply_codex_session(payload: dict) -> str:
200
200
  # Shared handlers use this neutral lifecycle identity despite its
201
201
  # historical environment-variable name. It is never sent to a host.
202
202
  os.environ["CLAUDE_SESSION_ID"] = session_id
203
+ # Presence is separate from memory correctness and is deliberately
204
+ # fail-open. It lets the portable Living Brain show that Codex is
205
+ # genuinely active, rather than pretending an installed hook is a
206
+ # connected client.
207
+ try:
208
+ from superlocalmemory.hooks.session_registry import (
209
+ mark_active,
210
+ resolve_active_profile,
211
+ )
212
+ mark_active(
213
+ session_id,
214
+ agent_type="codex",
215
+ profile_id=resolve_active_profile(),
216
+ )
217
+ except Exception:
218
+ pass
203
219
  return project_dir
204
220
 
205
221
 
@@ -35,14 +35,13 @@ from pathlib import Path
35
35
  from superlocalmemory.hooks._outcome_common import (
36
36
  emit_empty_json,
37
37
  log_perf,
38
- memory_db_path as _memory_db_path_fn,
39
- now_ms,
40
- open_memory_db,
41
38
  read_stdin_json,
42
39
  session_state_file,
43
40
  summarize_response,
44
41
  )
45
-
42
+ from superlocalmemory.hooks._outcome_common import (
43
+ memory_db_path as _memory_db_path_fn,
44
+ )
46
45
 
47
46
  _HOOK_NAME = "post_tool_outcome"
48
47
 
@@ -98,8 +97,15 @@ def _inner_main() -> str:
98
97
  # S9-DASH-10: keep registry fresh on every PostToolUse so the MCP
99
98
  # server can pick up the current session even mid-turn.
100
99
  try:
101
- from superlocalmemory.hooks.session_registry import mark_active
102
- mark_active(session_id, agent_type="claude")
100
+ from superlocalmemory.hooks.session_registry import (
101
+ mark_active,
102
+ resolve_active_profile,
103
+ )
104
+ mark_active(
105
+ session_id,
106
+ agent_type="claude",
107
+ profile_id=resolve_active_profile(),
108
+ )
103
109
  except Exception:
104
110
  pass
105
111
 
@@ -20,8 +20,9 @@ lost (reaper finalizes everything at neutral 0.5).
20
20
 
21
21
  **Fix (this module).** A simple file-based registry:
22
22
 
23
- * ``mark_active(session_id, agent_type)`` — called by hooks on every
24
- prompt/tool event. Writes ``(session_id, agent_type, ts_ns, pid)``
23
+ * ``mark_active(session_id, agent_type, profile_id)`` — called by hooks on
24
+ every prompt/tool event. Writes ``(session_id, agent_type, profile_id,
25
+ ts_ns, pid)``
25
26
  to ``~/.superlocalmemory/.active_sessions.json``.
26
27
  * ``most_recent_active(agent_type, within_seconds=60)`` — queries the
27
28
  registry for the most recently seen session of the named agent.
@@ -56,6 +57,20 @@ logger = logging.getLogger(__name__)
56
57
 
57
58
  _PRUNE_AFTER_SEC = 3600 # 1h — anything older is dead
58
59
 
60
+ # Keep the public host vocabulary small and stable. Callers can still use an
61
+ # unknown value internally, but the Living Brain must not turn arbitrary hook
62
+ # input into a new UI label or expose a host identifier verbatim.
63
+ _PUBLIC_CLIENT_KINDS = {
64
+ "claude": "claude_code",
65
+ "claude_code": "claude_code",
66
+ "codex": "codex",
67
+ "cursor": "cursor",
68
+ "antigravity": "antigravity",
69
+ "copilot": "copilot",
70
+ "cli": "cli",
71
+ "mcp": "mcp",
72
+ }
73
+
59
74
 
60
75
  def _registry_file() -> Path:
61
76
  from superlocalmemory.infra.data_root import state_path
@@ -63,6 +78,70 @@ def _registry_file() -> Path:
63
78
  return state_path(".active_sessions.json")
64
79
 
65
80
 
81
+ def _profiles_file() -> Path:
82
+ """Return the profile cache updated atomically by daemon switches."""
83
+ from superlocalmemory.infra.data_root import state_path
84
+
85
+ return state_path("profiles.json")
86
+
87
+
88
+ def resolve_active_profile() -> str | None:
89
+ """Read the canonical active profile without relying on host env wiring.
90
+
91
+ Hooks are separate host processes, so daemon-managed profile changes are
92
+ not reliably reflected in their environment. The profile runtime writes
93
+ this compatibility cache atomically on every successful switch; using it
94
+ keeps ephemeral presence scoped like the durable memory stores.
95
+ """
96
+ try:
97
+ payload = json.loads(_profiles_file().read_text(encoding="utf-8"))
98
+ if not isinstance(payload, dict):
99
+ return None
100
+ modern_pointer = payload.get("active_profile")
101
+ legacy_pointer = payload.get("active")
102
+ pointer = (
103
+ modern_pointer.strip()
104
+ if isinstance(modern_pointer, str) and modern_pointer.strip()
105
+ else legacy_pointer.strip()
106
+ if isinstance(legacy_pointer, str) and legacy_pointer.strip()
107
+ else None
108
+ )
109
+ if pointer is None:
110
+ return None
111
+ profiles = payload.get("profiles")
112
+ catalog_present = "profiles" in payload
113
+ entries = []
114
+ if isinstance(profiles, list):
115
+ entries = [(None, profile) for profile in profiles]
116
+ elif isinstance(profiles, dict):
117
+ entries = list(profiles.items())
118
+ canonical: list[tuple[str, str | None]] = []
119
+ for key, profile in entries:
120
+ if not isinstance(profile, dict):
121
+ continue
122
+ profile_id = profile.get("profile_id")
123
+ if not isinstance(profile_id, str) or not profile_id.strip():
124
+ profile_id = key if isinstance(key, str) and key.strip() else None
125
+ name = profile.get("name")
126
+ if isinstance(profile_id, str) and profile_id.strip():
127
+ canonical.append((profile_id.strip(), name if isinstance(name, str) else None))
128
+ id_matches = {profile_id for profile_id, _name in canonical if profile_id == pointer}
129
+ if len(id_matches) == 1:
130
+ return id_matches.pop()
131
+ name_matches = {profile_id for profile_id, name in canonical if name == pointer}
132
+ if len(name_matches) == 1:
133
+ return name_matches.pop()
134
+ # The current runtime writes an ``active_profile`` ID even when its
135
+ # catalog has not been materialized yet. With no catalog at all it is
136
+ # the only available authority; a partially present catalog instead
137
+ # fails closed to avoid treating an unmapped display name as an ID.
138
+ if not catalog_present and isinstance(modern_pointer, str) and modern_pointer.strip():
139
+ return modern_pointer.strip()
140
+ except (OSError, TypeError, ValueError):
141
+ pass
142
+ return None
143
+
144
+
66
145
  def _now_ns() -> int:
67
146
  return time.time_ns()
68
147
 
@@ -105,6 +184,7 @@ def _prune(data: dict) -> dict:
105
184
  def mark_active(
106
185
  session_id: str,
107
186
  agent_type: str = "claude",
187
+ profile_id: str | None = None,
108
188
  ) -> None:
109
189
  """Record ``session_id`` keyed by the CALLING process PID.
110
190
 
@@ -122,11 +202,14 @@ def mark_active(
122
202
  try:
123
203
  data = _load()
124
204
  key = str(os.getpid()) # the IDE / hook process PID
125
- data[key] = {
205
+ row = {
126
206
  "session_id": session_id,
127
207
  "agent_type": agent_type or "unknown",
128
208
  "ts_ns": _now_ns(),
129
209
  }
210
+ if isinstance(profile_id, str) and profile_id.strip():
211
+ row["profile_id"] = profile_id.strip()
212
+ data[key] = row
130
213
  data = _prune(data)
131
214
  _save(data)
132
215
  except Exception as exc: # pragma: no cover — defensive
@@ -189,6 +272,56 @@ def most_recent_active(
189
272
  return None
190
273
 
191
274
 
275
+ def active_client_summary(
276
+ profile_id: str | None = None,
277
+ within_seconds: int = 60,
278
+ ) -> list[dict[str, object]]:
279
+ """Return privacy-safe, recently active hosts for the Living Brain.
280
+
281
+ This is deliberately a *presence* signal, not durable product analytics:
282
+ registry entries expire within an hour and session identifiers never leave
283
+ the local registry. The dashboard therefore distinguishes these active
284
+ clients from configured adapters, which only prove installation.
285
+ """
286
+ # Compatibility-safe default for any out-of-tree caller that used the
287
+ # original no-argument helper: no profile means no visibility, never a
288
+ # silent cross-profile aggregate.
289
+ if not profile_id:
290
+ return []
291
+ try:
292
+ cutoff_ns = _now_ns() - (max(0, int(within_seconds)) * 1_000_000_000)
293
+ newest_by_kind: dict[str, int] = {}
294
+ for row in _load().values():
295
+ if not isinstance(row, dict):
296
+ continue
297
+ # Entries written before profile attribution are intentionally
298
+ # invisible here: guessing a profile would leak client metadata.
299
+ if str(row.get("profile_id", "")) != profile_id:
300
+ continue
301
+ try:
302
+ ts_ns = int(row.get("ts_ns", 0))
303
+ except (TypeError, ValueError):
304
+ continue
305
+ if ts_ns < cutoff_ns:
306
+ continue
307
+ raw_kind = str(row.get("agent_type", "")).strip().lower()
308
+ kind = _PUBLIC_CLIENT_KINDS.get(raw_kind, "other")
309
+ newest_by_kind[kind] = max(newest_by_kind.get(kind, 0), ts_ns)
310
+ now_ns = _now_ns()
311
+ return [
312
+ {
313
+ "kind": kind,
314
+ "active": True,
315
+ "last_seen_seconds_ago": max(0, int((now_ns - ts_ns) / 1_000_000_000)),
316
+ "source": "session_registry",
317
+ "is_real": True,
318
+ }
319
+ for kind, ts_ns in sorted(newest_by_kind.items())
320
+ ]
321
+ except Exception:
322
+ return []
323
+
324
+
192
325
  def _reset_for_testing() -> None:
193
326
  """TEST-ONLY: wipe registry."""
194
327
  try:
@@ -63,8 +63,15 @@ def main() -> int:
63
63
  # the MCP protocol doesn't thread the session_id through tool
64
64
  # arguments. Fail-soft — never raises on the hot path.
65
65
  try:
66
- from superlocalmemory.hooks.session_registry import mark_active
67
- mark_active(session_id, agent_type="claude")
66
+ from superlocalmemory.hooks.session_registry import (
67
+ mark_active,
68
+ resolve_active_profile,
69
+ )
70
+ mark_active(
71
+ session_id,
72
+ agent_type="claude",
73
+ profile_id=resolve_active_profile(),
74
+ )
68
75
  except Exception:
69
76
  pass
70
77
 
@@ -0,0 +1 @@
1
+ """Stable optional integration boundaries for external agent runtimes."""
@@ -0,0 +1,236 @@
1
+ """Read-only public-CLI adapter for bounded-loops v0.5.1 graph receipts.
2
+
3
+ SLM deliberately neither imports bounded-loops nor reimplements its event-log
4
+ grammar. The optional, installed ``bl`` executable is the versioned protocol
5
+ port: its public ``graph status`` command reconstructs and validates a graph
6
+ receipt before SLM accepts the resulting projection.
7
+
8
+ v0.5.1's local receipt is explicitly unverified. Consequently every result
9
+ from this adapter is display/observation evidence only; it cannot promote a
10
+ memory, alter learning, or assert execution authority.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import selectors
18
+ import shutil
19
+ import subprocess
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ class BoundedLoopsReceiptError(ValueError):
27
+ """Raised when a bounded-loops v0.5.1 receipt cannot be safely observed."""
28
+
29
+
30
+ _VERSION = "0.5.1"
31
+ _TIMEOUT_SECONDS = 15
32
+ _MAX_OUTPUT_BYTES = 2 * 1024 * 1024
33
+ _TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class VerifiedBoundedLoopsReceipt:
38
+ """A normalized, non-promotable projection of a local v0.5.1 receipt."""
39
+
40
+ organization_id: str
41
+ project_id: str
42
+ run_id: str
43
+ terminal_status: str
44
+ receipt_digest: str
45
+ artifact_digests: tuple[str, ...]
46
+ event_count: int
47
+ demonstration: bool
48
+ trust_level: str = "local_unverified"
49
+ eligible_for_learning: bool = False
50
+
51
+
52
+ def verify_v051_graph_receipt(
53
+ run_dir: str | Path,
54
+ *,
55
+ bl_executable: str | Path | None = None,
56
+ ) -> VerifiedBoundedLoopsReceipt:
57
+ """Read a v0.5.1 graph run validated by the bounded-loops public CLI.
58
+
59
+ ``run_dir`` must be an existing local directory, not a symlink. The
60
+ executable is discovered from the local environment or supplied as an
61
+ absolute path for an explicitly configured integration. Arguments are
62
+ always passed as an argv list, never through a shell.
63
+ """
64
+ directory = _safe_run_directory(run_dir)
65
+ executable = _resolve_v051_executable(bl_executable)
66
+ status = _command_json(executable, "graph", "status", "--run", str(directory), "--json")
67
+ return _normalize_projection(status)
68
+
69
+
70
+ def _safe_run_directory(run_dir: str | Path) -> Path:
71
+ supplied = Path(run_dir)
72
+ if not supplied.is_absolute():
73
+ raise BoundedLoopsReceiptError("run directory must be an absolute path")
74
+ try:
75
+ directory = supplied.resolve(strict=True)
76
+ except OSError as exc:
77
+ raise BoundedLoopsReceiptError("run directory is unavailable") from exc
78
+ if supplied.is_symlink() or not directory.is_dir():
79
+ raise BoundedLoopsReceiptError("run directory must be a real directory")
80
+ return directory
81
+
82
+
83
+ def _resolve_v051_executable(configured: str | Path | None) -> str:
84
+ if configured is None:
85
+ discovered = shutil.which("bl")
86
+ if discovered is None:
87
+ raise BoundedLoopsReceiptError("bounded-loops v0.5.1 is not installed")
88
+ executable = Path(discovered)
89
+ else:
90
+ executable = Path(configured)
91
+ if not executable.is_absolute():
92
+ raise BoundedLoopsReceiptError("configured bl executable must be an absolute path")
93
+ try:
94
+ resolved = executable.resolve(strict=True)
95
+ except OSError as exc:
96
+ raise BoundedLoopsReceiptError("configured bl executable is unavailable") from exc
97
+ if not resolved.is_file():
98
+ raise BoundedLoopsReceiptError("configured bl executable is not a file")
99
+ version = _run_command(str(resolved), "--version")
100
+ if version.strip() != f"bl {_VERSION}":
101
+ raise BoundedLoopsReceiptError("bounded-loops executable must be exactly v0.5.1")
102
+ return str(resolved)
103
+
104
+
105
+ def _command_json(executable: str, *arguments: str) -> Any:
106
+ output = _run_command(executable, *arguments)
107
+ try:
108
+ return json.loads(output)
109
+ except json.JSONDecodeError as exc:
110
+ raise BoundedLoopsReceiptError("bounded-loops returned invalid JSON") from exc
111
+
112
+
113
+ def _run_command(executable: str, *arguments: str) -> str:
114
+ try:
115
+ process = subprocess.Popen(
116
+ [executable, *arguments],
117
+ stdout=subprocess.PIPE,
118
+ stderr=subprocess.PIPE,
119
+ )
120
+ stdout = _read_bounded_output(process)
121
+ except (OSError, subprocess.TimeoutExpired) as exc:
122
+ raise BoundedLoopsReceiptError("bounded-loops command did not complete") from exc
123
+ if process.returncode != 0:
124
+ raise BoundedLoopsReceiptError("bounded-loops rejected the graph receipt")
125
+ try:
126
+ return stdout.decode("utf-8")
127
+ except UnicodeDecodeError as exc:
128
+ raise BoundedLoopsReceiptError("bounded-loops response is not UTF-8") from exc
129
+
130
+
131
+ def _read_bounded_output(process: subprocess.Popen[bytes]) -> bytes:
132
+ """Read both pipes incrementally and terminate output that exceeds the cap."""
133
+ if process.stdout is None or process.stderr is None:
134
+ raise BoundedLoopsReceiptError("bounded-loops command pipes are unavailable")
135
+ selector = selectors.DefaultSelector()
136
+ selector.register(process.stdout, selectors.EVENT_READ, data="stdout")
137
+ selector.register(process.stderr, selectors.EVENT_READ, data="stderr")
138
+ deadline = time.monotonic() + _TIMEOUT_SECONDS
139
+ stdout = bytearray()
140
+ total = 0
141
+ try:
142
+ while selector.get_map():
143
+ remaining = deadline - time.monotonic()
144
+ if remaining <= 0:
145
+ process.kill()
146
+ process.wait()
147
+ raise subprocess.TimeoutExpired(process.args, _TIMEOUT_SECONDS)
148
+ for key, _ in selector.select(remaining):
149
+ descriptor = (
150
+ key.fileobj if isinstance(key.fileobj, int) else key.fileobj.fileno()
151
+ )
152
+ chunk = os.read(descriptor, 64 * 1024)
153
+ if not chunk:
154
+ selector.unregister(key.fileobj)
155
+ continue
156
+ total += len(chunk)
157
+ if total > _MAX_OUTPUT_BYTES:
158
+ process.kill()
159
+ process.wait()
160
+ raise BoundedLoopsReceiptError(
161
+ "bounded-loops response exceeds the import size limit"
162
+ )
163
+ if key.data == "stdout":
164
+ stdout.extend(chunk)
165
+ process.wait(timeout=max(0.001, deadline - time.monotonic()))
166
+ finally:
167
+ selector.close()
168
+ if process.poll() is None:
169
+ process.kill()
170
+ process.wait()
171
+ return bytes(stdout)
172
+
173
+
174
+ def _normalize_projection(status: Any) -> VerifiedBoundedLoopsReceipt:
175
+ if not isinstance(status, dict):
176
+ raise BoundedLoopsReceiptError("bounded-loops response has an invalid shape")
177
+ required_strings = (
178
+ "organization_id", "project_id", "run_id", "run_state", "receipt_head_hash",
179
+ )
180
+ if any(not isinstance(status.get(name), str) or not status[name] for name in required_strings):
181
+ raise BoundedLoopsReceiptError("bounded-loops status lacks required receipt fields")
182
+ if status["run_state"] not in _TERMINAL_STATES:
183
+ raise BoundedLoopsReceiptError("bounded-loops receipt is not terminal")
184
+ if status.get("verified") is not False:
185
+ raise BoundedLoopsReceiptError("unexpected bounded-loops receipt verification state")
186
+ sequence = status.get("receipt_sequence")
187
+ if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence < 1:
188
+ raise BoundedLoopsReceiptError("bounded-loops receipt sequence is invalid")
189
+ if not _is_hash(status["receipt_head_hash"]):
190
+ raise BoundedLoopsReceiptError("bounded-loops receipt digest is invalid")
191
+ artifact_digests = _projection_artifact_digests(status.get("nodes"))
192
+ demonstration = status.get("demonstration")
193
+ if not isinstance(demonstration, bool):
194
+ raise BoundedLoopsReceiptError("bounded-loops demonstration marker is invalid")
195
+ return VerifiedBoundedLoopsReceipt(
196
+ organization_id=status["organization_id"],
197
+ project_id=status["project_id"],
198
+ run_id=status["run_id"],
199
+ terminal_status=status["run_state"],
200
+ receipt_digest=f"sha256:{status['receipt_head_hash']}",
201
+ artifact_digests=artifact_digests,
202
+ event_count=sequence,
203
+ demonstration=demonstration,
204
+ )
205
+
206
+
207
+ def _projection_artifact_digests(nodes: Any) -> tuple[str, ...]:
208
+ """Read only event-log-bound artifacts from the verified arena projection."""
209
+ if not isinstance(nodes, list):
210
+ raise BoundedLoopsReceiptError("bounded-loops status lacks node projections")
211
+ digests: set[str] = set()
212
+ for node in nodes:
213
+ if not isinstance(node, dict):
214
+ raise BoundedLoopsReceiptError("bounded-loops node projection is invalid")
215
+ raw_digests = node.get("artifact_digests")
216
+ if not isinstance(raw_digests, list) or not all(_is_digest(value) for value in raw_digests):
217
+ raise BoundedLoopsReceiptError("bounded-loops node artifact digests are invalid")
218
+ digests.update(raw_digests)
219
+ return tuple(sorted(digests))
220
+
221
+
222
+ def _is_digest(value: object) -> bool:
223
+ return (
224
+ isinstance(value, str)
225
+ and value.startswith("sha256:")
226
+ and len(value) == 71
227
+ and all(character in "0123456789abcdef" for character in value[7:])
228
+ )
229
+
230
+
231
+ def _is_hash(value: object) -> bool:
232
+ return (
233
+ isinstance(value, str)
234
+ and len(value) == 64
235
+ and all(character in "0123456789abcdef" for character in value)
236
+ )
@@ -182,8 +182,7 @@ class LearningDatabase:
182
182
  conn = self._connect()
183
183
  try:
184
184
  row = conn.execute(
185
- "SELECT COUNT(*) AS cnt FROM learning_signals "
186
- "WHERE profile_id = ?",
185
+ "SELECT COUNT(*) AS cnt FROM learning_signals WHERE profile_id = ?",
187
186
  (profile_id,),
188
187
  ).fetchone()
189
188
  return int(row["cnt"]) if row else 0
@@ -229,9 +228,7 @@ class LearningDatabase:
229
228
  finally:
230
229
  conn.close()
231
230
 
232
- def get_training_data(
233
- self, profile_id: str, limit: int = 5000
234
- ) -> list[dict[str, Any]]:
231
+ def get_training_data(self, profile_id: str, limit: int = 5000) -> list[dict[str, Any]]:
235
232
  """Retrieve labeled feature vectors for model training.
236
233
 
237
234
  Returns newest examples first. Each dict contains:
@@ -292,8 +289,7 @@ class LearningDatabase:
292
289
  conn = self._connect()
293
290
  try:
294
291
  row = conn.execute(
295
- "SELECT state_bytes FROM learning_model_state "
296
- "WHERE profile_id = ?",
292
+ "SELECT state_bytes FROM learning_model_state WHERE profile_id = ?",
297
293
  (profile_id,),
298
294
  ).fetchone()
299
295
  return bytes(row["state_bytes"]) if row else None
@@ -328,8 +324,7 @@ class LearningDatabase:
328
324
  if existing:
329
325
  new_value = float(existing["value"]) + value
330
326
  conn.execute(
331
- "UPDATE engagement_metrics SET value = ?, updated_at = ? "
332
- "WHERE id = ?",
327
+ "UPDATE engagement_metrics SET value = ?, updated_at = ? WHERE id = ?",
333
328
  (new_value, self._now(), existing["id"]),
334
329
  )
335
330
  else:
@@ -357,8 +352,7 @@ class LearningDatabase:
357
352
  conn = self._connect()
358
353
  try:
359
354
  rows = conn.execute(
360
- "SELECT metric_type, value FROM engagement_metrics "
361
- "WHERE profile_id = ?",
355
+ "SELECT metric_type, value FROM engagement_metrics WHERE profile_id = ?",
362
356
  (profile_id,),
363
357
  ).fetchall()
364
358
  return {row["metric_type"]: float(row["value"]) for row in rows}
@@ -378,8 +372,7 @@ class LearningDatabase:
378
372
  conn = self._connect()
379
373
  try:
380
374
  row = conn.execute(
381
- "SELECT COUNT(*) AS cnt FROM learning_signals "
382
- "WHERE profile_id = ?",
375
+ "SELECT COUNT(*) AS cnt FROM learning_signals WHERE profile_id = ?",
383
376
  (profile_id,),
384
377
  ).fetchone()
385
378
  return int(row["cnt"]) if row else 0
@@ -580,7 +573,8 @@ class LearningDatabase:
580
573
  except sqlite3.Error as exc:
581
574
  logger.warning(
582
575
  "fetch_training_examples failed (m006=%s): %s",
583
- m006_applied, exc,
576
+ m006_applied,
577
+ exc,
584
578
  )
585
579
  return []
586
580
  out: list[dict] = []
@@ -602,6 +596,10 @@ class LearningDatabase:
602
596
  profile_id: If provided, only erase data for that profile.
603
597
  If None, erase ALL learning data.
604
598
  """
599
+ if profile_id:
600
+ from superlocalmemory.storage.agent_experience import purge_profile_receipts
601
+
602
+ purge_profile_receipts(self._db_path, profile_id, close_profile=False)
605
603
  with self._lock:
606
604
  conn = self._connect()
607
605
  try:
@@ -611,6 +609,15 @@ class LearningDatabase:
611
609
  "learning_model_state",
612
610
  "engagement_metrics",
613
611
  ]
612
+ receipt_tables = {
613
+ row[0]
614
+ for row in conn.execute(
615
+ "SELECT name FROM sqlite_master WHERE type='table' "
616
+ "AND name IN ('agent_experiences', 'cognitive_turn_receipts')"
617
+ )
618
+ }
619
+ if profile_id is None:
620
+ tables.extend(sorted(receipt_tables))
614
621
  for table in tables:
615
622
  if profile_id:
616
623
  conn.execute(
@@ -84,6 +84,9 @@ class DaemonPoolProxy:
84
84
  include_shared: bool | None = None,
85
85
  window: str | None = None,
86
86
  as_of: str | None = None,
87
+ known_as_of: str | None = None,
88
+ valid_at: str | None = None,
89
+ include_unknown: bool = False,
87
90
  ) -> dict[str, Any]:
88
91
  if self._unavailable:
89
92
  return self._unavailable_response()
@@ -108,6 +111,12 @@ class DaemonPoolProxy:
108
111
  _params["window"] = window
109
112
  if as_of:
110
113
  _params["as_of"] = as_of
114
+ if known_as_of:
115
+ _params["known_as_of"] = known_as_of
116
+ if valid_at:
117
+ _params["valid_at"] = valid_at
118
+ if include_unknown:
119
+ _params["include_unknown"] = "true"
111
120
  params = urllib.parse.urlencode(_params)
112
121
  try:
113
122
  from superlocalmemory.cli.daemon import daemon_request