superlocalmemory 3.7.8 → 3.8.0
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/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +69 -0
- package/README.md +199 -29
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/agents/slm-governance-advisor.md +80 -0
- package/plugin-src/agents/slm-loop-runner.md +71 -0
- package/plugin-src/agents/slm-memory-advisor.md +10 -5
- package/plugin-src/agents/slm-optimize-advisor.md +9 -3
- package/plugin-src/commands/slm-loop.md +31 -0
- package/plugin-src/hooks/hooks.json +79 -0
- package/plugin-src/manifest.json +7 -2
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/rules/CLAUDE.md.fragment +8 -8
- package/plugin-src/scripts/slm-launch +46 -7
- package/plugin-src/settings.json +9 -0
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +94 -10
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/loop_cmd.py +192 -0
- package/src/superlocalmemory/cli/main.py +39 -4
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +17 -2
- package/src/superlocalmemory/core/embeddings.py +12 -1
- package/src/superlocalmemory/core/engine.py +17 -1
- package/src/superlocalmemory/core/engine_ingestion.py +29 -0
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +2 -0
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +35 -11
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +114 -1
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +243 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +21 -49
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +8 -1
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +168 -26
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +34 -10
- package/src/superlocalmemory/server/routes/behavioral.py +13 -12
- package/src/superlocalmemory/server/routes/brain.py +21 -5
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +436 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +9 -4
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +135 -17
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +14 -14
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +95 -15
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +13 -6
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/v3_api.py +183 -69
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +384 -56
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +53 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +87 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +306 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +47 -31
- package/src/superlocalmemory/ui/js/dashboard.js +314 -63
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +780 -0
- package/src/superlocalmemory/ui/js/od-brain.js +779 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +593 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +887 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
- package/src/superlocalmemory/ui/js/od-shell.js +593 -0
- package/src/superlocalmemory/ui/js/od-skills.js +573 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +2 -2
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -25,6 +25,7 @@ import json
|
|
|
25
25
|
import logging
|
|
26
26
|
import os
|
|
27
27
|
import socket
|
|
28
|
+
import sys
|
|
28
29
|
import threading
|
|
29
30
|
import time
|
|
30
31
|
from pathlib import Path
|
|
@@ -35,6 +36,12 @@ logger = logging.getLogger(__name__)
|
|
|
35
36
|
|
|
36
37
|
_DEFAULT_SOCK_NAME = "hook_daemon.sock"
|
|
37
38
|
|
|
39
|
+
# AF_UNIX is absent on Windows builds < 10.0.17063 and on Python < 3.9. When it
|
|
40
|
+
# is unavailable the hook daemon does not start and callers fall back to the
|
|
41
|
+
# subprocess recall path. Detect it explicitly (once, with a log) instead of
|
|
42
|
+
# relying on an AttributeError being swallowed by a broad except.
|
|
43
|
+
_AF_UNIX = getattr(socket, "AF_UNIX", None)
|
|
44
|
+
|
|
38
45
|
|
|
39
46
|
def _default_sock_path() -> Path:
|
|
40
47
|
return state_path(_DEFAULT_SOCK_NAME)
|
|
@@ -77,7 +84,13 @@ class HookDaemon:
|
|
|
77
84
|
from superlocalmemory.core.recall_queue import RecallQueue
|
|
78
85
|
self._queue = RecallQueue(self._queue_db_path)
|
|
79
86
|
|
|
80
|
-
|
|
87
|
+
if _AF_UNIX is None:
|
|
88
|
+
logger.info(
|
|
89
|
+
"HookDaemon: AF_UNIX unavailable on %s; hook recall uses the "
|
|
90
|
+
"subprocess fallback", sys.platform,
|
|
91
|
+
)
|
|
92
|
+
raise RuntimeError("AF_UNIX unavailable on this platform")
|
|
93
|
+
self._server_sock = socket.socket(_AF_UNIX, socket.SOCK_STREAM)
|
|
81
94
|
self._server_sock.bind(str(self._sock_path))
|
|
82
95
|
self._server_sock.listen(8)
|
|
83
96
|
self._server_sock.settimeout(1.0)
|
|
@@ -226,9 +239,11 @@ def try_socket_recall(
|
|
|
226
239
|
path = sock_path or _default_sock_path()
|
|
227
240
|
if not path.exists():
|
|
228
241
|
return None
|
|
242
|
+
if _AF_UNIX is None:
|
|
243
|
+
return None
|
|
229
244
|
|
|
230
245
|
try:
|
|
231
|
-
client = socket.socket(
|
|
246
|
+
client = socket.socket(_AF_UNIX, socket.SOCK_STREAM)
|
|
232
247
|
client.settimeout(timeout)
|
|
233
248
|
client.connect(str(path))
|
|
234
249
|
|
|
@@ -259,10 +274,12 @@ def ensure_hook_daemon(
|
|
|
259
274
|
) -> HookDaemon | None:
|
|
260
275
|
"""Start hook daemon if not already running. Returns daemon or None."""
|
|
261
276
|
path = sock_path or _default_sock_path()
|
|
277
|
+
if _AF_UNIX is None:
|
|
278
|
+
return None
|
|
262
279
|
|
|
263
280
|
if path.exists():
|
|
264
281
|
try:
|
|
265
|
-
test = socket.socket(
|
|
282
|
+
test = socket.socket(_AF_UNIX, socket.SOCK_STREAM)
|
|
266
283
|
test.settimeout(1.0)
|
|
267
284
|
test.connect(str(path))
|
|
268
285
|
test.close()
|
|
@@ -93,6 +93,58 @@ def memory_protocol_markdown() -> str:
|
|
|
93
93
|
"learned>\", \"tags\": \"<comma-separated kebab-case keywords>\"}`.\n"
|
|
94
94
|
"- A \"substantial task\" is anything you would write a commit "
|
|
95
95
|
"message or handoff note about — not every tool call.\n"
|
|
96
|
+
"\n"
|
|
97
|
+
+ optimize_protocol_markdown()
|
|
98
|
+
+ "\n"
|
|
99
|
+
+ loop_protocol_markdown()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def optimize_protocol_markdown() -> str:
|
|
104
|
+
"""Return the agent-facing token-optimization protocol block (cache/compress).
|
|
105
|
+
|
|
106
|
+
Proxy-free: these MCP tools let the agent losslessly shrink large tool output
|
|
107
|
+
and reuse repeated reads through the same surface, so an agent on any
|
|
108
|
+
front-end (not just Claude Code) can save tokens without a proxy. Fail-open —
|
|
109
|
+
a non-ok result means "use the original and continue".
|
|
110
|
+
"""
|
|
111
|
+
return (
|
|
112
|
+
"## Runtime token-optimization protocol (fail-open)\n"
|
|
113
|
+
"SLM can losslessly compress large tool output and cache repeated reads "
|
|
114
|
+
"through the same MCP surface — no proxy required. These calls only save "
|
|
115
|
+
"tokens; if one returns `ok: false`, use the original and continue.\n\n"
|
|
116
|
+
"- **Large tool output (>2000 chars)** → `hub__call_tool` with "
|
|
117
|
+
"`tool=\"slm__slm_compress\"` and `arguments={\"content\": \"<text>\", "
|
|
118
|
+
"\"mode\": \"auto\", \"reversible\": true}`; keep the returned `ccr_id` "
|
|
119
|
+
"and call `tool=\"slm__slm_retrieve\"` if you later need the full "
|
|
120
|
+
"original.\n"
|
|
121
|
+
"- **Repeated reads/searches** → `hub__call_tool` with "
|
|
122
|
+
"`tool=\"slm__slm_cache_get\"` and `arguments={\"key\": \"file:<path>\"}` "
|
|
123
|
+
"first; on a miss, store the result with `tool=\"slm__slm_cache_set\"` "
|
|
124
|
+
"(ttl ~1800).\n"
|
|
125
|
+
"- **Never compress or cache**: code you will edit, JSON you will parse, "
|
|
126
|
+
"secrets, ccr_ids, or anything under ~500 chars.\n"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def loop_protocol_markdown() -> str:
|
|
131
|
+
"""Return the agent-facing bounded-loop protocol block.
|
|
132
|
+
|
|
133
|
+
Bounded loops make an agent stop when an INDEPENDENT gate passes — not when
|
|
134
|
+
the agent claims it is done. Persisting each lap to SLM memory makes a run
|
|
135
|
+
auditable and resumable. This block is appended to the shared memory
|
|
136
|
+
protocol so any connected front-end (Claude Code, Codex, Antigravity,
|
|
137
|
+
Cursor, and other IDEs) learns the feature exists and how to reach it.
|
|
138
|
+
"""
|
|
139
|
+
return (
|
|
140
|
+
"## Runtime bounded-loop protocol\n"
|
|
141
|
+
"For a task with a checkable gate (tests, schema, lint, reconciliation), "
|
|
142
|
+
"run a *bounded loop*: iterate until an INDEPENDENT gate passes — never "
|
|
143
|
+
"on the agent's own claim, which is advisory only. Try `slm loop demo`; "
|
|
144
|
+
"inspect with `slm loop history` / `slm loop show <run_id>` (each lap "
|
|
145
|
+
"persists as SLM memory, tag `loop:<name>`). Statuses: DONE / HALT / "
|
|
146
|
+
"PAUSE / KILLED / ERROR — report exactly, never as success unless DONE. "
|
|
147
|
+
"Full guide: the slm-loop skill.\n"
|
|
96
148
|
)
|
|
97
149
|
|
|
98
150
|
|
|
@@ -101,4 +153,6 @@ __all__ = (
|
|
|
101
153
|
"SLM_MARKER_END",
|
|
102
154
|
"strip_slm_block",
|
|
103
155
|
"memory_protocol_markdown",
|
|
156
|
+
"optimize_protocol_markdown",
|
|
157
|
+
"loop_protocol_markdown",
|
|
104
158
|
)
|
|
@@ -139,7 +139,7 @@ IDE_MATRIX: dict[str, IDEDescriptor] = {
|
|
|
139
139
|
mcp_path_project=".mcp.json",
|
|
140
140
|
server_key="mcpServers",
|
|
141
141
|
fmt="json",
|
|
142
|
-
agents_md_path=
|
|
142
|
+
agents_md_path=".junie/AGENTS.md", # Junie guidelines file (GA) — carries memory + optimize protocol
|
|
143
143
|
server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
|
|
144
144
|
caveats="path per product [CN-ONLINE]",
|
|
145
145
|
),
|
|
@@ -347,6 +347,54 @@ def connect_ide(
|
|
|
347
347
|
return result
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
def connect_many(
|
|
351
|
+
ide_ids: list[str],
|
|
352
|
+
*,
|
|
353
|
+
home: Path | None = None,
|
|
354
|
+
project: Path | None = None,
|
|
355
|
+
here: bool = False,
|
|
356
|
+
profile: str | None = None,
|
|
357
|
+
agents_md_source: Callable[[], str] | None = None,
|
|
358
|
+
) -> list[dict[str, Any]]:
|
|
359
|
+
"""Wire SLM into multiple IDE configs via non-destructive merge.
|
|
360
|
+
|
|
361
|
+
Iterates over ``ide_ids`` and calls :func:`connect_ide` for each entry.
|
|
362
|
+
Each IDE is processed independently — a failure on one IDE does NOT abort
|
|
363
|
+
the remaining targets.
|
|
364
|
+
|
|
365
|
+
The underlying :func:`connect_ide` is MERGE-NOT-CLOBBER:
|
|
366
|
+
- Only the ``superlocalmemory`` server key is touched.
|
|
367
|
+
- All other MCP servers + top-level keys are preserved byte-for-byte.
|
|
368
|
+
- Writes are atomic (.tmp + os.replace).
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
ide_ids: IDE ids to wire (from :data:`IDE_MATRIX`). Pass an empty
|
|
372
|
+
list to no-op. Unknown ids produce error entries in the output.
|
|
373
|
+
home: Override ``$HOME`` (test hook).
|
|
374
|
+
project: Project root for ``here=True`` installs.
|
|
375
|
+
here: When True, write to project-relative path instead of global.
|
|
376
|
+
profile: Inject ``SLM_MCP_PROFILE`` env-var into every server block.
|
|
377
|
+
agents_md_source: Callable returning AGENTS.md content to append.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
List of per-IDE result dicts, one per input id. Each dict has the
|
|
381
|
+
same shape as :func:`connect_ide`'s return value::
|
|
382
|
+
|
|
383
|
+
{ide, mcp_config, mcp_path, agents_md, servers_preserved, error}
|
|
384
|
+
"""
|
|
385
|
+
return [
|
|
386
|
+
connect_ide(
|
|
387
|
+
ide_id,
|
|
388
|
+
home=home,
|
|
389
|
+
project=project,
|
|
390
|
+
here=here,
|
|
391
|
+
profile=profile,
|
|
392
|
+
agents_md_source=agents_md_source,
|
|
393
|
+
)
|
|
394
|
+
for ide_id in ide_ids
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
|
|
350
398
|
# ---------------------------------------------------------------------------
|
|
351
399
|
# Internal helpers
|
|
352
400
|
# ---------------------------------------------------------------------------
|
|
@@ -504,3 +552,68 @@ def _handle_agents_md(
|
|
|
504
552
|
_tmp.write_text(existing + section, encoding="utf-8")
|
|
505
553
|
os.replace(_tmp, agents_path)
|
|
506
554
|
return "wrote"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
# __main__ — `python -m superlocalmemory.hooks.portable_kit [ids...]`
|
|
559
|
+
#
|
|
560
|
+
# Used by the npm installer to execute non-destructive IDE connects when
|
|
561
|
+
# Python is available at postinstall time. If Python / the package is not
|
|
562
|
+
# importable the installer records a pending_ide_connections.json instead.
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
if __name__ == "__main__": # pragma: no cover
|
|
566
|
+
import argparse as _argparse
|
|
567
|
+
|
|
568
|
+
_parser = _argparse.ArgumentParser(
|
|
569
|
+
description="SLM IDE connector — non-destructive merge-not-clobber."
|
|
570
|
+
)
|
|
571
|
+
_parser.add_argument(
|
|
572
|
+
"ide_ids",
|
|
573
|
+
nargs="*",
|
|
574
|
+
metavar="IDE",
|
|
575
|
+
help=(
|
|
576
|
+
"IDE ids to connect. Pass 'all' to connect every supported IDE "
|
|
577
|
+
"(excluding claude-code which uses the WP-06 plugin)."
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
_parser.add_argument("--home", help="Override home directory (test hook).")
|
|
581
|
+
_parser.add_argument("--profile", help="SLM_MCP_PROFILE to inject.")
|
|
582
|
+
_parser.add_argument(
|
|
583
|
+
"--list", action="store_true",
|
|
584
|
+
help="Print supported IDE ids with display names and exit.",
|
|
585
|
+
)
|
|
586
|
+
_args = _parser.parse_args()
|
|
587
|
+
|
|
588
|
+
if _args.list:
|
|
589
|
+
for _id, _desc in IDE_MATRIX.items():
|
|
590
|
+
_flag = "[OUT]" if not _desc.fmt else ""
|
|
591
|
+
print(f" {_id:22s} {_desc.display} {_flag}".rstrip())
|
|
592
|
+
sys.exit(0)
|
|
593
|
+
|
|
594
|
+
_home = Path(_args.home) if _args.home else None
|
|
595
|
+
|
|
596
|
+
# Resolve targets: "all" → every MCP-capable IDE (fmt != ""), else explicit list.
|
|
597
|
+
if "all" in _args.ide_ids:
|
|
598
|
+
_targets = [k for k, d in IDE_MATRIX.items() if d.fmt]
|
|
599
|
+
else:
|
|
600
|
+
_targets = [t for t in _args.ide_ids if t]
|
|
601
|
+
|
|
602
|
+
if not _targets:
|
|
603
|
+
print("No IDE ids given. Pass ide ids or 'all'. Use --list to see options.")
|
|
604
|
+
sys.exit(0)
|
|
605
|
+
|
|
606
|
+
_results = connect_many(_targets, home=_home, profile=_args.profile)
|
|
607
|
+
_ok = 0
|
|
608
|
+
_fail = 0
|
|
609
|
+
for _r in _results:
|
|
610
|
+
_err = _r.get("error")
|
|
611
|
+
_status = _r.get("mcp_config", "error")
|
|
612
|
+
if _err:
|
|
613
|
+
print(f" ERROR {_r['ide']}: {_err}", file=sys.stderr)
|
|
614
|
+
_fail += 1
|
|
615
|
+
else:
|
|
616
|
+
print(f" {_status.upper():9s} {_r['ide']} → {_r.get('mcp_path', '')}")
|
|
617
|
+
_ok += 1
|
|
618
|
+
print(f"\n{_ok} connected, {_fail} errors.")
|
|
619
|
+
sys.exit(0 if _fail == 0 else 1)
|
|
@@ -266,7 +266,18 @@ class BackupManager:
|
|
|
266
266
|
|
|
267
267
|
A safety snapshot of the current state is taken first.
|
|
268
268
|
"""
|
|
269
|
-
|
|
269
|
+
# Containment: filename must be a bare .db name inside backup_dir — no
|
|
270
|
+
# path separators or traversal. Prevents restoring (and thus copying
|
|
271
|
+
# over memory.db) an arbitrary file the daemon user can read.
|
|
272
|
+
if (not filename or "/" in filename or "\\" in filename
|
|
273
|
+
or ".." in filename or not filename.endswith(".db")):
|
|
274
|
+
logger.error("Restore rejected: invalid backup filename: %r", filename)
|
|
275
|
+
return False
|
|
276
|
+
backup_dir = self.backup_dir.resolve()
|
|
277
|
+
backup_path = (self.backup_dir / filename).resolve()
|
|
278
|
+
if backup_path.parent != backup_dir:
|
|
279
|
+
logger.error("Restore rejected: path escapes backup dir: %r", filename)
|
|
280
|
+
return False
|
|
270
281
|
if not backup_path.exists():
|
|
271
282
|
logger.error("Backup not found: %s", filename)
|
|
272
283
|
return False
|
|
@@ -14,8 +14,11 @@ import getpass
|
|
|
14
14
|
import hashlib
|
|
15
15
|
import hmac
|
|
16
16
|
import json
|
|
17
|
+
import logging
|
|
17
18
|
import os
|
|
18
19
|
import secrets
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
19
22
|
import time
|
|
20
23
|
import uuid
|
|
21
24
|
from dataclasses import asdict, dataclass
|
|
@@ -30,6 +33,8 @@ DAEMON_SERVICE = "superlocalmemory-daemon"
|
|
|
30
33
|
_NAMESPACE_DOMAIN = b"superlocalmemory-daemon-namespace-v1\0"
|
|
31
34
|
_CAPABILITY_DOMAIN = b"superlocalmemory-daemon-capability-v1\0"
|
|
32
35
|
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
33
38
|
|
|
34
39
|
def _canonical_path(value: str | Path) -> Path:
|
|
35
40
|
expanded = Path(value).expanduser().resolve(strict=False)
|
|
@@ -144,6 +149,40 @@ def descriptor_path(data_root: str | Path | None = None) -> Path:
|
|
|
144
149
|
return root / "daemon.json"
|
|
145
150
|
|
|
146
151
|
|
|
152
|
+
def _restrict_to_owner(path: Path) -> None:
|
|
153
|
+
"""Restrict a sensitive file to the current user on every platform.
|
|
154
|
+
|
|
155
|
+
``daemon.json`` holds the capability token that authorizes write requests.
|
|
156
|
+
POSIX gets a 0600 chmod. On Windows chmod is a no-op, so use icacls to strip
|
|
157
|
+
inherited ACEs and grant the current user only — otherwise the token can be
|
|
158
|
+
read by other local accounts (privilege escalation on shared machines).
|
|
159
|
+
Files under %USERPROFILE% usually inherit user-only ACLs already; this is
|
|
160
|
+
defense-in-depth. Fail-soft — a hardening failure warns, never crashes.
|
|
161
|
+
"""
|
|
162
|
+
if sys.platform == "win32":
|
|
163
|
+
try:
|
|
164
|
+
user = getpass.getuser()
|
|
165
|
+
subprocess.run(
|
|
166
|
+
["icacls", str(path), "/inheritance:r"],
|
|
167
|
+
check=False, capture_output=True,
|
|
168
|
+
)
|
|
169
|
+
if user:
|
|
170
|
+
subprocess.run(
|
|
171
|
+
["icacls", str(path), "/grant:r", f"{user}:F"],
|
|
172
|
+
check=False, capture_output=True,
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc: # noqa: BLE001
|
|
175
|
+
logger.warning(
|
|
176
|
+
"could not restrict ACL on %s (%s); the capability token may be "
|
|
177
|
+
"readable by other local users", path, exc,
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
try:
|
|
181
|
+
os.chmod(path, 0o600)
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
logger.warning("could not chmod %s to 0600: %s", path, exc)
|
|
184
|
+
|
|
185
|
+
|
|
147
186
|
def write_descriptor(
|
|
148
187
|
descriptor: DaemonDescriptor,
|
|
149
188
|
*,
|
|
@@ -162,10 +201,7 @@ def write_descriptor(
|
|
|
162
201
|
stream.flush()
|
|
163
202
|
os.fsync(stream.fileno())
|
|
164
203
|
os.replace(temporary, destination)
|
|
165
|
-
|
|
166
|
-
os.chmod(destination, 0o600)
|
|
167
|
-
except OSError:
|
|
168
|
-
pass
|
|
204
|
+
_restrict_to_owner(destination)
|
|
169
205
|
finally:
|
|
170
206
|
temporary.unlink(missing_ok=True)
|
|
171
207
|
return destination
|
|
@@ -11,6 +11,7 @@ explicit process contract and always wins over persisted configuration.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
|
+
import logging
|
|
14
15
|
import os
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
@@ -24,6 +25,8 @@ _DURABLE_IDENTITY_NAMES = frozenset(
|
|
|
24
25
|
},
|
|
25
26
|
)
|
|
26
27
|
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
27
30
|
|
|
28
31
|
class DataRootConflictError(RuntimeError):
|
|
29
32
|
"""Raised when two state-bearing roots make startup ambiguous."""
|
|
@@ -122,17 +125,53 @@ def assert_no_durable_root_conflict(
|
|
|
122
125
|
*,
|
|
123
126
|
home: str | Path | None = None,
|
|
124
127
|
) -> None:
|
|
125
|
-
"""Refuse ambiguous startup when
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
"""Refuse *ambiguous* startup when two state roots make the live namespace unclear.
|
|
129
|
+
|
|
130
|
+
The root actually selected for this process is always inspected: an
|
|
131
|
+
unreadable selected root fails closed. Beyond that, a conflict is only raised
|
|
132
|
+
when the selection was *implicit* — resolved from the legacy
|
|
133
|
+
``config.json:base_dir`` relocation hint — and a separately-populated default
|
|
134
|
+
root leaves it genuinely ambiguous which namespace is live.
|
|
135
|
+
|
|
136
|
+
An explicit environment selection (``SLM_DATA_DIR`` / ``SL_MEMORY_PATH`` /
|
|
137
|
+
``SLM_HOME``) is an unambiguous operator contract: a separately-populated
|
|
138
|
+
default root is then a deliberate multi-root / per-team / second-instance
|
|
139
|
+
layout, not an ambiguity, so startup proceeds. If the explicitly chosen root
|
|
140
|
+
is empty while the old default still holds data, that likely-mistyped path is
|
|
141
|
+
surfaced as a warning rather than a hard block.
|
|
142
|
+
|
|
143
|
+
This check never writes, copies, or deletes data.
|
|
129
144
|
"""
|
|
130
145
|
home_path = _canonical_path(home if home is not None else Path.home())
|
|
131
146
|
default_root = _canonical_path(home_path / ".superlocalmemory")
|
|
132
147
|
selected_root = canonical_data_root(home=home_path)
|
|
133
148
|
if selected_root == default_root:
|
|
134
149
|
return
|
|
150
|
+
|
|
151
|
+
# The root about to be used must be inspectable regardless of how it was
|
|
152
|
+
# chosen; an unreadable selected root fails closed inside _durable_markers.
|
|
135
153
|
selected_markers = _durable_markers(selected_root)
|
|
154
|
+
|
|
155
|
+
if environment_data_root() is not None:
|
|
156
|
+
# Explicit selection wins; a populated default root is a deliberate
|
|
157
|
+
# multi-root layout, not an ambiguity. Only warn on the "empty new root
|
|
158
|
+
# while the old default still holds data" case so a wrong SLM_DATA_DIR
|
|
159
|
+
# stays visible. Inspection of the unused default never blocks startup.
|
|
160
|
+
if not selected_markers:
|
|
161
|
+
try:
|
|
162
|
+
default_has_data = bool(_durable_markers(default_root))
|
|
163
|
+
except DataRootConflictError:
|
|
164
|
+
default_has_data = False
|
|
165
|
+
if default_has_data:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"SLM_DATA_DIR selects an empty state root (%s) while the "
|
|
168
|
+
"default root (%s) still holds data; starting with the empty "
|
|
169
|
+
"root as explicitly requested.",
|
|
170
|
+
selected_root,
|
|
171
|
+
default_root,
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
|
|
136
175
|
default_markers = _durable_markers(default_root)
|
|
137
176
|
if not selected_markers or not default_markers:
|
|
138
177
|
return
|
|
@@ -102,13 +102,20 @@ class EventBus:
|
|
|
102
102
|
logger.info("EventBus initialized: db=%s", self.db_path)
|
|
103
103
|
|
|
104
104
|
def _init_schema(self) -> None:
|
|
105
|
-
"""Create the memory_events table if it does not exist.
|
|
105
|
+
"""Create the memory_events table if it does not exist.
|
|
106
|
+
|
|
107
|
+
Self-migrates a pre-isolation DB (memory_events without profile_id) so a
|
|
108
|
+
dashboard viewing profile A never sees profile B's events. This table is
|
|
109
|
+
store-owned (created here, not by the migration runner), so the store
|
|
110
|
+
owns its upgrade. Existing rows backfill to the 'default' profile.
|
|
111
|
+
"""
|
|
106
112
|
conn = sqlite3.connect(str(self.db_path))
|
|
107
113
|
try:
|
|
108
114
|
cur = conn.cursor()
|
|
109
115
|
cur.execute("""
|
|
110
116
|
CREATE TABLE IF NOT EXISTS memory_events (
|
|
111
117
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
profile_id TEXT NOT NULL DEFAULT 'default',
|
|
112
119
|
event_type TEXT NOT NULL,
|
|
113
120
|
memory_id INTEGER,
|
|
114
121
|
source_agent TEXT DEFAULT 'user',
|
|
@@ -119,13 +126,42 @@ class EventBus:
|
|
|
119
126
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
120
127
|
)
|
|
121
128
|
""")
|
|
129
|
+
existing = {r[1] for r in cur.execute(
|
|
130
|
+
"PRAGMA table_info(memory_events)").fetchall()}
|
|
131
|
+
if "profile_id" not in existing:
|
|
132
|
+
cur.execute(
|
|
133
|
+
"ALTER TABLE memory_events "
|
|
134
|
+
"ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'default'"
|
|
135
|
+
)
|
|
122
136
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON memory_events(event_type)")
|
|
123
137
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON memory_events(created_at)")
|
|
124
138
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_tier ON memory_events(tier)")
|
|
139
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_profile ON memory_events(profile_id, id)")
|
|
125
140
|
conn.commit()
|
|
126
141
|
finally:
|
|
127
142
|
conn.close()
|
|
128
143
|
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _resolve_profile(profile_id: Optional[str]) -> str:
|
|
146
|
+
"""Resolve the active profile for an event when not passed explicitly.
|
|
147
|
+
|
|
148
|
+
Uses the request-runtime helper (ContextVar for HTTP, else the
|
|
149
|
+
profiles.json active_profile cache that every switch keeps in sync) so
|
|
150
|
+
MCP-in-daemon and CLI emits are attributed correctly too. Lazy import
|
|
151
|
+
keeps the infra layer free of a hard server dependency; any failure
|
|
152
|
+
falls back to 'default'.
|
|
153
|
+
"""
|
|
154
|
+
if profile_id:
|
|
155
|
+
return profile_id
|
|
156
|
+
try:
|
|
157
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
158
|
+
resolved = get_active_profile()
|
|
159
|
+
if resolved:
|
|
160
|
+
return resolved
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
return "default"
|
|
164
|
+
|
|
129
165
|
def emit(
|
|
130
166
|
self,
|
|
131
167
|
event_type: str,
|
|
@@ -134,8 +170,14 @@ class EventBus:
|
|
|
134
170
|
source_agent: str = "user",
|
|
135
171
|
source_protocol: str = "internal",
|
|
136
172
|
importance: int = 5,
|
|
173
|
+
profile_id: Optional[str] = None,
|
|
137
174
|
) -> Optional[int]:
|
|
138
|
-
"""Emit an event to all subscribers and persist to database.
|
|
175
|
+
"""Emit an event to all subscribers and persist to database.
|
|
176
|
+
|
|
177
|
+
``profile_id`` scopes the event to a memory profile. When omitted it is
|
|
178
|
+
resolved from the active profile so a dashboard viewing one profile
|
|
179
|
+
never sees another profile's real-time or historical events.
|
|
180
|
+
"""
|
|
139
181
|
if event_type not in VALID_EVENT_TYPES:
|
|
140
182
|
raise ValueError(
|
|
141
183
|
f"Invalid event type: {event_type}. "
|
|
@@ -143,6 +185,7 @@ class EventBus:
|
|
|
143
185
|
)
|
|
144
186
|
|
|
145
187
|
importance = max(1, min(10, importance))
|
|
188
|
+
profile_id = self._resolve_profile(profile_id)
|
|
146
189
|
|
|
147
190
|
now = datetime.now(timezone.utc).isoformat()
|
|
148
191
|
with self._counter_lock:
|
|
@@ -151,6 +194,7 @@ class EventBus:
|
|
|
151
194
|
|
|
152
195
|
event: Dict[str, Any] = {
|
|
153
196
|
"seq": seq,
|
|
197
|
+
"profile_id": profile_id,
|
|
154
198
|
"event_type": event_type,
|
|
155
199
|
"memory_id": memory_id,
|
|
156
200
|
"source_agent": source_agent,
|
|
@@ -177,18 +221,24 @@ class EventBus:
|
|
|
177
221
|
event_type, event_id, memory_id,
|
|
178
222
|
)
|
|
179
223
|
|
|
180
|
-
# Auto-prune heuristic
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
224
|
+
# Auto-prune heuristic. Decide + reset the counter atomically under the
|
|
225
|
+
# lock so concurrent emit() calls cannot both cross the threshold and
|
|
226
|
+
# double-run the prune; run the prune itself OUTSIDE the lock.
|
|
227
|
+
should_prune = False
|
|
228
|
+
with self._counter_lock:
|
|
229
|
+
self._write_count += 1
|
|
230
|
+
if (
|
|
231
|
+
self._write_count >= 100
|
|
232
|
+
or (datetime.now() - self._last_prune).total_seconds() > 86400
|
|
233
|
+
):
|
|
234
|
+
should_prune = True
|
|
235
|
+
self._write_count = 0
|
|
236
|
+
self._last_prune = datetime.now()
|
|
237
|
+
if should_prune:
|
|
186
238
|
try:
|
|
187
239
|
self.prune_events()
|
|
188
240
|
except Exception:
|
|
189
241
|
pass
|
|
190
|
-
self._write_count = 0
|
|
191
|
-
self._last_prune = datetime.now()
|
|
192
242
|
|
|
193
243
|
return event_id
|
|
194
244
|
|
|
@@ -202,10 +252,12 @@ class EventBus:
|
|
|
202
252
|
try:
|
|
203
253
|
cur = conn.cursor()
|
|
204
254
|
cur.execute(
|
|
205
|
-
"INSERT INTO memory_events (event_type, memory_id,
|
|
206
|
-
" source_protocol, payload, importance, tier,
|
|
207
|
-
"
|
|
208
|
-
(
|
|
255
|
+
"INSERT INTO memory_events (profile_id, event_type, memory_id,"
|
|
256
|
+
" source_agent, source_protocol, payload, importance, tier,"
|
|
257
|
+
" created_at)"
|
|
258
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, 'hot', ?)",
|
|
259
|
+
(event.get("profile_id", "default"), event["event_type"],
|
|
260
|
+
event.get("memory_id"),
|
|
209
261
|
event["source_agent"], event["source_protocol"],
|
|
210
262
|
json.dumps(event["payload"]), event["importance"],
|
|
211
263
|
event["timestamp"]),
|
|
@@ -253,9 +305,16 @@ class EventBus:
|
|
|
253
305
|
since_id: Optional[int] = None,
|
|
254
306
|
limit: int = 50,
|
|
255
307
|
event_type: Optional[str] = None,
|
|
308
|
+
profile_id: Optional[str] = None,
|
|
256
309
|
) -> List[dict]:
|
|
257
|
-
"""Get recent events from the database.
|
|
310
|
+
"""Get recent events from the database, scoped to a profile.
|
|
311
|
+
|
|
312
|
+
``profile_id`` defaults to the active profile so callers never leak
|
|
313
|
+
another profile's events. Pass ``profile_id="*"`` to bypass scoping
|
|
314
|
+
(internal maintenance/pruning only — never a client-facing path).
|
|
315
|
+
"""
|
|
258
316
|
limit = min(limit, 200)
|
|
317
|
+
scope = profile_id if profile_id == "*" else self._resolve_profile(profile_id)
|
|
259
318
|
|
|
260
319
|
try:
|
|
261
320
|
conn = sqlite3.connect(str(self.db_path))
|
|
@@ -263,10 +322,15 @@ class EventBus:
|
|
|
263
322
|
cur = conn.cursor()
|
|
264
323
|
|
|
265
324
|
query = ("SELECT id, event_type, memory_id, source_agent,"
|
|
266
|
-
" source_protocol, payload, importance, tier, created_at"
|
|
325
|
+
" source_protocol, payload, importance, tier, created_at,"
|
|
326
|
+
" profile_id"
|
|
267
327
|
" FROM memory_events WHERE 1=1")
|
|
268
328
|
params: List[Any] = []
|
|
269
329
|
|
|
330
|
+
if scope != "*":
|
|
331
|
+
query += " AND profile_id = ?"
|
|
332
|
+
params.append(scope)
|
|
333
|
+
|
|
270
334
|
if since_id is not None:
|
|
271
335
|
query += " AND id > ?"
|
|
272
336
|
params.append(since_id)
|
|
@@ -293,7 +357,7 @@ class EventBus:
|
|
|
293
357
|
"id": row[0], "event_type": row[1], "memory_id": row[2],
|
|
294
358
|
"source_agent": row[3], "source_protocol": row[4],
|
|
295
359
|
"payload": parsed, "importance": row[6],
|
|
296
|
-
"tier": row[7], "timestamp": row[8],
|
|
360
|
+
"tier": row[7], "timestamp": row[8], "profile_id": row[9],
|
|
297
361
|
})
|
|
298
362
|
return events
|
|
299
363
|
|
|
@@ -306,19 +370,32 @@ class EventBus:
|
|
|
306
370
|
with self._buffer_lock:
|
|
307
371
|
return [e for e in self._buffer if e.get("seq", 0) > since_seq]
|
|
308
372
|
|
|
309
|
-
def get_event_stats(self) -> dict:
|
|
310
|
-
"""Get event system statistics.
|
|
373
|
+
def get_event_stats(self, profile_id: Optional[str] = None) -> dict:
|
|
374
|
+
"""Get event system statistics, scoped to a profile.
|
|
375
|
+
|
|
376
|
+
``profile_id`` defaults to the active profile so dashboard event
|
|
377
|
+
counts never blend across profiles.
|
|
378
|
+
"""
|
|
379
|
+
scope = self._resolve_profile(profile_id)
|
|
311
380
|
try:
|
|
312
381
|
conn = sqlite3.connect(str(self.db_path))
|
|
313
382
|
try:
|
|
314
383
|
cur = conn.cursor()
|
|
315
384
|
|
|
316
|
-
total = cur.execute(
|
|
317
|
-
|
|
385
|
+
total = cur.execute(
|
|
386
|
+
"SELECT COUNT(*) FROM memory_events WHERE profile_id = ?",
|
|
387
|
+
(scope,)).fetchone()[0]
|
|
388
|
+
cur.execute(
|
|
389
|
+
"SELECT event_type, COUNT(*) FROM memory_events "
|
|
390
|
+
"WHERE profile_id = ? GROUP BY event_type", (scope,))
|
|
318
391
|
by_type = dict(cur.fetchall())
|
|
319
|
-
cur.execute(
|
|
392
|
+
cur.execute(
|
|
393
|
+
"SELECT tier, COUNT(*) FROM memory_events "
|
|
394
|
+
"WHERE profile_id = ? GROUP BY tier", (scope,))
|
|
320
395
|
by_tier = dict(cur.fetchall())
|
|
321
|
-
cur.execute(
|
|
396
|
+
cur.execute(
|
|
397
|
+
"SELECT COUNT(*) FROM memory_events WHERE profile_id = ? "
|
|
398
|
+
"AND created_at >= datetime('now', '-24 hours')", (scope,))
|
|
322
399
|
last_24h = cur.fetchone()[0]
|
|
323
400
|
finally:
|
|
324
401
|
conn.close()
|
|
@@ -343,7 +420,13 @@ class EventBus:
|
|
|
343
420
|
warm_hours: int = DEFAULT_WARM_HOURS,
|
|
344
421
|
cold_hours: int = DEFAULT_COLD_HOURS,
|
|
345
422
|
) -> dict:
|
|
346
|
-
"""Apply tiered retention policy to persisted events.
|
|
423
|
+
"""Apply tiered retention policy to persisted events.
|
|
424
|
+
|
|
425
|
+
INTENTIONALLY GLOBAL (all profiles): this is age/tier-based housekeeping
|
|
426
|
+
of the shared event log, not a per-tenant read or retention-policy
|
|
427
|
+
surface. Tenant isolation of event CONTENT is enforced on read via the
|
|
428
|
+
profile_id filter; this sweep only demotes/expires old rows by age.
|
|
429
|
+
"""
|
|
347
430
|
try:
|
|
348
431
|
conn = sqlite3.connect(str(self.db_path))
|
|
349
432
|
try:
|