superlocalmemory 4.0.5 → 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 (42) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -6
  3. package/package.json +3 -1
  4. package/pyproject.toml +1 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/access/rbac.py +106 -0
  7. package/src/superlocalmemory/brain/truth.py +80 -10
  8. package/src/superlocalmemory/cli/__main__.py +17 -0
  9. package/src/superlocalmemory/cli/commands.py +14 -3
  10. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  11. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  12. package/src/superlocalmemory/cli/main.py +76 -0
  13. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  14. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  15. package/src/superlocalmemory/code_graph/parser.py +280 -100
  16. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  17. package/src/superlocalmemory/core/config.py +44 -1
  18. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  19. package/src/superlocalmemory/core/maintenance.py +43 -1
  20. package/src/superlocalmemory/core/recall_worker.py +33 -12
  21. package/src/superlocalmemory/infra/backup.py +138 -0
  22. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  23. package/src/superlocalmemory/learning/engagement.py +165 -0
  24. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  25. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  26. package/src/superlocalmemory/retrieval/engine.py +21 -0
  27. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  28. package/src/superlocalmemory/server/routes/brain.py +283 -15
  29. package/src/superlocalmemory/server/routes/learning.py +13 -25
  30. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  31. package/src/superlocalmemory/storage/database.py +36 -0
  32. package/src/superlocalmemory/storage/models.py +12 -4
  33. package/src/superlocalmemory/summaries/__init__.py +37 -0
  34. package/src/superlocalmemory/summaries/base.py +108 -0
  35. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  36. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  37. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  38. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  39. package/src/superlocalmemory/ui/index.html +28 -11
  40. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  41. package/src/superlocalmemory/ui/js/od-brain.js +257 -77
  42. 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,
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import json
16
16
  import logging
17
17
  import os
18
+ import platform
18
19
  import sqlite3
19
20
  import threading
20
21
  import time
@@ -85,6 +86,10 @@ _BUSY_TIMEOUT_MS = _env_int("SLM_DB_BUSY_TIMEOUT_MS", 10_000) # wait for write
85
86
  _MAX_RETRIES = _env_int("SLM_DB_MAX_RETRIES", 5) # retry on SQLITE_BUSY
86
87
  _RETRY_BASE_DELAY = _env_float("SLM_DB_RETRY_BASE_DELAY", 0.1) # backoff base (s)
87
88
 
89
+ # Warn once per process, not once per connection, when the WAL close-path
90
+ # deadlock guard cannot be installed (Python < 3.12).
91
+ _NO_CKPT_WARNED = False
92
+
88
93
 
89
94
  def _unbounded_facts_ceiling() -> int:
90
95
  """Hard upper bound applied when a fact fetch is called with limit=None, so
@@ -231,6 +236,37 @@ class DatabaseManager:
231
236
  conn.row_factory = sqlite3.Row
232
237
  conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
233
238
  conn.execute("PRAGMA foreign_keys=ON")
239
+ # wal_autocheckpoint is a PER-CONNECTION pragma and is NOT persisted in
240
+ # the database file (unlike journal_mode=WAL). Setting it only on the
241
+ # short-lived initialisation connection left every working connection
242
+ # on SQLite's default of 1000 frames. With checkpoint-on-close
243
+ # disabled below, autocheckpoint is the ONLY remaining checkpoint path,
244
+ # so the intended value must be set where the writes actually happen.
245
+ conn.execute("PRAGMA wal_autocheckpoint=400")
246
+ # Deadlock hardening (postmortem 2026-08-13, Option B): WAL close
247
+ # triggers a checkpoint that can wait indefinitely on reader marks
248
+ # pinned by another process/thread — while holding SQLite's
249
+ # process-global VFS mutex, which convoys every later connect().
250
+ # busy_timeout does NOT apply to the close path. NO_CKPT_ON_CLOSE
251
+ # makes close() checkpoint-free so it can never block; normal
252
+ # checkpointing continues via the wal_autocheckpoint set above.
253
+ # Available since Python 3.12 / SQLite 3.31; guarded for portability.
254
+ try:
255
+ conn.setconfig(sqlite3.SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, 1) # type: ignore[attr-defined]
256
+ except (AttributeError, sqlite3.OperationalError):
257
+ # Silent degradation would hide an inactive deadlock guard on a
258
+ # supported interpreter (requires-python allows 3.11, which
259
+ # predates Connection.setconfig). Warn once, not per connection.
260
+ global _NO_CKPT_WARNED
261
+ if not _NO_CKPT_WARNED:
262
+ _NO_CKPT_WARNED = True
263
+ logger.warning(
264
+ "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE unavailable (Python %s, "
265
+ "SQLite %s); WAL close-path deadlock hardening is INACTIVE. "
266
+ "Python 3.12+ is required for this protection.",
267
+ platform.python_version(),
268
+ sqlite3.sqlite_version,
269
+ )
234
270
  return conn
235
271
 
236
272
  @contextmanager
@@ -73,11 +73,19 @@ class SignalType(str, Enum):
73
73
 
74
74
 
75
75
  class Mode(str, Enum):
76
- """Operating modes — EU AI Act alignment."""
76
+ """Operating modes.
77
+
78
+ A — All data stays on this device. No AI language model runs anywhere.
79
+ Fastest and most private.
80
+ B — All data stays on this device. Uses a local Ollama AI model to
81
+ improve recall quality. Requires Ollama to be installed and running.
82
+ C — Uses a cloud AI provider (OpenAI, Anthropic, …) for the best recall
83
+ quality. Queries leave this device; an API key is required.
84
+ """
77
85
 
78
- A = "a" # Local Guardian: zero LLM, EU AI Act FULL
79
- B = "b" # Smart Local: local Ollama LLM, EU AI Act FULL
80
- C = "c" # Full Power: UNRESTRICTED, best models, 90%+ target
86
+ A = "a" # Local Guardian on-device only, no LLM
87
+ B = "b" # Smart Local — on-device + local Ollama LLM, no cloud
88
+ C = "c" # Cloud LLM best accuracy, queries leave device, API key needed
81
89
 
82
90
 
83
91
  # ---------------------------------------------------------------------------
@@ -0,0 +1,37 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SuperLocalMemory issue #113 — Personal Memory Views.
6
+
7
+ Three bounded, profile-scoped, traceable summaries:
8
+
9
+ Session Summary — what happened in a specific session (data is sparse:
10
+ only ~3.9% of facts carry a session_id on a real store;
11
+ coverage is always disclosed explicitly).
12
+ Daily Reflection — what was recorded on a specific date.
13
+ Project Work Log — what tool events and facts belong to a project,
14
+ scoped by tool_events.project_path (NOT by
15
+ entity_profiles.project_name, which has one distinct
16
+ value across 1,148 rows and is useless for scoping).
17
+
18
+ Every SummaryResult carries:
19
+ - source_fact_ids for traceability (maintainer's binding constraint)
20
+ - profile_id — cross-profile access is not permitted
21
+ - coverage — honest assessment; never silently partial
22
+
23
+ Deferred to 4.0.7: prompt-driven custom views. They are non-deterministic,
24
+ hard to make traceable, and a prompt-injection surface.
25
+ """
26
+
27
+ from .base import SummaryResult
28
+ from .daily_reflection import generate_daily_reflection
29
+ from .project_work_log import generate_project_work_log
30
+ from .session_summary import generate_session_summary
31
+
32
+ __all__ = [
33
+ "SummaryResult",
34
+ "generate_session_summary",
35
+ "generate_daily_reflection",
36
+ "generate_project_work_log",
37
+ ]
@@ -0,0 +1,108 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Base types for the #113 bounded summary layer."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass
14
+ class SummaryResult:
15
+ """A bounded, profile-scoped, traceable summary of user memories.
16
+
17
+ Maintainer's binding constraint (issue #113 reply):
18
+ "views must be customizable, profile-scoped, privacy-aware, and
19
+ traceable back to the underlying memories rather than becoming
20
+ opaque generic summaries"
21
+
22
+ This dataclass enforces three of those four constraints structurally:
23
+
24
+ Traceability
25
+ ``source_fact_ids`` carries the atomic_facts.fact_id for every fact
26
+ that contributed to this summary. A user can always drill back to
27
+ the raw memories.
28
+
29
+ Profile scope
30
+ ``profile_id`` is mandatory; callers must never mix profiles.
31
+
32
+ Honesty / non-opaqueness
33
+ ``coverage`` must be set to an accurate value. See the constants
34
+ below. A summary over 3.9% of facts that presents itself as "your
35
+ session" is precisely the opaque generic summary the maintainer said
36
+ to avoid.
37
+
38
+ Generated-by
39
+ ``generated_by`` records whether the content is extractive
40
+ (deterministic, always available, Mode A default) or came from an
41
+ LLM (Mode B Ollama / Mode C cloud).
42
+
43
+ Attributes:
44
+ kind: "session" | "daily" | "project"
45
+ profile_id: Owning profile — never expose across profiles.
46
+ content: Human-readable summary text.
47
+ source_fact_ids: IDs of the atomic_facts that contributed.
48
+ Empty only when the underlying data does not exist.
49
+ coverage: One of the COVERAGE_* constants below.
50
+ generated_by: One of the GENERATED_BY_* constants below.
51
+ metadata: Extra context: date, project_path, session_id, etc.
52
+ """
53
+
54
+ kind: str
55
+ profile_id: str
56
+ content: str
57
+ source_fact_ids: list[str]
58
+ coverage: str
59
+ generated_by: str
60
+ metadata: dict[str, Any] = field(default_factory=dict)
61
+
62
+
63
+ # ── coverage constants ──────────────────────────────────────────────────────
64
+ #
65
+ # Use these strings; the acceptance gate checks for their presence
66
+ # and the values must be human-interpretable without this file.
67
+
68
+ COVERAGE_FULL = "full"
69
+ """All relevant data was available and contributed to the summary."""
70
+
71
+ COVERAGE_PARTIAL = "partial"
72
+ """Some data was available. Session summaries are always at most partial
73
+ because only ~3.9% of facts carry a session_id on a real store."""
74
+
75
+ COVERAGE_INSUFFICIENT = "insufficient"
76
+ """Too few facts to produce a meaningful summary (below MIN_FACTS threshold)."""
77
+
78
+ COVERAGE_NO_SESSION = "no_session"
79
+ """Session ID not found, or the session has no associated facts."""
80
+
81
+ COVERAGE_UNAVAILABLE = "unavailable"
82
+ """Required data does not exist or a query error prevented access."""
83
+
84
+
85
+ # ── generated_by constants ──────────────────────────────────────────────────
86
+ #
87
+ # extractive is the deterministic fallback, ALWAYS available.
88
+ # Mode A users never get anything else. Mode B/C users fall back when
89
+ # Ollama or the cloud is down — silence is not an option.
90
+
91
+ GENERATED_BY_EXTRACTIVE = "extractive"
92
+ """Deterministic extractive summary — no LLM. Always available."""
93
+
94
+ GENERATED_BY_LLM_B = "llm_b"
95
+ """Ollama local LLM (Mode B). Falls back to extractive if unavailable."""
96
+
97
+ GENERATED_BY_LLM_C = "llm_c"
98
+ """Cloud LLM (Mode C). Falls back via llm_b to extractive."""
99
+
100
+
101
+ def get_mode_str(config: object | None) -> str:
102
+ """Extract the operating mode string ('a', 'b', or 'c') from a config."""
103
+ if config is None:
104
+ return "a"
105
+ m = getattr(config, "mode", None)
106
+ if m is None:
107
+ return "a"
108
+ return getattr(m, "value", str(m)).lower()