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.
Files changed (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. 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
- if client_host in ("127.0.0.1", "::1", "localhost"):
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
- gate = sqlite3.connect(self.db_path, timeout=30)
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
- embedding = embedder.embed(fact.content) if embedder else None
170
- fisher_mean, fisher_variance = (None, None)
171
- if embedder and embedding:
172
- fisher_mean, fisher_variance = embedder.compute_fisher_params(embedding)
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
- """Update last_seen timestamp on a canonical entity scoped to profile.
567
-
568
- L-01 fix: the original query had no profile_id guard. Since entity_ids are
569
- UUIDs today the blast radius is theoretical, but the guard is required for
570
- defense-in-depth against future import/sharing features that could introduce
571
- UUID collisions across profiles.
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
- self._db.execute(
574
- "UPDATE canonical_entities SET last_seen = ? "
575
- "WHERE entity_id = ? AND profile_id = ?",
576
- (_now(), entity_id, profile_id),
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 0.3 embedded bindings and later client conveniences.
78
+ """Bridge legacy PyCozo responses and the pinned 0.7.6 client surface.
79
79
 
80
- PyCozo 0.3 is the last client compatible with the published macOS native
81
- binding. It returns dictionaries and exposes ``import_relations`` rather
82
- than ``put``; later clients return dataframe-like values and add ``put``.
83
- SLM only needs relation upserts and row results, so normalize those here.
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). Fail fast rather than block a host tool.
44
- BUSY_TIMEOUT_MS: int = 50
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
- conn = sqlite3.connect(str(db_path))
91
- try:
92
- conn.executescript(
93
- "CREATE TABLE IF NOT EXISTS cross_platform_sync_log ("
94
- " adapter_name TEXT NOT NULL,"
95
- " profile_id TEXT NOT NULL,"
96
- " target_path_sha256 TEXT NOT NULL,"
97
- " target_basename TEXT NOT NULL,"
98
- " last_sync_at TEXT NOT NULL,"
99
- " bytes_written INTEGER NOT NULL,"
100
- " content_sha256 TEXT NOT NULL,"
101
- " success INTEGER NOT NULL,"
102
- " error_msg TEXT,"
103
- " PRIMARY KEY (adapter_name, target_path_sha256));"
104
- )
105
- conn.commit()
106
- finally:
107
- conn.close()
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
- _ensure_memory_log(db_path)
160
- conn = sqlite3.connect(str(db_path))
161
- try:
162
- conn.execute(
163
- "INSERT INTO cross_platform_sync_log ("
164
- "adapter_name, profile_id, target_path_sha256, target_basename, "
165
- "last_sync_at, bytes_written, content_sha256, success, error_msg"
166
- ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
167
- "ON CONFLICT(adapter_name, target_path_sha256) DO UPDATE SET "
168
- " profile_id = excluded.profile_id,"
169
- " target_basename = excluded.target_basename,"
170
- " last_sync_at = excluded.last_sync_at,"
171
- " bytes_written = excluded.bytes_written,"
172
- " content_sha256 = excluded.content_sha256,"
173
- " success = excluded.success,"
174
- " error_msg = excluded.error_msg",
175
- (
176
- adapter_name, profile_id, target_path_sha256, target_basename,
177
- _now_iso(), bytes_written, content_sha256,
178
- 1 if success else 0, error_msg,
179
- ),
180
- )
181
- conn.commit()
182
- finally:
183
- conn.close()
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(self, path: Path) -> bool:
192
- """Merge SLM config into a JSON config file."""
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
- data["mcpServers"]["superlocalmemory"] = {
205
- "type": "stdio",
206
- "command": "slm",
207
- "args": ["mcp"],
208
- "enabled": True,
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 — extract server container
307
- # For continue (yaml list), special-case
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
- # Step 5 merge
320
- block = copy.deepcopy(desc.server_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 into every server block.
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
- return client_host in _LOOPBACK_ADDRS
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
- if client_host in ("127.0.0.1", "::1", "localhost"):
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)