superlocalmemory 4.0.0 → 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.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -11
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +5 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +60 -1
- package/src/superlocalmemory/cli/main.py +24 -1
- package/src/superlocalmemory/compliance/gdpr.py +104 -73
- package/src/superlocalmemory/contracts/__init__.py +1 -0
- package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
- package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
- package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
- package/src/superlocalmemory/contracts/v402.py +62 -0
- package/src/superlocalmemory/core/engine.py +10 -0
- package/src/superlocalmemory/core/recall_pipeline.py +6 -0
- package/src/superlocalmemory/core/recall_worker.py +12 -0
- package/src/superlocalmemory/core/worker_pool.py +12 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
- package/src/superlocalmemory/hooks/session_registry.py +136 -3
- package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
- package/src/superlocalmemory/integrations/__init__.py +1 -0
- package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
- package/src/superlocalmemory/learning/database.py +21 -14
- package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
- package/src/superlocalmemory/mcp/server.py +5 -0
- package/src/superlocalmemory/mcp/tools_brain.py +132 -0
- package/src/superlocalmemory/mcp/tools_core.py +25 -6
- package/src/superlocalmemory/mcp/tools_v3.py +16 -2
- package/src/superlocalmemory/retrieval/engine.py +43 -1
- package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
- package/src/superlocalmemory/server/routes/brain.py +206 -1
- package/src/superlocalmemory/server/routes/helpers.py +53 -35
- package/src/superlocalmemory/server/routes/v3_api.py +118 -11
- package/src/superlocalmemory/server/ui.py +7 -2
- package/src/superlocalmemory/server/unified_daemon.py +37 -4
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/agent_experience.py +490 -0
- package/src/superlocalmemory/storage/database.py +189 -34
- package/src/superlocalmemory/storage/migration_runner.py +8 -0
- package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
- package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/schema.py +4 -0
- package/src/superlocalmemory/ui/index.html +2 -2
- package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
- package/src/superlocalmemory/ui/js/brain.js +57 -1
- package/src/superlocalmemory/ui/js/od-brain.js +114 -40
- 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
|
|
102
|
-
|
|
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
|
|
24
|
-
prompt/tool event. Writes ``(session_id, agent_type,
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
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,
|
|
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
|