superlocalmemory 3.8.3 → 3.8.6
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 +76 -0
- package/README.md +3 -2
- 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-graph/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-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -182,7 +182,9 @@ def is_rate_limit_exempt(client_host: str) -> bool:
|
|
|
182
182
|
same rapid reads, so it is exempt too — otherwise normal dashboard polling
|
|
183
183
|
trips the limiter (issue #40 Issue 3).
|
|
184
184
|
"""
|
|
185
|
-
|
|
185
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
186
|
+
|
|
187
|
+
if _is_loopback_host(client_host):
|
|
186
188
|
return True
|
|
187
189
|
return is_lan_client_allowed(client_host)
|
|
188
190
|
|
|
@@ -349,7 +349,24 @@ class ScaleEngineManager:
|
|
|
349
349
|
self._release_lifecycle_lock(lock_path)
|
|
350
350
|
|
|
351
351
|
def _promote(self, stage_id: str) -> dict[str, Any]:
|
|
352
|
-
"""Promote while the caller owns the lifecycle lock.
|
|
352
|
+
"""Promote while the caller owns the lifecycle lock.
|
|
353
|
+
|
|
354
|
+
Concurrency fix (v3.8.4): the original code held a SQLite
|
|
355
|
+
BEGIN IMMEDIATE lock across multiple fsync/rename filesystem operations
|
|
356
|
+
(mkdir_durable, replace_durable, write_promotion_journal) which could
|
|
357
|
+
starve other memory.db writers for 30+ seconds.
|
|
358
|
+
|
|
359
|
+
Fix: the fingerprint check runs inside a short memory_read() block
|
|
360
|
+
(consistent WAL snapshot, no write lock). All filesystem operations
|
|
361
|
+
happen AFTER that block with no SQLite lock held. The lifecycle lock
|
|
362
|
+
(file-based, acquired by the caller) already serialises concurrent
|
|
363
|
+
promote/rollback calls.
|
|
364
|
+
|
|
365
|
+
Lock-ordering invariant preserved: get_write_lock is OUTERMOST; no
|
|
366
|
+
SQLite write lock is held here at all (only a read snapshot).
|
|
367
|
+
"""
|
|
368
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
369
|
+
|
|
353
370
|
stage_dir, manifest = self._load_stage(stage_id)
|
|
354
371
|
self._validate_manifest(manifest, state="verified")
|
|
355
372
|
staged = (stage_dir / "cozo", stage_dir / "lance")
|
|
@@ -357,17 +374,30 @@ class ScaleEngineManager:
|
|
|
357
374
|
raise ScaleEngineError("verified stage is incomplete; prepare a new stage")
|
|
358
375
|
backup_dir = self.backup_root / f"{stage_id}-{uuid.uuid4().hex[:6]}"
|
|
359
376
|
active = self.active_paths
|
|
360
|
-
|
|
377
|
+
|
|
378
|
+
# ── Phase 1: fingerprint check (short read snapshot, no write lock) ──
|
|
379
|
+
# memory_read() gives a consistent WAL snapshot and closes the
|
|
380
|
+
# connection immediately on exit — no lock held after this block.
|
|
381
|
+
try:
|
|
382
|
+
with memory_read(self.db_path) as gate:
|
|
383
|
+
canonical = self._canonical_counts(gate)
|
|
384
|
+
if manifest["source_fingerprint"] != self._projection_fingerprint(
|
|
385
|
+
gate, canonical
|
|
386
|
+
):
|
|
387
|
+
raise ScaleEngineError(
|
|
388
|
+
"canonical SQLite changed after verification; prepare a new stage"
|
|
389
|
+
)
|
|
390
|
+
except ScaleEngineError:
|
|
391
|
+
raise
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
raise ScaleEngineError(
|
|
394
|
+
f"fingerprint check failed: {exc}"
|
|
395
|
+
) from exc
|
|
396
|
+
|
|
397
|
+
# ── Phase 2: filesystem operations — NO SQLite lock held ─────────────
|
|
398
|
+
# The lifecycle lock (file-based, held by the caller) serialises
|
|
399
|
+
# concurrent promotions; no SQLite fence is required for the swaps.
|
|
361
400
|
try:
|
|
362
|
-
# The stage was built from a point-in-time SQLite snapshot. Hold a
|
|
363
|
-
# short writer fence for the final fingerprint check and directory
|
|
364
|
-
# swap so no successful promotion can trail a canonical write.
|
|
365
|
-
gate.execute("BEGIN IMMEDIATE")
|
|
366
|
-
canonical = self._canonical_counts(gate)
|
|
367
|
-
if manifest["source_fingerprint"] != self._projection_fingerprint(gate, canonical):
|
|
368
|
-
raise ScaleEngineError(
|
|
369
|
-
"canonical SQLite changed after verification; prepare a new stage"
|
|
370
|
-
)
|
|
371
401
|
self._mkdir_durable(self.backup_root)
|
|
372
402
|
journal = {
|
|
373
403
|
"schema_version": self.SCHEMA_VERSION,
|
|
@@ -409,13 +439,8 @@ class ScaleEngineManager:
|
|
|
409
439
|
journal["state"] = "committed"
|
|
410
440
|
self._write_promotion_journal(journal)
|
|
411
441
|
self.promotion_journal_path.unlink(missing_ok=True)
|
|
412
|
-
gate.rollback()
|
|
413
442
|
return manifest
|
|
414
443
|
except Exception as exc:
|
|
415
|
-
try:
|
|
416
|
-
gate.rollback()
|
|
417
|
-
except sqlite3.Error:
|
|
418
|
-
pass
|
|
419
444
|
try:
|
|
420
445
|
recovery = self._recover_interrupted_promotion()
|
|
421
446
|
except Exception as recovery_error:
|
|
@@ -426,8 +451,6 @@ class ScaleEngineManager:
|
|
|
426
451
|
_, recovered_manifest = self._load_stage(stage_id)
|
|
427
452
|
return recovered_manifest
|
|
428
453
|
raise ScaleEngineError(f"promotion rolled back: {exc}") from exc
|
|
429
|
-
finally:
|
|
430
|
-
gate.close()
|
|
431
454
|
|
|
432
455
|
def rollback(self, backup_id: str) -> dict[str, Any]:
|
|
433
456
|
"""Restore an explicitly named pre-promotion backup."""
|
|
@@ -166,10 +166,24 @@ def enrich_fact(
|
|
|
166
166
|
from superlocalmemory.encoding.emotional import emotional_importance_boost, tag_emotion
|
|
167
167
|
from superlocalmemory.encoding.signal_inference import infer_signal
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
# v3.8.4 D: if the fact already carries a sync-embedded vector (warm-guard
|
|
170
|
+
# path in store_fast), reuse it — avoids a redundant embed call in the
|
|
171
|
+
# materializer and keeps the vector consistent with the one indexed in the
|
|
172
|
+
# vector store at write time.
|
|
173
|
+
if fact.embedding is not None:
|
|
174
|
+
embedding = fact.embedding
|
|
175
|
+
fisher_mean = fact.fisher_mean
|
|
176
|
+
fisher_variance = fact.fisher_variance
|
|
177
|
+
# fisher_params are computed in the warm-guard path too, but guard
|
|
178
|
+
# against the edge case where they weren't (e.g. compute_fisher_params
|
|
179
|
+
# raised after embed succeeded).
|
|
180
|
+
if (fisher_mean is None or fisher_variance is None) and embedder and embedding:
|
|
181
|
+
fisher_mean, fisher_variance = embedder.compute_fisher_params(embedding)
|
|
182
|
+
else:
|
|
183
|
+
embedding = embedder.embed(fact.content) if embedder else None
|
|
184
|
+
fisher_mean, fisher_variance = (None, None)
|
|
185
|
+
if embedder and embedding:
|
|
186
|
+
fisher_mean, fisher_variance = embedder.compute_fisher_params(embedding)
|
|
173
187
|
|
|
174
188
|
canonical = {}
|
|
175
189
|
if entity_resolver and fact.entities:
|
|
@@ -563,18 +563,25 @@ class EntityResolver:
|
|
|
563
563
|
self._db.store_alias(alias, profile_id)
|
|
564
564
|
|
|
565
565
|
def _touch_last_seen(self, entity_id: str, profile_id: str = "default") -> None:
|
|
566
|
-
"""
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
566
|
+
"""Record a last_seen touch (DEFERRED, non-blocking).
|
|
567
|
+
|
|
568
|
+
last_seen is dashboard-only bookkeeping (entities/graph "last seen"
|
|
569
|
+
columns); it never feeds recall ranking. Writing it INLINE made recall
|
|
570
|
+
a WRITER that waited on the global write lock — the root of the
|
|
571
|
+
"recall is 8 s" regression. We now record it in memory and flush from a
|
|
572
|
+
single background thread in coalesced batches, so recall stays
|
|
573
|
+
READ-ONLY on its hot path. The dashboard stays correct within the flush
|
|
574
|
+
interval (~2 s). The profile_id guard (L-01) is preserved in the
|
|
575
|
+
deferred UPDATE (see storage/deferred_writes.py).
|
|
572
576
|
"""
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
577
|
+
try:
|
|
578
|
+
from superlocalmemory.storage.deferred_writes import (
|
|
579
|
+
get_deferred_last_seen,
|
|
580
|
+
)
|
|
581
|
+
get_deferred_last_seen(self._db).touch(entity_id, profile_id, _now())
|
|
582
|
+
except Exception:
|
|
583
|
+
# Bookkeeping must never break entity resolution.
|
|
584
|
+
pass
|
|
578
585
|
|
|
579
586
|
# -- Internal: LLM disambiguation (Mode B/C) ---------------------------
|
|
580
587
|
|
|
@@ -75,12 +75,12 @@ class _CozoResult:
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
class _CozoClientAdapter:
|
|
78
|
-
"""Bridge PyCozo
|
|
78
|
+
"""Bridge legacy PyCozo responses and the pinned 0.7.6 client surface.
|
|
79
79
|
|
|
80
|
-
PyCozo 0.
|
|
81
|
-
|
|
82
|
-
than ``put
|
|
83
|
-
|
|
80
|
+
PyCozo 0.7.6 publishes embedded wheels for macOS, Linux x86_64/aarch64,
|
|
81
|
+
and Windows. Older installations can still return dictionaries and expose
|
|
82
|
+
``import_relations`` rather than ``put``. SLM only needs relation upserts
|
|
83
|
+
and row results, so normalize both forms here.
|
|
84
84
|
"""
|
|
85
85
|
|
|
86
86
|
def __init__(self, client: Any) -> None:
|
|
@@ -40,8 +40,15 @@ from typing import IO, Optional
|
|
|
40
40
|
# Budget constants
|
|
41
41
|
# ---------------------------------------------------------------------------
|
|
42
42
|
|
|
43
|
-
#: Hot-path SQLite busy timeout (ms).
|
|
44
|
-
|
|
43
|
+
#: Hot-path SQLite busy timeout (ms).
|
|
44
|
+
#
|
|
45
|
+
# Raised to 10 000 ms to match the daemon's SLM_DB_BUSY_TIMEOUT_MS default.
|
|
46
|
+
# Hooks run as a SEPARATE OS process — the daemon's threading.RLock write-lock
|
|
47
|
+
# cannot help cross-process — so PRAGMA busy_timeout is the ONLY lever that
|
|
48
|
+
# prevents SQLITE_BUSY when the daemon or CLI holds the WAL write lock.
|
|
49
|
+
# 10 s is long enough to outlast a typical daemon write cycle while staying
|
|
50
|
+
# safely below Claude Code's hook-kill timeout.
|
|
51
|
+
BUSY_TIMEOUT_MS: int = 10_000
|
|
45
52
|
|
|
46
53
|
#: Cap on tool_response bytes scanned — bounds substring work to O(100 KB).
|
|
47
54
|
SCAN_BYTES_CAP: int = 100_000
|
|
@@ -32,6 +32,8 @@ from datetime import datetime, timezone
|
|
|
32
32
|
from pathlib import Path
|
|
33
33
|
from typing import Protocol, runtime_checkable
|
|
34
34
|
|
|
35
|
+
from superlocalmemory.storage.write_lock import get_write_lock
|
|
36
|
+
|
|
35
37
|
|
|
36
38
|
# ---------------------------------------------------------------------------
|
|
37
39
|
# Constants
|
|
@@ -86,25 +88,31 @@ def _ensure_memory_log(db_path: Path) -> None:
|
|
|
86
88
|
"""Lazily create ``cross_platform_sync_log`` if a test-mode memory.db is
|
|
87
89
|
fresh. Production code goes through the migration runner, but tests can
|
|
88
90
|
hand us an empty DB; this keeps adapters usable without pre-running
|
|
89
|
-
migrations.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
91
|
+
migrations.
|
|
92
|
+
|
|
93
|
+
Acquires the process-level write lock for *db_path* before opening
|
|
94
|
+
a sqlite3 connection so that this DDL write is serialised with all
|
|
95
|
+
other in-process writers (DatabaseManager, VectorStore, etc.).
|
|
96
|
+
"""
|
|
97
|
+
with get_write_lock(db_path):
|
|
98
|
+
conn = sqlite3.connect(str(db_path))
|
|
99
|
+
try:
|
|
100
|
+
conn.executescript(
|
|
101
|
+
"CREATE TABLE IF NOT EXISTS cross_platform_sync_log ("
|
|
102
|
+
" adapter_name TEXT NOT NULL,"
|
|
103
|
+
" profile_id TEXT NOT NULL,"
|
|
104
|
+
" target_path_sha256 TEXT NOT NULL,"
|
|
105
|
+
" target_basename TEXT NOT NULL,"
|
|
106
|
+
" last_sync_at TEXT NOT NULL,"
|
|
107
|
+
" bytes_written INTEGER NOT NULL,"
|
|
108
|
+
" content_sha256 TEXT NOT NULL,"
|
|
109
|
+
" success INTEGER NOT NULL,"
|
|
110
|
+
" error_msg TEXT,"
|
|
111
|
+
" PRIMARY KEY (adapter_name, target_path_sha256));"
|
|
112
|
+
)
|
|
113
|
+
conn.commit()
|
|
114
|
+
finally:
|
|
115
|
+
conn.close()
|
|
108
116
|
|
|
109
117
|
|
|
110
118
|
def sync_log_last_content_sha256(
|
|
@@ -156,31 +164,37 @@ def sync_log_record(
|
|
|
156
164
|
)
|
|
157
165
|
if os.sep in target_path_sha256 or "/" in target_path_sha256:
|
|
158
166
|
raise ValueError("target_path_sha256 must be a hash, not a raw path")
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
167
|
+
# Acquire the process-level write lock BEFORE opening the sqlite3 connection.
|
|
168
|
+
# This ensures the INSERT/UPDATE below is serialised with all other in-process
|
|
169
|
+
# writers (DatabaseManager, VectorStore, consolidation) via the single shared
|
|
170
|
+
# RLock for memory.db, eliminating SQLITE_BUSY races at the WAL layer.
|
|
171
|
+
# _ensure_memory_log also acquires the same RLock (re-entrant — safe).
|
|
172
|
+
with get_write_lock(db_path):
|
|
173
|
+
_ensure_memory_log(db_path)
|
|
174
|
+
conn = sqlite3.connect(str(db_path))
|
|
175
|
+
try:
|
|
176
|
+
conn.execute(
|
|
177
|
+
"INSERT INTO cross_platform_sync_log ("
|
|
178
|
+
"adapter_name, profile_id, target_path_sha256, target_basename, "
|
|
179
|
+
"last_sync_at, bytes_written, content_sha256, success, error_msg"
|
|
180
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
181
|
+
"ON CONFLICT(adapter_name, target_path_sha256) DO UPDATE SET "
|
|
182
|
+
" profile_id = excluded.profile_id,"
|
|
183
|
+
" target_basename = excluded.target_basename,"
|
|
184
|
+
" last_sync_at = excluded.last_sync_at,"
|
|
185
|
+
" bytes_written = excluded.bytes_written,"
|
|
186
|
+
" content_sha256 = excluded.content_sha256,"
|
|
187
|
+
" success = excluded.success,"
|
|
188
|
+
" error_msg = excluded.error_msg",
|
|
189
|
+
(
|
|
190
|
+
adapter_name, profile_id, target_path_sha256, target_basename,
|
|
191
|
+
_now_iso(), bytes_written, content_sha256,
|
|
192
|
+
1 if success else 0, error_msg,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
conn.commit()
|
|
196
|
+
finally:
|
|
197
|
+
conn.close()
|
|
184
198
|
|
|
185
199
|
|
|
186
200
|
# ---------------------------------------------------------------------------
|
|
@@ -188,8 +188,18 @@ class IDEConnector: # pragma: no cover — legacy shim, covered by test_ide_con
|
|
|
188
188
|
path.write_text(content + "\n" + section)
|
|
189
189
|
return True
|
|
190
190
|
|
|
191
|
-
def _merge_json(
|
|
192
|
-
|
|
191
|
+
def _merge_json(
|
|
192
|
+
self,
|
|
193
|
+
path: Path,
|
|
194
|
+
transport: str = "stdio",
|
|
195
|
+
daemon_port: int = 8765,
|
|
196
|
+
) -> bool:
|
|
197
|
+
"""Merge SLM config into a JSON config file.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
transport: "stdio" (default), "http", or "http-mcp-remote".
|
|
201
|
+
daemon_port: Daemon port for http transports (default 8765).
|
|
202
|
+
"""
|
|
193
203
|
data: dict[str, Any] = {}
|
|
194
204
|
if path.exists():
|
|
195
205
|
try:
|
|
@@ -201,12 +211,20 @@ class IDEConnector: # pragma: no cover — legacy shim, covered by test_ide_con
|
|
|
201
211
|
if "mcpServers" not in data:
|
|
202
212
|
data["mcpServers"] = {}
|
|
203
213
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
"
|
|
207
|
-
|
|
208
|
-
"
|
|
209
|
-
|
|
214
|
+
base_url = f"http://127.0.0.1:{daemon_port}/mcp/"
|
|
215
|
+
if transport == "http":
|
|
216
|
+
block: dict[str, Any] = {"type": "http", "url": base_url}
|
|
217
|
+
elif transport == "http-mcp-remote":
|
|
218
|
+
block = {"type": "stdio", "command": "mcp-remote", "args": [base_url]}
|
|
219
|
+
else:
|
|
220
|
+
block = {
|
|
221
|
+
"type": "stdio",
|
|
222
|
+
"command": "slm",
|
|
223
|
+
"args": ["mcp"],
|
|
224
|
+
"enabled": True,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
data["mcpServers"]["superlocalmemory"] = block
|
|
210
228
|
|
|
211
229
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
212
230
|
path.write_text(json.dumps(data, indent=2))
|
|
@@ -32,6 +32,12 @@ logger = logging.getLogger(__name__)
|
|
|
32
32
|
SLM_MARKER_START = "<!-- SLM-START -->"
|
|
33
33
|
SLM_MARKER_END = "<!-- SLM-END -->"
|
|
34
34
|
|
|
35
|
+
# Valid transport values for connect_ide() and slm connect --transport.
|
|
36
|
+
# "stdio" — default, zero regression; uses desc.server_block as-is.
|
|
37
|
+
# "http" — native MCP Streamable-HTTP; requires daemon at :daemon_port.
|
|
38
|
+
# "http-mcp-remote" — mcp-remote stdio bridge for stdio-only clients.
|
|
39
|
+
VALID_TRANSPORTS: frozenset[str] = frozenset({"stdio", "http", "http-mcp-remote"})
|
|
40
|
+
|
|
35
41
|
CLAUDE_CODE_PLUGIN_POINTER = (
|
|
36
42
|
"slm connect claude-code: Claude Code is configured via the SLM plugin (WP-06).\n"
|
|
37
43
|
"Run: slm plugin install OR see plugin-src/ for manual installation.\n"
|
|
@@ -241,16 +247,26 @@ def connect_ide(
|
|
|
241
247
|
profile: str | None = None,
|
|
242
248
|
agents_md_source: Callable[[], str] | None = None,
|
|
243
249
|
dry_run: bool = False,
|
|
250
|
+
transport: str = "stdio",
|
|
251
|
+
daemon_port: int = 8765,
|
|
244
252
|
) -> dict[str, Any]:
|
|
245
253
|
"""Wire SLM into the target IDE config via merge-not-clobber.
|
|
246
254
|
|
|
255
|
+
Args:
|
|
256
|
+
transport: MCP transport to write into the config.
|
|
257
|
+
"stdio" (default, zero regression) — uses the IDE's canonical server_block.
|
|
258
|
+
"http" — native MCP Streamable-HTTP block; requires SLM daemon at daemon_port.
|
|
259
|
+
"http-mcp-remote" — stdio bridge via mcp-remote for stdio-only clients.
|
|
260
|
+
daemon_port: Daemon listen port for http / http-mcp-remote (default 8765).
|
|
261
|
+
|
|
247
262
|
Returns a result dict:
|
|
248
|
-
{ide, mcp_config: wrote|merged|unchanged|would_write|skipped|error,
|
|
263
|
+
{ide, transport, mcp_config: wrote|merged|unchanged|would_write|skipped|error,
|
|
249
264
|
mcp_path, agents_md: wrote|skipped(...)|unchanged|error,
|
|
250
265
|
servers_preserved: int, error: str|None}
|
|
251
266
|
"""
|
|
252
267
|
result: dict[str, Any] = {
|
|
253
268
|
"ide": ide_id,
|
|
269
|
+
"transport": transport,
|
|
254
270
|
"mcp_config": "error",
|
|
255
271
|
"mcp_path": "",
|
|
256
272
|
"agents_md": "skipped(not-run)",
|
|
@@ -258,6 +274,14 @@ def connect_ide(
|
|
|
258
274
|
"error": None,
|
|
259
275
|
}
|
|
260
276
|
|
|
277
|
+
# Step 0 — validate transport
|
|
278
|
+
if transport not in VALID_TRANSPORTS:
|
|
279
|
+
result["error"] = (
|
|
280
|
+
f"Invalid transport '{transport}'. "
|
|
281
|
+
f"Valid choices: {', '.join(sorted(VALID_TRANSPORTS))}"
|
|
282
|
+
)
|
|
283
|
+
return result
|
|
284
|
+
|
|
261
285
|
# Step 1 — resolve
|
|
262
286
|
desc = resolve_descriptor(ide_id)
|
|
263
287
|
if desc is None:
|
|
@@ -303,9 +327,25 @@ def connect_ide(
|
|
|
303
327
|
# File is untouched (we never wrote; abort)
|
|
304
328
|
return result
|
|
305
329
|
|
|
306
|
-
# Step 4 —
|
|
307
|
-
|
|
330
|
+
# Step 4 — daemon health-check for HTTP transports (advisory, never blocks write)
|
|
331
|
+
if transport in ("http", "http-mcp-remote") and desc.fmt != "":
|
|
332
|
+
if not _check_daemon_health(daemon_port):
|
|
333
|
+
print(
|
|
334
|
+
f"[SLM] Warning: daemon not reachable at http://127.0.0.1:{daemon_port}/api/v3/health. "
|
|
335
|
+
f"Run `slm serve start` to start it, or `slm serve install` to register as an OS service.",
|
|
336
|
+
file=sys.stderr,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Step 5 — extract server container and build block
|
|
340
|
+
# For YAML-format IDEs (continue.dev), transport is ignored — their block
|
|
341
|
+
# structure is list-based and incompatible with the JSON http/mcp-remote block.
|
|
308
342
|
if desc.fmt == "yaml":
|
|
343
|
+
if transport != "stdio":
|
|
344
|
+
logger.warning(
|
|
345
|
+
"transport=%r is not supported for YAML-format IDE %r; using stdio fallback.",
|
|
346
|
+
transport,
|
|
347
|
+
ide_id,
|
|
348
|
+
)
|
|
309
349
|
mcp_status, servers_preserved = _merge_yaml_list(
|
|
310
350
|
data, desc, profile
|
|
311
351
|
)
|
|
@@ -316,10 +356,8 @@ def connect_ide(
|
|
|
316
356
|
pre_count = len(servers)
|
|
317
357
|
pre_slm = copy.deepcopy(servers.get("superlocalmemory"))
|
|
318
358
|
|
|
319
|
-
#
|
|
320
|
-
block =
|
|
321
|
-
if profile:
|
|
322
|
-
block.setdefault("env", {})["SLM_MCP_PROFILE"] = profile
|
|
359
|
+
# Build the server block for the requested transport
|
|
360
|
+
block = _build_server_block(desc, transport, daemon_port, profile)
|
|
323
361
|
|
|
324
362
|
servers["superlocalmemory"] = block
|
|
325
363
|
|
|
@@ -384,6 +422,8 @@ def connect_many(
|
|
|
384
422
|
here: bool = False,
|
|
385
423
|
profile: str | None = None,
|
|
386
424
|
agents_md_source: Callable[[], str] | None = None,
|
|
425
|
+
transport: str = "stdio",
|
|
426
|
+
daemon_port: int = 8765,
|
|
387
427
|
) -> list[dict[str, Any]]:
|
|
388
428
|
"""Wire SLM into multiple IDE configs via non-destructive merge.
|
|
389
429
|
|
|
@@ -402,14 +442,17 @@ def connect_many(
|
|
|
402
442
|
home: Override ``$HOME`` (test hook).
|
|
403
443
|
project: Project root for ``here=True`` installs.
|
|
404
444
|
here: When True, write to project-relative path instead of global.
|
|
405
|
-
profile: Inject ``SLM_MCP_PROFILE`` env-var
|
|
445
|
+
profile: Inject ``SLM_MCP_PROFILE`` env-var (stdio) or URL param (http).
|
|
406
446
|
agents_md_source: Callable returning AGENTS.md content to append.
|
|
447
|
+
transport: MCP transport to use for all IDEs ("stdio", "http",
|
|
448
|
+
"http-mcp-remote"). Default "stdio" preserves existing behavior.
|
|
449
|
+
daemon_port: Daemon listen port for http/http-mcp-remote (default 8765).
|
|
407
450
|
|
|
408
451
|
Returns:
|
|
409
452
|
List of per-IDE result dicts, one per input id. Each dict has the
|
|
410
453
|
same shape as :func:`connect_ide`'s return value::
|
|
411
454
|
|
|
412
|
-
{ide, mcp_config, mcp_path, agents_md, servers_preserved, error}
|
|
455
|
+
{ide, transport, mcp_config, mcp_path, agents_md, servers_preserved, error}
|
|
413
456
|
"""
|
|
414
457
|
return [
|
|
415
458
|
connect_ide(
|
|
@@ -419,6 +462,8 @@ def connect_many(
|
|
|
419
462
|
here=here,
|
|
420
463
|
profile=profile,
|
|
421
464
|
agents_md_source=agents_md_source,
|
|
465
|
+
transport=transport,
|
|
466
|
+
daemon_port=daemon_port,
|
|
422
467
|
)
|
|
423
468
|
for ide_id in ide_ids
|
|
424
469
|
]
|
|
@@ -507,6 +552,57 @@ def _merge_yaml_list(
|
|
|
507
552
|
return "wrote", pre_count
|
|
508
553
|
|
|
509
554
|
|
|
555
|
+
def _build_server_block(
|
|
556
|
+
desc: IDEDescriptor,
|
|
557
|
+
transport: str,
|
|
558
|
+
daemon_port: int,
|
|
559
|
+
profile: str | None,
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
"""Return the server block for the requested transport.
|
|
562
|
+
|
|
563
|
+
YAML and TOML IDEs use a format-specific block structure that is
|
|
564
|
+
incompatible with the JSON http/mcp-remote block — caller should never
|
|
565
|
+
reach here for those (they take the yaml branch in connect_ide).
|
|
566
|
+
For JSON-format IDEs:
|
|
567
|
+
"stdio" → copy of desc.server_block with optional SLM_MCP_PROFILE env
|
|
568
|
+
"http" → native MCP HTTP block; profile goes as URL query param
|
|
569
|
+
"http-mcp-remote" → mcp-remote stdio bridge; profile appended to proxied URL
|
|
570
|
+
"""
|
|
571
|
+
base_url = f"http://127.0.0.1:{daemon_port}/mcp/"
|
|
572
|
+
|
|
573
|
+
if transport == "http":
|
|
574
|
+
url = base_url + (f"?profile={profile}" if profile else "")
|
|
575
|
+
return {"type": "http", "url": url}
|
|
576
|
+
|
|
577
|
+
if transport == "http-mcp-remote":
|
|
578
|
+
url = base_url + (f"?profile={profile}" if profile else "")
|
|
579
|
+
return {"type": "stdio", "command": "mcp-remote", "args": [url]}
|
|
580
|
+
|
|
581
|
+
# Default: stdio — preserve existing behavior exactly
|
|
582
|
+
block = copy.deepcopy(desc.server_block)
|
|
583
|
+
if profile:
|
|
584
|
+
block.setdefault("env", {})["SLM_MCP_PROFILE"] = profile
|
|
585
|
+
return block
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _check_daemon_health(daemon_port: int, timeout: float = 2.0) -> bool:
|
|
589
|
+
"""Non-blocking probe of the SLM daemon health endpoint.
|
|
590
|
+
|
|
591
|
+
Returns True if the daemon responds with HTTP 200, False otherwise.
|
|
592
|
+
Never raises — all exceptions are caught and treated as "unreachable".
|
|
593
|
+
"""
|
|
594
|
+
try:
|
|
595
|
+
import urllib.request
|
|
596
|
+
import urllib.error
|
|
597
|
+
|
|
598
|
+
url = f"http://127.0.0.1:{daemon_port}/api/v3/health"
|
|
599
|
+
req = urllib.request.Request(url, method="GET")
|
|
600
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
601
|
+
return resp.status == 200
|
|
602
|
+
except Exception:
|
|
603
|
+
return False
|
|
604
|
+
|
|
605
|
+
|
|
510
606
|
def _atomic_write(path: Path, data: dict[str, Any], fmt: str) -> None:
|
|
511
607
|
"""Serialize data and atomically write to path (.tmp + os.replace)."""
|
|
512
608
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -38,6 +38,8 @@ _ORIGIN_HEADER_VARIANTS: tuple[str, ...] = ("Origin", "origin")
|
|
|
38
38
|
|
|
39
39
|
# Loopback addresses accepted by LLD-01. ``localhost`` is NOT included per
|
|
40
40
|
# SEC-01-02 — we want literal IPs only to avoid DNS-based bypass tricks.
|
|
41
|
+
# Note: ::ffff:127.x.x.x is handled dynamically via ipaddress.ip_address()
|
|
42
|
+
# in is_loopback() below to cover dual-stack container deployments (#90).
|
|
41
43
|
_LOOPBACK_ADDRS: frozenset[str] = frozenset({"127.0.0.1", "::1"})
|
|
42
44
|
|
|
43
45
|
# Body-size cap: LLD-01 §4.5 step 4 → 8 KB.
|
|
@@ -65,10 +67,27 @@ class AuthDecision:
|
|
|
65
67
|
|
|
66
68
|
|
|
67
69
|
def is_loopback(client_host: str) -> bool:
|
|
68
|
-
"""Return True iff ``client_host`` is an accepted loopback literal.
|
|
70
|
+
"""Return True iff ``client_host`` is an accepted loopback literal.
|
|
71
|
+
|
|
72
|
+
Deliberately excludes ``"localhost"`` per SEC-01-02 — /internal/prewarm
|
|
73
|
+
callers are in-process hooks that always connect to a literal IP, so
|
|
74
|
+
hostname aliases are rejected to prevent DNS-based bypass tricks.
|
|
75
|
+
|
|
76
|
+
Accepts IPv4-mapped IPv6 loopback (``::ffff:127.x.x.x``) for dual-stack
|
|
77
|
+
correctness (issue #90), while still excluding ``"localhost"``.
|
|
78
|
+
"""
|
|
69
79
|
if not isinstance(client_host, str) or not client_host:
|
|
70
80
|
return False
|
|
71
|
-
|
|
81
|
+
if client_host in _LOOPBACK_ADDRS:
|
|
82
|
+
return True
|
|
83
|
+
# Handle IPv4-mapped IPv6 loopback (::ffff:127.x.x.x) — dual-stack fix.
|
|
84
|
+
# Intentionally NOT accepting "localhost" (SEC-01-02 preserved).
|
|
85
|
+
try:
|
|
86
|
+
import ipaddress as _ipa
|
|
87
|
+
ip = _ipa.ip_address(client_host)
|
|
88
|
+
return ip.is_loopback
|
|
89
|
+
except ValueError:
|
|
90
|
+
return False
|
|
72
91
|
|
|
73
92
|
|
|
74
93
|
# ---------------------------------------------------------------------------
|
|
@@ -137,7 +137,9 @@ def authorize_http_mcp_request(
|
|
|
137
137
|
peer must present the configured SLM API key. The LAN allowlist limits
|
|
138
138
|
reachability but deliberately does not grant a write identity.
|
|
139
139
|
"""
|
|
140
|
-
|
|
140
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
141
|
+
|
|
142
|
+
if _is_loopback_host(client_host):
|
|
141
143
|
return True
|
|
142
144
|
provided = request_headers.get("x-slm-api-key", "")
|
|
143
145
|
return verify_api_key(provided, key_file=key_file)
|