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.
- package/CHANGELOG.md +57 -0
- package/README.md +10 -6
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/truth.py +80 -10
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +14 -3
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +76 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +21 -0
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/server/routes/brain.py +283 -15
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/database.py +36 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +257 -77
- 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
|
|
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
|
-
#
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
if base_url_input is not None or endpoint_input is not None:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
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
|
|
79
|
-
B = "b" # Smart Local
|
|
80
|
-
C = "c" #
|
|
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()
|