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
@@ -0,0 +1,424 @@
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
+ """Project Work Log — issue #113 bounded summary.
6
+
7
+ Produces a human-readable log of tool activity and recorded facts for a
8
+ specific project, identified by tool_events.project_path.
9
+
10
+ CRITICAL IMPLEMENTATION NOTE — DO NOT USE entity_profiles.project_name
11
+ -----------------------------------------------------------------------
12
+ entity_profiles.project_name has 1,148 rows and EXACTLY ONE distinct value
13
+ on a real store. Grouping by it yields a single meaningless bucket for all
14
+ projects. This is measured fact, not assumption.
15
+
16
+ Project scope MUST come from tool_events.project_path.
17
+ - 1,899 rows across 13 real projects on the same store.
18
+ - Top project: ".../testing - automation" (336 events).
19
+ - This is the only reliable project discriminator in the schema.
20
+
21
+ DETERMINISTIC FALLBACK
22
+ -----------------------
23
+ The extractive path aggregates tool events and the top facts for the project.
24
+ It never calls an LLM and never fails to return a result. Mode B/C
25
+ enrichment is attempted when configured, but falls back to extractive on any
26
+ failure.
27
+
28
+ HOT PATH EXCLUSION
29
+ -------------------
30
+ This module must NEVER be imported from core/recall_pipeline.py or
31
+ core/store_pipeline.py.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import sqlite3
38
+ from collections import Counter
39
+ from pathlib import Path
40
+
41
+ from .base import (
42
+ COVERAGE_FULL,
43
+ COVERAGE_INSUFFICIENT,
44
+ COVERAGE_UNAVAILABLE,
45
+ GENERATED_BY_EXTRACTIVE,
46
+ GENERATED_BY_LLM_B,
47
+ GENERATED_BY_LLM_C,
48
+ SummaryResult,
49
+ get_mode_str,
50
+ )
51
+
52
+ logger = logging.getLogger("superlocalmemory.summaries.project")
53
+
54
+ _MIN_EVENTS = 1 # Minimum tool_events rows to attempt a summary
55
+ _MIN_FACTS = 1 # Minimum atomic_facts rows to include facts section
56
+ _TOP_TOOLS = 8 # How many tools to list in the summary
57
+ _TOP_FACTS = 8 # How many facts to include in the extractive body
58
+ _MAX_FACT_CHARS = 300 # Per-fact character cap in the body
59
+
60
+
61
+ def generate_project_work_log(
62
+ db_path: str | Path,
63
+ project_path: str,
64
+ profile_id: str = "default",
65
+ config: object | None = None,
66
+ ) -> SummaryResult:
67
+ """Generate a Project Work Log for a specific project.
68
+
69
+ Scope is determined by tool_events.project_path — NOT by
70
+ entity_profiles.project_name (which has 1 distinct value on a real store
71
+ and is therefore useless for scoping).
72
+
73
+ Args:
74
+ db_path: Path to memory.db.
75
+ project_path: The project path as stored in tool_events.project_path.
76
+ Exact match — callers may pass a prefix and use
77
+ generate_project_work_log_by_prefix() for fuzzy matching.
78
+ profile_id: Profile scope — never mix profiles.
79
+ config: Optional SLMConfig for LLM enrichment. None = Mode A.
80
+
81
+ Returns:
82
+ SummaryResult with source_fact_ids. Never returns None.
83
+ """
84
+ db_path = Path(db_path)
85
+
86
+ # ── query tool events ────────────────────────────────────────────────────
87
+ tool_rows, facts_rows, query_error = _query_project_data(
88
+ db_path, project_path, profile_id
89
+ )
90
+
91
+ if query_error:
92
+ return SummaryResult(
93
+ kind="project",
94
+ profile_id=profile_id,
95
+ content=(
96
+ f"Project work log for '{project_path}' is unavailable: "
97
+ f"data access error."
98
+ ),
99
+ source_fact_ids=[],
100
+ coverage=COVERAGE_UNAVAILABLE,
101
+ generated_by=GENERATED_BY_EXTRACTIVE,
102
+ metadata={"project_path": project_path, "error": query_error},
103
+ )
104
+
105
+ if not tool_rows and not facts_rows:
106
+ return SummaryResult(
107
+ kind="project",
108
+ profile_id=profile_id,
109
+ content=(
110
+ f"No tool events or facts found for project '{project_path}'.\n"
111
+ f"Note: project scope is matched by tool_events.project_path "
112
+ f"(exact match)."
113
+ ),
114
+ source_fact_ids=[],
115
+ coverage=COVERAGE_INSUFFICIENT,
116
+ generated_by=GENERATED_BY_EXTRACTIVE,
117
+ metadata={"project_path": project_path, "event_count": 0},
118
+ )
119
+
120
+ source_fact_ids = [f["fact_id"] for f in facts_rows]
121
+ event_count = len(tool_rows)
122
+ fact_count = len(facts_rows)
123
+
124
+ coverage = COVERAGE_FULL if (event_count >= _MIN_EVENTS or fact_count >= _MIN_FACTS) \
125
+ else COVERAGE_INSUFFICIENT
126
+
127
+ extractive_content = _build_extractive_content(
128
+ project_path, tool_rows, facts_rows, event_count, fact_count
129
+ )
130
+
131
+ # ── LLM enrichment (optional) ─────────────────────────────────────────────
132
+ mode = get_mode_str(config)
133
+ if mode in ("b", "c"):
134
+ llm_content, llm_mode = _try_llm(
135
+ project_path, tool_rows, facts_rows, config, mode
136
+ )
137
+ if llm_content:
138
+ return SummaryResult(
139
+ kind="project",
140
+ profile_id=profile_id,
141
+ content=llm_content,
142
+ source_fact_ids=source_fact_ids,
143
+ coverage=coverage,
144
+ generated_by=llm_mode,
145
+ metadata={
146
+ "project_path": project_path,
147
+ "event_count": event_count,
148
+ "fact_count": fact_count,
149
+ },
150
+ )
151
+
152
+ return SummaryResult(
153
+ kind="project",
154
+ profile_id=profile_id,
155
+ content=extractive_content,
156
+ source_fact_ids=source_fact_ids,
157
+ coverage=coverage,
158
+ generated_by=GENERATED_BY_EXTRACTIVE,
159
+ metadata={
160
+ "project_path": project_path,
161
+ "event_count": event_count,
162
+ "fact_count": fact_count,
163
+ },
164
+ )
165
+
166
+
167
+ def generate_project_work_log_by_prefix(
168
+ db_path: str | Path,
169
+ project_prefix: str,
170
+ profile_id: str = "default",
171
+ config: object | None = None,
172
+ ) -> list[SummaryResult]:
173
+ """Generate work logs for all projects whose path starts with project_prefix.
174
+
175
+ Useful when the user knows only the parent directory, not the exact path.
176
+ Returns one SummaryResult per distinct project_path found.
177
+
178
+ Each result uses the EXACT project_path as the key, so source_fact_ids
179
+ and tool events are correctly scoped per sub-project.
180
+ """
181
+ db_path = Path(db_path)
182
+ try:
183
+ conn = sqlite3.connect(str(db_path), timeout=5.0)
184
+ conn.row_factory = sqlite3.Row
185
+ conn.execute("PRAGMA query_only=ON")
186
+ try:
187
+ rows = conn.execute(
188
+ """
189
+ SELECT DISTINCT project_path
190
+ FROM tool_events
191
+ WHERE profile_id = ?
192
+ AND project_path LIKE ?
193
+ AND project_path != ''
194
+ ORDER BY project_path
195
+ """,
196
+ (profile_id, f"{project_prefix}%"),
197
+ ).fetchall()
198
+ finally:
199
+ conn.close()
200
+ except Exception:
201
+ return []
202
+
203
+ results = []
204
+ for row in rows:
205
+ path = dict(row)["project_path"]
206
+ result = generate_project_work_log(db_path, path, profile_id, config)
207
+ results.append(result)
208
+ return results
209
+
210
+
211
+ # ── internal helpers ──────────────────────────────────────────────────────────
212
+
213
+ def _query_project_data(
214
+ db_path: Path,
215
+ project_path: str,
216
+ profile_id: str,
217
+ ) -> tuple[list[dict], list[dict], str | None]:
218
+ """Query tool events and associated facts for the project.
219
+
220
+ Returns (tool_rows, facts_rows, error_message_or_None).
221
+ Uses tool_events.project_path for project scoping — not project_name.
222
+ """
223
+ try:
224
+ conn = sqlite3.connect(str(db_path), timeout=5.0)
225
+ conn.row_factory = sqlite3.Row
226
+ conn.execute("PRAGMA query_only=ON")
227
+ try:
228
+ # Tool events scoped by project_path (the correct column).
229
+ # Using tool_events.project_path — NOT entity_profiles.project_name.
230
+ tool_rows = conn.execute(
231
+ """
232
+ SELECT tool_name, event_type, session_id,
233
+ input_summary, output_summary, created_at, duration_ms
234
+ FROM tool_events
235
+ WHERE profile_id = ?
236
+ AND project_path = ?
237
+ ORDER BY created_at ASC
238
+ """,
239
+ (profile_id, project_path),
240
+ ).fetchall()
241
+
242
+ # Facts recorded during sessions that touched this project.
243
+ facts_rows = conn.execute(
244
+ """
245
+ SELECT DISTINCT af.fact_id, af.content, af.created_at,
246
+ af.importance, af.canonical_entities_json
247
+ FROM atomic_facts af
248
+ JOIN tool_events te
249
+ ON te.session_id = af.session_id
250
+ AND te.profile_id = af.profile_id
251
+ WHERE af.profile_id = ?
252
+ AND te.project_path = ?
253
+ AND af.lifecycle != 'archived'
254
+ ORDER BY af.importance DESC, af.created_at ASC
255
+ """,
256
+ (profile_id, project_path),
257
+ ).fetchall()
258
+ finally:
259
+ conn.close()
260
+ except Exception as exc:
261
+ logger.warning("project work log query failed for %s: %s", project_path, exc)
262
+ return [], [], str(exc)
263
+
264
+ return [dict(r) for r in tool_rows], [dict(r) for r in facts_rows], None
265
+
266
+
267
+ def _build_extractive_content(
268
+ project_path: str,
269
+ tool_rows: list[dict],
270
+ facts_rows: list[dict],
271
+ event_count: int,
272
+ fact_count: int,
273
+ ) -> str:
274
+ """Build a deterministic extractive project work log."""
275
+ # Readable project name: last 2 path components.
276
+ parts = project_path.rstrip("/").split("/")
277
+ display_name = "/".join(parts[-2:]) if len(parts) >= 2 else project_path
278
+
279
+ lines = [
280
+ f"Project Work Log: {display_name}",
281
+ f"Full path: {project_path}",
282
+ f"Tool events: {event_count}",
283
+ f"Associated facts: {fact_count}",
284
+ "",
285
+ ]
286
+
287
+ # Tool usage breakdown.
288
+ if tool_rows:
289
+ tool_counter: Counter = Counter()
290
+ for r in tool_rows:
291
+ tool_counter[r.get("tool_name") or "unknown"] += 1
292
+ top_tools = tool_counter.most_common(_TOP_TOOLS)
293
+ lines.append("Tool usage:")
294
+ for tool, count in top_tools:
295
+ lines.append(f" {tool}: {count} event(s)")
296
+ if len(tool_counter) > _TOP_TOOLS:
297
+ lines.append(f" ... and {len(tool_counter) - _TOP_TOOLS} other tools.")
298
+ lines.append("")
299
+
300
+ # Top facts by importance.
301
+ if facts_rows:
302
+ lines.append("Key facts from project sessions:")
303
+ for f in facts_rows[:_TOP_FACTS]:
304
+ content = f.get("content", "")
305
+ if len(content) > _MAX_FACT_CHARS:
306
+ content = content[:_MAX_FACT_CHARS - 3] + "..."
307
+ lines.append(f" - {content}")
308
+ if fact_count > _TOP_FACTS:
309
+ lines.append(f" ... and {fact_count - _TOP_FACTS} more facts.")
310
+
311
+ return "\n".join(lines)
312
+
313
+
314
+ def _try_llm(
315
+ project_path: str,
316
+ tool_rows: list[dict],
317
+ facts_rows: list[dict],
318
+ config: object | None,
319
+ mode: str,
320
+ ) -> tuple[str | None, str]:
321
+ """Attempt LLM-based project summary. Returns (content, generated_by)."""
322
+ parts = project_path.rstrip("/").split("/")
323
+ display_name = "/".join(parts[-2:]) if len(parts) >= 2 else project_path
324
+ prompt = (
325
+ f"Write a concise project work log for '{display_name}' based on "
326
+ f"{len(tool_rows)} tool events and {len(facts_rows)} recorded facts."
327
+ )
328
+ if mode == "c":
329
+ result = _call_cloud_llm(prompt, tool_rows, facts_rows, config)
330
+ if result:
331
+ return result, GENERATED_BY_LLM_C
332
+ if mode in ("b", "c"):
333
+ result = _call_ollama(prompt, tool_rows, facts_rows, config)
334
+ if result:
335
+ return result, GENERATED_BY_LLM_B
336
+ return None, GENERATED_BY_EXTRACTIVE
337
+
338
+
339
+ def _call_ollama(
340
+ prompt: str,
341
+ tool_rows: list[dict],
342
+ facts_rows: list[dict],
343
+ config: object | None,
344
+ ) -> str | None:
345
+ """Mode B: call Ollama. Returns None on any failure."""
346
+ try:
347
+ import json
348
+ import urllib.request
349
+
350
+ api_base = "http://localhost:11434"
351
+ model = "llama3.2"
352
+ timeout = 30
353
+ if config and hasattr(config, "llm"):
354
+ api_base = getattr(config.llm, "api_base", api_base) or api_base
355
+ model = getattr(config.llm, "model", model) or model
356
+ timeout = (
357
+ getattr(config.llm, "timeout_seconds", None)
358
+ or getattr(config.llm, "timeout", None)
359
+ or timeout
360
+ )
361
+
362
+ tool_summary = ", ".join(
363
+ f"{r['tool_name']}({r.get('event_type', '')})"
364
+ for r in tool_rows[:10]
365
+ )
366
+ fact_texts = "\n".join(f"- {f['content']}" for f in facts_rows[:8])
367
+ full_prompt = (
368
+ f"{prompt}\n\n"
369
+ f"Tools used: {tool_summary}\n\n"
370
+ f"Key facts:\n{fact_texts}\n\n"
371
+ f"Respond in 3-5 sentences."
372
+ )
373
+ payload = json.dumps({
374
+ "model": model,
375
+ "prompt": full_prompt,
376
+ "stream": False,
377
+ "options": {"num_predict": 300},
378
+ }).encode()
379
+ req = urllib.request.Request(
380
+ f"{api_base}/api/generate",
381
+ data=payload,
382
+ headers={"Content-Type": "application/json"},
383
+ )
384
+ resp = urllib.request.urlopen(req, timeout=timeout)
385
+ data = json.loads(resp.read().decode())
386
+ text = data.get("response", "").strip()
387
+ return text if text and len(text) > 20 else None
388
+ except Exception as exc:
389
+ logger.debug("Ollama project work log failed: %s", exc)
390
+ return None
391
+
392
+
393
+ def _call_cloud_llm(
394
+ prompt: str,
395
+ tool_rows: list[dict],
396
+ facts_rows: list[dict],
397
+ config: object | None,
398
+ ) -> str | None:
399
+ """Mode C: call the configured cloud LLM. Returns None on any failure."""
400
+ if not config or not hasattr(config, "llm"):
401
+ return None
402
+ try:
403
+ from superlocalmemory.llm.backbone import LLMBackbone
404
+ llm = LLMBackbone(config.llm)
405
+ if not llm.is_available():
406
+ return None
407
+ tool_summary = ", ".join(
408
+ f"{r['tool_name']}({r.get('event_type', '')})"
409
+ for r in tool_rows[:10]
410
+ )
411
+ fact_texts = "\n".join(f"- {f['content']}" for f in facts_rows[:8])
412
+ full_prompt = (
413
+ f"{prompt}\n\nTools: {tool_summary}\n\nFacts:\n{fact_texts}"
414
+ )
415
+ text = llm.generate(
416
+ prompt=full_prompt,
417
+ system="You are a concise project activity summariser.",
418
+ max_tokens=300,
419
+ temperature=0.1,
420
+ )
421
+ return text.strip() if text and len(text.strip()) > 20 else None
422
+ except Exception as exc:
423
+ logger.debug("Cloud LLM project work log failed: %s", exc)
424
+ return None