superlocalmemory 4.0.4 → 4.0.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 (90) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +23 -14
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +2 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +6 -5
  26. package/plugin-src/skills/slm-graph/SKILL.md +2 -1
  27. package/plugin-src/skills/slm-profile/SKILL.md +1 -0
  28. package/pyproject.toml +1 -1
  29. package/src/superlocalmemory/__init__.py +1 -1
  30. package/src/superlocalmemory/access/rbac.py +106 -0
  31. package/src/superlocalmemory/brain/__init__.py +5 -0
  32. package/src/superlocalmemory/brain/truth.py +418 -0
  33. package/src/superlocalmemory/cli/__main__.py +17 -0
  34. package/src/superlocalmemory/cli/commands.py +96 -28
  35. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  36. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  37. package/src/superlocalmemory/cli/main.py +88 -0
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/context_cache.py +58 -1
  44. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  45. package/src/superlocalmemory/core/maintenance.py +43 -1
  46. package/src/superlocalmemory/core/mutations.py +155 -25
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  48. package/src/superlocalmemory/core/recall_worker.py +33 -12
  49. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  50. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  51. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  52. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  54. package/src/superlocalmemory/infra/backup.py +138 -0
  55. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  57. package/src/superlocalmemory/learning/engagement.py +165 -0
  58. package/src/superlocalmemory/mcp/profiles.py +19 -7
  59. package/src/superlocalmemory/mcp/server.py +4 -2
  60. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  61. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  62. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  63. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  64. package/src/superlocalmemory/retrieval/engine.py +28 -10
  65. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  66. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  67. package/src/superlocalmemory/server/routes/brain.py +297 -14
  68. package/src/superlocalmemory/server/routes/learning.py +13 -25
  69. package/src/superlocalmemory/server/routes/memories.py +129 -3
  70. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  71. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  72. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  73. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  74. package/src/superlocalmemory/storage/database.py +230 -24
  75. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  76. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/models.py +12 -4
  79. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  80. package/src/superlocalmemory/summaries/__init__.py +37 -0
  81. package/src/superlocalmemory/summaries/base.py +108 -0
  82. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  83. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  84. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  85. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  86. package/src/superlocalmemory/ui/index.html +28 -11
  87. package/src/superlocalmemory/ui/js/brain.js +43 -7
  88. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  89. package/src/superlocalmemory/ui/js/od-brain.js +280 -84
  90. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -260,6 +260,131 @@ async def set_mode(request: Request):
260
260
  return _internal_error()
261
261
 
262
262
 
263
+ def apply_settings_update(config: "SLMConfig", payload: dict) -> "SLMConfig":
264
+ """Credential-safe LLM config update for the settings dashboard (fixes #119).
265
+
266
+ This is the SINGLE authoritative place where incoming dashboard save payloads
267
+ are merged onto the stored config. The POST /api/v3/mode/set handler calls
268
+ this function after its HTTP-layer concerns (SSRF guard, auth) are settled,
269
+ so the acceptance gate (test_wave2_acceptance.py::P3) tests real product
270
+ behaviour — not a reimplementation.
271
+
272
+ SEC-L-01 PRESERVED:
273
+ The GET /mode route returns only ``urlparse(api_base).netloc`` (the host),
274
+ never the full URL. When the UI echoes that value back on save,
275
+ apply_settings_update detects the scheme-less netloc and silently restores
276
+ the stored full URL (scheme + path). This means the server-side logic
277
+ internally reads the stored URL but NEVER returns it to callers — viewer
278
+ users remain unable to discover the full endpoint topology.
279
+
280
+ Credential semantics:
281
+ api_key blank/omitted → UNCHANGED (browsers never repopulate pw fields)
282
+ api_key non-blank → applied
283
+ clear_api_key = True → explicit "" (deliberate wipe — only way to clear)
284
+
285
+ endpoint absent/empty → UNCHANGED
286
+ endpoint == stored netloc (no scheme "://") → UNCHANGED (redacted echo)
287
+ endpoint with "://" → applied
288
+ clear_base_url = True → explicit "" (deliberate wipe)
289
+
290
+ Security rule: if the destination provider or endpoint genuinely changes AND
291
+ no new key was supplied, the stored key is cleared to prevent accidental
292
+ credential reuse across providers. An explicit clear_api_key=True is
293
+ always honoured regardless.
294
+
295
+ NEVER logs api_key.
296
+ """
297
+ import dataclasses as _dc
298
+ from urllib.parse import urlparse, urlsplit, urlunsplit
299
+
300
+ stored = config.llm
301
+ stored_key: str = stored.api_key
302
+ stored_base: str = stored.api_base or ""
303
+
304
+ # ── endpoint ─────────────────────────────────────────────────────────────
305
+ raw_endpoint: str = (payload.get("endpoint") or payload.get("base_url") or "").strip()
306
+ clear_endpoint: bool = payload.get("clear_base_url") is True
307
+
308
+ if clear_endpoint:
309
+ new_base = ""
310
+ endpoint_changed = bool(stored_base)
311
+ elif not raw_endpoint:
312
+ # Nothing supplied — keep stored URL intact.
313
+ new_base = stored_base
314
+ endpoint_changed = False
315
+ else:
316
+ # Detect the SEC-L-01 redacted echo: GET /mode returns
317
+ # urlparse(api_base).netloc (host only, no scheme, no path). If the
318
+ # client sent that exact string back, it did NOT change the endpoint —
319
+ # restore the full stored URL. We compare the raw string directly to
320
+ # stored_netloc so that hosts with ports ("api.host.com:443") also match.
321
+ stored_netloc: str = urlparse(stored_base).netloc if stored_base else ""
322
+ is_redacted_echo: bool = bool(stored_netloc) and (raw_endpoint == stored_netloc)
323
+
324
+ if is_redacted_echo:
325
+ new_base = stored_base
326
+ endpoint_changed = False
327
+ else:
328
+ new_base = raw_endpoint
329
+ # Canonical comparison (strip trailing slash, normalise scheme/host
330
+ # case) so a harmless trailing-slash difference does not clear the key.
331
+ def _canonical(u: str) -> str:
332
+ p = urlsplit(u)
333
+ return urlunsplit((
334
+ p.scheme.lower(), p.netloc.lower(),
335
+ p.path.rstrip("/"), p.query, "",
336
+ ))
337
+ endpoint_changed = _canonical(new_base) != _canonical(stored_base)
338
+
339
+ # ── provider ─────────────────────────────────────────────────────────────
340
+ raw_provider: str = (payload.get("provider") or "").strip()
341
+ if raw_provider == "none":
342
+ new_provider = "" # "none" sentinel means "clear provider"
343
+ elif raw_provider:
344
+ new_provider = raw_provider
345
+ else:
346
+ new_provider = stored.provider or ""
347
+
348
+ provider_changed: bool = new_provider != (stored.provider or "")
349
+
350
+ # ── api_key ───────────────────────────────────────────────────────────────
351
+ # Evaluated AFTER endpoint/provider so we know whether the destination changed.
352
+ raw_key: str = (payload.get("api_key") or "").strip()
353
+ clear_key: bool = payload.get("clear_api_key") is True
354
+ destination_changed: bool = provider_changed or endpoint_changed
355
+
356
+ if clear_key:
357
+ new_key = "" # explicit user wipe
358
+ elif raw_key:
359
+ new_key = raw_key # user supplied a replacement key
360
+ elif destination_changed:
361
+ # Security: destination changed but no new key → clear to prevent
362
+ # the stored credential from being silently redirected to a new
363
+ # provider or endpoint the user may not own.
364
+ new_key = ""
365
+ else:
366
+ # Blank key + same destination = browser did not repopulate the
367
+ # password field. Preserve the stored key verbatim.
368
+ new_key = stored_key
369
+
370
+ # ── model ────────────────────────────────────────────────────────────────
371
+ raw_model: str = (payload.get("model") or "").strip()
372
+ new_model: str = raw_model if raw_model else (stored.model or "")
373
+
374
+ # ── apply ─────────────────────────────────────────────────────────────────
375
+ # dataclasses.replace preserves temperature / max_tokens / timeout_seconds.
376
+ # Assigning to config.llm works because SLMConfig is not frozen.
377
+ # NEVER include new_key in any log call.
378
+ config.llm = _dc.replace(
379
+ stored,
380
+ provider=new_provider,
381
+ model=new_model,
382
+ api_key=new_key,
383
+ api_base=new_base,
384
+ )
385
+ return config
386
+
387
+
263
388
  @router.post("/mode/set")
264
389
  async def set_full_config(request: Request):
265
390
  """Save mode + provider + model + API key together.
@@ -273,7 +398,7 @@ async def set_full_config(request: Request):
273
398
  body = await request.json()
274
399
  if not isinstance(body, dict):
275
400
  return JSONResponse({"error": "Request body must be a JSON object"}, status_code=400)
276
- from superlocalmemory.core.config import SLMConfig, EmbeddingConfig, LLMConfig
401
+ from superlocalmemory.core.config import SLMConfig, EmbeddingConfig
277
402
  from superlocalmemory.storage.models import Mode
278
403
  from superlocalmemory.server.routes.helpers import log_mode_change
279
404
 
@@ -316,68 +441,54 @@ async def set_full_config(request: Request):
316
441
  if new_mode not in ("a", "b", "c"):
317
442
  return JSONResponse({"error": "Invalid mode"}, status_code=400)
318
443
 
319
- # Field presence, not form defaults, is the persistence contract. A
320
- # dashboard panel may update embeddings without sending mode/provider,
321
- # and password fields deliberately reload blank. In both cases saved
322
- # LLM state must remain intact unless the user supplies a replacement
323
- # or explicit clear flag.
324
- provider = provider_input or config.llm.provider or "none"
325
- model = model_input or config.llm.model
326
- _endpoint = "" if clear_base_url else (base_url_input or endpoint_input or "")
444
+ # Resolve the effective endpoint value before SSRF validation.
445
+ # Only the Ollama default injection happens here; the fallback to the
446
+ # stored URL (and redacted-echo detection) live inside
447
+ # apply_settings_update so that the P3 acceptance gate exercises
448
+ # the same code path as the HTTP handler — not a reimplementation.
449
+ _raw_ep: str = "" if clear_base_url else (base_url_input or endpoint_input or "")
327
450
  if (
328
- not _endpoint and provider_input == "ollama"
451
+ not _raw_ep
452
+ and provider_input == "ollama"
329
453
  and config.llm.provider != "ollama"
330
454
  and not clear_base_url
331
455
  ):
332
- _endpoint = "http://localhost:11434"
333
- elif not _endpoint and not clear_base_url:
334
- _endpoint = config.llm.api_base or ""
335
-
336
- # Custom endpoints become runtime egress destinations. Apply the
337
- # same boundary policy as the connection-test route before persisting.
338
- if base_url_input is not None or endpoint_input is not None:
339
- client = getattr(request, "client", None)
340
- endpoint_error = _validate_provider_url(
341
- _endpoint, getattr(client, "host", "") if client else ""
342
- )
343
- if endpoint_error:
344
- return JSONResponse({"error": endpoint_error}, status_code=400)
345
-
346
- # A retained cloud credential must never be redirected to a caller's
347
- # newly supplied provider or endpoint. Compare canonical URL identity
348
- # after validation so a harmless host-case or trailing-slash rewrite
349
- # does not unexpectedly clear the key.
350
- from urllib.parse import urlsplit, urlunsplit
351
-
352
- def _endpoint_identity(url: str) -> str:
353
- parsed = urlsplit(url)
354
- return urlunsplit((
355
- parsed.scheme.lower(), parsed.netloc.lower(),
356
- parsed.path.rstrip("/"), parsed.query, "",
357
- ))
358
-
359
- destination_changed = (
360
- (provider_input is not None and provider_input != config.llm.provider)
361
- or (
362
- (base_url_input is not None or endpoint_input is not None)
363
- and _endpoint_identity(_endpoint)
364
- != _endpoint_identity(config.llm.api_base or "")
365
- )
366
- )
367
- api_key = "" if (clear_api_key or (destination_changed and not api_key_input)) else (
368
- api_key_input or config.llm.api_key
369
- )
370
-
371
- # Mutate only the fields the dashboard sent — all other config blocks
372
- # (forgetting, injection, retrieval, math, consolidation, scope, …) are
373
- # preserved because we loaded the full existing config above.
456
+ _raw_ep = "http://localhost:11434"
457
+
458
+ # SSRF guard fires only for genuinely new egress destinations.
459
+ # Redacted echoes (the netloc-only string GET /mode returns per
460
+ # SEC-L-01) are NOT outbound targets; they will be silently replaced
461
+ # with the stored full URL by apply_settings_update.
462
+ if _raw_ep and (base_url_input is not None or endpoint_input is not None):
463
+ from urllib.parse import urlparse as _up
464
+ _stored_nl: str = _up(config.llm.api_base or "").netloc
465
+ _is_redacted_echo: bool = bool(_stored_nl) and (_raw_ep == _stored_nl)
466
+ if not _is_redacted_echo:
467
+ client = getattr(request, "client", None)
468
+ endpoint_error = _validate_provider_url(
469
+ _raw_ep, getattr(client, "host", "") if client else ""
470
+ )
471
+ if endpoint_error:
472
+ return JSONResponse({"error": endpoint_error}, status_code=400)
473
+
474
+ # Build the normalised body for apply_settings_update. Inject the
475
+ # resolved endpoint (Ollama default when applicable) so that the
476
+ # credential-safe logic sees the approved value. Consolidate
477
+ # base_url → endpoint to keep apply_settings_update's lookup simple.
478
+ _apply_body: dict = dict(body)
479
+ if _raw_ep or clear_base_url:
480
+ _apply_body["endpoint"] = _raw_ep
481
+ _apply_body.pop("base_url", None)
482
+
483
+ # Credential-safe LLM update. All field presence, preservation, and
484
+ # security-clearing rules live here. Other config blocks (forgetting,
485
+ # injection, retrieval, math, consolidation, scope, …) are preserved
486
+ # because we loaded the full existing config at the top of this handler.
487
+ config = apply_settings_update(config, _apply_body)
488
+
489
+ # Mode switch — happens after LLM update so the correct provider/key
490
+ # is already in place when the runtime engine is reconfigured.
374
491
  config.mode = Mode(new_mode)
375
- config.llm = LLMConfig(
376
- provider=provider if provider != "none" else "",
377
- model=model,
378
- api_key=api_key,
379
- api_base=_endpoint,
380
- )
381
492
 
382
493
  # Update embedding only when the dashboard explicitly sent those fields;
383
494
  # absence means "leave it alone" (AIDEV-86 / broader fix).
@@ -423,8 +534,8 @@ async def set_full_config(request: Request):
423
534
  return {
424
535
  "success": True,
425
536
  "mode": new_mode,
426
- "provider": provider,
427
- "model": model,
537
+ "provider": config.llm.provider or "none",
538
+ "model": config.llm.model,
428
539
  "embedding_provider": config.embedding.provider,
429
540
  "embedding_model": config.embedding.model_name,
430
541
  "embedding_dimension": config.embedding.dimension,
@@ -150,6 +150,9 @@ from superlocalmemory.storage.migrations import (
150
150
  from superlocalmemory.storage.migrations import (
151
151
  M041_external_evidence_receipts as _M041,
152
152
  )
153
+ from superlocalmemory.storage.migrations import (
154
+ M042_correction_case_ledger as _M042,
155
+ )
153
156
 
154
157
  # Emit under the runner's logger name so operational log filters that key on
155
158
  # "superlocalmemory.storage.migration_runner" keep matching after this split.
@@ -199,6 +202,7 @@ _MODULES = {
199
202
  _M039.NAME: _M039,
200
203
  _M040.NAME: _M040,
201
204
  _M041.NAME: _M041,
205
+ _M042.NAME: _M042,
202
206
  }
203
207
 
204
208
  # Exact historical DDL fingerprints whose resulting schema is intentionally
@@ -21,9 +21,9 @@ import sqlite3
21
21
  from pathlib import Path
22
22
 
23
23
  #: Highest schema_version this runner can write. Matches the trailing serial
24
- #: of the latest migration (M041). Increment when adding new migrations or
24
+ #: of the latest migration (M042). Increment when adding new migrations or
25
25
  #: table-level breaking changes.
26
- SUPPORTED_SCHEMA_VERSION: int = 41
26
+ SUPPORTED_SCHEMA_VERSION: int = 42
27
27
 
28
28
 
29
29
  class SchemaVersionError(RuntimeError):