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
@@ -31,6 +31,7 @@ from superlocalmemory.code_graph.models import (
31
31
  NodeKind,
32
32
  ParseResult,
33
33
  )
34
+ from superlocalmemory.code_graph.resolver import ImportResolver
34
35
  from superlocalmemory.storage.models import _new_id
35
36
 
36
37
  logger = logging.getLogger(__name__)
@@ -65,6 +66,140 @@ def _sha256(data: bytes) -> str:
65
66
  return hashlib.sha256(data).hexdigest()
66
67
 
67
68
 
69
+ def _assign_edge_sources(
70
+ nodes: list[GraphNode],
71
+ edges: list[GraphEdge],
72
+ ) -> list[GraphEdge]:
73
+ """Replace ``__unresolved__`` source_node_ids on CALLS edges.
74
+
75
+ Locates the innermost function/method whose line-range contains the call
76
+ site. Falls back to the file-level node when no function wraps the call
77
+ (e.g. top-level script code). IMPORTS and other non-CALLS edges whose
78
+ source is still ``__unresolved__`` are dropped — they cannot be resolved
79
+ without storing the module path in the edge, which is a separate defect.
80
+
81
+ Returns a new list of edges.
82
+ """
83
+ # ── build per-file line-range index ────────────────────────────────
84
+ # {file_path: [(line_start, line_end, node_id), ...]}
85
+ # Sorted by range size ascending so the NARROWEST (innermost) match wins.
86
+ file_ranges: dict[str, list[tuple[int, int, str]]] = {}
87
+ file_node_ids: dict[str, str] = {} # file_path → FILE node_id
88
+
89
+ for node in nodes:
90
+ if node.kind == NodeKind.FILE:
91
+ file_node_ids[node.file_path] = node.node_id
92
+ elif node.kind in (NodeKind.FUNCTION, NodeKind.METHOD):
93
+ fp = node.file_path
94
+ file_ranges.setdefault(fp, []).append(
95
+ (node.line_start, node.line_end, node.node_id)
96
+ )
97
+
98
+ for fp in file_ranges:
99
+ file_ranges[fp].sort(key=lambda t: t[1] - t[0]) # narrowest first
100
+
101
+ resolved: list[GraphEdge] = []
102
+ dropped = 0
103
+
104
+ for edge in edges:
105
+ if edge.source_node_id != "__unresolved__":
106
+ resolved.append(edge)
107
+ continue
108
+
109
+ if edge.kind != EdgeKind.CALLS:
110
+ # IMPORTS and other edges: source unknown — drop.
111
+ # Root cause: extract_imports() does not store the module path in
112
+ # extra_json, so targets are also unresolvable. Tracked as a
113
+ # separate pre-existing defect; fixing it here would require an
114
+ # extractor change beyond this PR scope.
115
+ dropped += 1
116
+ continue
117
+
118
+ # ── find innermost enclosing function ──────────────────────────
119
+ enclosing_id: str | None = None
120
+ for ls, le, nid in file_ranges.get(edge.file_path, []):
121
+ if ls <= edge.line <= le:
122
+ enclosing_id = nid
123
+ break # narrowest first → stop at first hit
124
+
125
+ # ── fallback: top-level call, use FILE node ─────────────────────
126
+ if enclosing_id is None:
127
+ enclosing_id = file_node_ids.get(edge.file_path)
128
+
129
+ if enclosing_id is None:
130
+ logger.debug(
131
+ "_assign_edge_sources: no source node for call at %s:%d — dropping",
132
+ edge.file_path, edge.line,
133
+ )
134
+ dropped += 1
135
+ continue
136
+
137
+ resolved.append(GraphEdge(
138
+ edge_id=edge.edge_id,
139
+ kind=edge.kind,
140
+ source_node_id=enclosing_id,
141
+ target_node_id=edge.target_node_id,
142
+ file_path=edge.file_path,
143
+ line=edge.line,
144
+ confidence=edge.confidence,
145
+ extra_json=edge.extra_json,
146
+ created_at=edge.created_at,
147
+ updated_at=edge.updated_at,
148
+ ))
149
+
150
+ if dropped:
151
+ logger.debug(
152
+ "_assign_edge_sources: dropped %d edge(s) with unresolvable source",
153
+ dropped,
154
+ )
155
+
156
+ return resolved
157
+
158
+
159
+ def _clean_and_resolve_edges(
160
+ nodes: list[GraphNode],
161
+ edges: list[GraphEdge],
162
+ import_maps_by_file: dict[str, dict[str, tuple[str, str]]],
163
+ repo_root: Path,
164
+ config: CodeGraphConfig,
165
+ ) -> list[GraphEdge]:
166
+ """Full resolution pipeline applied after parse_all collects all nodes.
167
+
168
+ 1. Assign real source_node_ids to CALLS edges (line-range lookup).
169
+ 2. Drop IMPORTS edges whose endpoints are still ``__unresolved__``.
170
+ 3. Run ImportResolver.resolve_call_targets to replace ``__call__<name>``
171
+ targets with real node_ids or drop the edge (external calls).
172
+ 4. Final defensive pass: drop any edge whose endpoints are still absent
173
+ from the full node set (belt-and-braces, should be a no-op after 1-3).
174
+
175
+ Returns a clean edge list safe to hand to GraphStore.
176
+ """
177
+ # Step 1: fix sources
178
+ edges = _assign_edge_sources(nodes, edges)
179
+
180
+ # Step 2: resolve targets
181
+ resolver = ImportResolver(repo_root, config)
182
+ edges = resolver.resolve_call_targets(nodes, edges, import_maps_by_file)
183
+
184
+ # Step 3: final drop of any remaining stragglers
185
+ valid_ids = {n.node_id for n in nodes}
186
+ clean: list[GraphEdge] = []
187
+ stray = 0
188
+ for e in edges:
189
+ if e.source_node_id in valid_ids and e.target_node_id in valid_ids:
190
+ clean.append(e)
191
+ else:
192
+ stray += 1
193
+
194
+ if stray:
195
+ logger.debug(
196
+ "_clean_and_resolve_edges: final pass dropped %d stray edge(s)",
197
+ stray,
198
+ )
199
+
200
+ return clean
201
+
202
+
68
203
  def _make_qualified_name(
69
204
  file_path: str, name: str, parent_name: str | None
70
205
  ) -> str:
@@ -111,11 +246,12 @@ def _parse_file_standalone(
111
246
  else:
112
247
  return {"nodes": [], "edges": [], "errors": [f"Unsupported language: {language}"]}
113
248
 
114
- nodes, edges = extractor.extract()
249
+ nodes, edges, import_map = extractor.extract_with_import_map()
115
250
 
116
251
  return {
117
252
  "nodes": nodes,
118
253
  "edges": edges,
254
+ "import_map": import_map,
119
255
  "errors": [],
120
256
  }
121
257
  except Exception as exc:
@@ -191,7 +327,7 @@ class CodeParser:
191
327
  file_path: Path,
192
328
  source_bytes: bytes,
193
329
  language: str,
194
- ) -> tuple[list[GraphNode], list[GraphEdge]]:
330
+ ) -> tuple[list[GraphNode], list[GraphEdge], dict[str, tuple[str, str]]]:
195
331
  """Parse a single file and return extracted nodes and edges.
196
332
 
197
333
  Raises UnsupportedLanguageError if language is not supported.
@@ -235,7 +371,7 @@ class CodeParser:
235
371
  from superlocalmemory.code_graph.extractors.typescript import TypeScriptExtractor
236
372
  extractor = TypeScriptExtractor(root, source_bytes, file_path_str, self._config)
237
373
 
238
- extracted_nodes, extracted_edges = extractor.extract()
374
+ extracted_nodes, extracted_edges, file_import_map = extractor.extract_with_import_map()
239
375
 
240
376
  # Check if test file
241
377
  is_test = _is_test_file(file_path_str, self._config)
@@ -277,7 +413,7 @@ class CodeParser:
277
413
 
278
414
  all_edges = extracted_edges + contains_edges + tested_by_edges
279
415
 
280
- return all_nodes, all_edges
416
+ return all_nodes, all_edges, file_import_map
281
417
 
282
418
  def parse_all(
283
419
  self, repo_root: Path
@@ -293,6 +429,11 @@ class CodeParser:
293
429
  all_nodes: list[GraphNode] = []
294
430
  all_edges: list[GraphEdge] = []
295
431
  all_file_records: list[FileRecord] = []
432
+ # Keyed by relative file_path string; populated by the parallel path
433
+ # (the sequential path goes through parse_file which does not expose
434
+ # the per-file import map, so it stays empty — resolver Strategy 2/3
435
+ # still works without it).
436
+ import_maps_by_file: dict[str, dict[str, tuple[str, str]]] = {}
296
437
 
297
438
  # Read files and prepare tasks
298
439
  tasks: list[tuple[Path, bytes, str]] = []
@@ -313,10 +454,16 @@ class CodeParser:
313
454
 
314
455
  # Parse with ProcessPoolExecutor for parallel CPU-bound work
315
456
  # For small numbers of files, run sequentially to avoid overhead
316
- if len(tasks) <= 2:
457
+ # Use the sequential (in-process) path for small repos or when the
458
+ # caller explicitly requests single-worker execution. parallel_workers=1
459
+ # is the only way to guarantee in-process parsing (no subprocess spawn).
460
+ if len(tasks) <= 2 or self._config.parallel_workers == 1:
317
461
  for rel_path, source_bytes, language in tasks:
318
462
  try:
319
- nodes, edges = self.parse_file(rel_path, source_bytes, language)
463
+ nodes, edges, file_import_map = self.parse_file(
464
+ rel_path, source_bytes, language
465
+ )
466
+ import_maps_by_file[str(rel_path)] = file_import_map
320
467
  all_nodes.extend(nodes)
321
468
  all_edges.extend(edges)
322
469
  all_file_records.append(FileRecord(
@@ -330,105 +477,138 @@ class CodeParser:
330
477
  ))
331
478
  except Exception as exc:
332
479
  logger.warning("Failed to parse %s: %s", rel_path, exc)
333
- return all_nodes, all_edges, all_file_records
480
+ # import_maps_by_file is now populated on the sequential path:
481
+ # parse_file returns the per-file import map (Strategy 1 works
482
+ # for small repos / parallel_workers=1 runs too).
483
+ else:
484
+ # Parallel execution
485
+ parse_failures = 0 # count of futures that raised (worker crashes)
486
+ config_dict = {
487
+ field_name: getattr(self._config, field_name)
488
+ for field_name in CodeGraphConfig.__dataclass_fields__
489
+ if not isinstance(getattr(self._config, field_name), Path)
490
+ }
491
+ # Convert Path fields to strings
492
+ config_dict["repo_root"] = str(self._config.repo_root)
493
+
494
+ workers = min(self._config.parallel_workers, len(tasks))
495
+ with ProcessPoolExecutor(max_workers=workers) as executor:
496
+ future_map = {}
497
+ for rel_path, source_bytes, language in tasks:
498
+ future = executor.submit(
499
+ _parse_file_standalone,
500
+ str(rel_path),
501
+ source_bytes,
502
+ language,
503
+ config_dict,
504
+ )
505
+ future_map[future] = (rel_path, source_bytes, language)
506
+
507
+ for future in as_completed(future_map):
508
+ rel_path, source_bytes, language = future_map[future]
509
+ try:
510
+ result = future.result(timeout=self._config.parse_timeout_seconds)
511
+ except Exception as exc:
512
+ logger.warning("Parse failed for %s: %s", rel_path, exc)
513
+ parse_failures += 1
514
+ continue
334
515
 
335
- # Parallel execution
336
- config_dict = {
337
- field_name: getattr(self._config, field_name)
338
- for field_name in CodeGraphConfig.__dataclass_fields__
339
- if not isinstance(getattr(self._config, field_name), Path)
340
- }
341
- # Convert Path fields to strings
342
- config_dict["repo_root"] = str(self._config.repo_root)
516
+ if result["errors"]:
517
+ for err in result["errors"]:
518
+ logger.warning("Parse error in %s: %s", rel_path, err)
519
+ if not result["nodes"]:
520
+ continue
521
+
522
+ file_nodes = result["nodes"]
523
+ file_edges = result["edges"]
524
+ # Collect per-file import map for Strategy 1 resolution.
525
+ import_maps_by_file[str(rel_path)] = result.get("import_map", {})
526
+
527
+ # Build the full parse result with file node and CONTAINS edges
528
+ file_path_str = str(rel_path)
529
+ content_hash = _sha256(source_bytes)
530
+
531
+ file_node = GraphNode(
532
+ node_id=_new_id(),
533
+ kind=NodeKind.FILE,
534
+ name=rel_path.name,
535
+ qualified_name=file_path_str,
536
+ file_path=file_path_str,
537
+ line_start=0,
538
+ line_end=0,
539
+ language=language,
540
+ content_hash=content_hash,
541
+ )
343
542
 
344
- workers = min(self._config.parallel_workers, len(tasks))
345
- with ProcessPoolExecutor(max_workers=workers) as executor:
346
- future_map = {}
347
- for rel_path, source_bytes, language in tasks:
348
- future = executor.submit(
349
- _parse_file_standalone,
350
- str(rel_path),
351
- source_bytes,
352
- language,
353
- config_dict,
354
- )
355
- future_map[future] = (rel_path, source_bytes, language)
356
-
357
- for future in as_completed(future_map):
358
- rel_path, source_bytes, language = future_map[future]
359
- try:
360
- result = future.result(timeout=self._config.parse_timeout_seconds)
361
- except Exception as exc:
362
- logger.warning("Parse failed for %s: %s", rel_path, exc)
363
- continue
543
+ is_test = _is_test_file(file_path_str, self._config)
544
+ if is_test:
545
+ marked: list[GraphNode] = []
546
+ for n in file_nodes:
547
+ if n.kind in (NodeKind.FUNCTION, NodeKind.METHOD):
548
+ marked.append(GraphNode(
549
+ node_id=n.node_id, kind=n.kind, name=n.name,
550
+ qualified_name=n.qualified_name,
551
+ file_path=n.file_path,
552
+ line_start=n.line_start, line_end=n.line_end,
553
+ language=n.language, parent_name=n.parent_name,
554
+ signature=n.signature, docstring=n.docstring,
555
+ is_test=True, content_hash=n.content_hash,
556
+ extra_json=n.extra_json,
557
+ ))
558
+ else:
559
+ marked.append(n)
560
+ file_nodes = marked
561
+
562
+ contains = self._generate_contains_edges(file_node, file_nodes)
563
+ tested_by = self._generate_tested_by_edges(file_nodes, file_edges)
564
+
565
+ final_nodes = [file_node] + file_nodes
566
+ final_edges = file_edges + contains + tested_by
567
+
568
+ all_nodes.extend(final_nodes)
569
+ all_edges.extend(final_edges)
570
+
571
+ try:
572
+ mtime = (repo_root / rel_path).stat().st_mtime
573
+ except OSError:
574
+ mtime = 0.0
364
575
 
365
- if result["errors"]:
366
- for err in result["errors"]:
367
- logger.warning("Parse error in %s: %s", rel_path, err)
368
- if not result["nodes"]:
369
- continue
576
+ all_file_records.append(FileRecord(
577
+ file_path=file_path_str,
578
+ content_hash=content_hash,
579
+ mtime=mtime,
580
+ language=language,
581
+ node_count=len(final_nodes),
582
+ edge_count=len(final_edges),
583
+ last_indexed=time.time(),
584
+ ))
370
585
 
371
- file_nodes = result["nodes"]
372
- file_edges = result["edges"]
373
-
374
- # Build the full parse result with file node and CONTAINS edges
375
- file_path_str = str(rel_path)
376
- content_hash = _sha256(source_bytes)
377
-
378
- file_node = GraphNode(
379
- node_id=_new_id(),
380
- kind=NodeKind.FILE,
381
- name=rel_path.name,
382
- qualified_name=file_path_str,
383
- file_path=file_path_str,
384
- line_start=0,
385
- line_end=0,
386
- language=language,
387
- content_hash=content_hash,
388
- )
389
-
390
- is_test = _is_test_file(file_path_str, self._config)
391
- if is_test:
392
- marked: list[GraphNode] = []
393
- for n in file_nodes:
394
- if n.kind in (NodeKind.FUNCTION, NodeKind.METHOD):
395
- marked.append(GraphNode(
396
- node_id=n.node_id, kind=n.kind, name=n.name,
397
- qualified_name=n.qualified_name,
398
- file_path=n.file_path,
399
- line_start=n.line_start, line_end=n.line_end,
400
- language=n.language, parent_name=n.parent_name,
401
- signature=n.signature, docstring=n.docstring,
402
- is_test=True, content_hash=n.content_hash,
403
- extra_json=n.extra_json,
404
- ))
405
- else:
406
- marked.append(n)
407
- file_nodes = marked
408
-
409
- contains = self._generate_contains_edges(file_node, file_nodes)
410
- tested_by = self._generate_tested_by_edges(file_nodes, file_edges)
411
-
412
- final_nodes = [file_node] + file_nodes
413
- final_edges = file_edges + contains + tested_by
414
-
415
- all_nodes.extend(final_nodes)
416
- all_edges.extend(final_edges)
586
+ # ── Fail-fast: majority pool failure ──────────────────────────────
587
+ # >50% failure rate means worker processes were killed (OOM, missing
588
+ # deps in subprocess env) — NOT a legitimate empty repo. Returning
589
+ # silently would let build_code_graph report success with zero nodes,
590
+ # which is worse than the original FK crash.
591
+ # Threshold rationale: ≥50% is a clear systemic failure; it allows
592
+ # for a tail of legitimately-malformed files without triggering on
593
+ # ordinary parse errors.
594
+ if parse_failures > 0 and len(tasks) > 0:
595
+ failure_rate = parse_failures / len(tasks)
596
+ if failure_rate > 0.50:
597
+ raise RuntimeError(
598
+ f"Parsing aborted: {parse_failures}/{len(tasks)} files "
599
+ f"failed ({failure_rate:.0%}). Likely cause: process pool "
600
+ f"worker crash or missing tree-sitter grammars in subprocess. "
601
+ f"Set CodeGraphConfig(parallel_workers=1) for in-process parsing."
602
+ )
417
603
 
418
- try:
419
- mtime = (repo_root / rel_path).stat().st_mtime
420
- except OSError:
421
- mtime = 0.0
422
-
423
- all_file_records.append(FileRecord(
424
- file_path=file_path_str,
425
- content_hash=content_hash,
426
- mtime=mtime,
427
- language=language,
428
- node_count=len(final_nodes),
429
- edge_count=len(final_edges),
430
- last_indexed=time.time(),
431
- ))
604
+ # ── Resolution pipeline ────────────────────────────────────────────────
605
+ # Resolve placeholder edge endpoints before returning. Ensures that
606
+ # every edge exiting parse_all has both endpoints in the returned node
607
+ # set, so GraphStore never encounters a FOREIGN KEY constraint failure.
608
+ # This is Fix A: wiring resolver.py into the parse pipeline.
609
+ all_edges = _clean_and_resolve_edges(
610
+ all_nodes, all_edges, import_maps_by_file, repo_root, self._config
611
+ )
432
612
 
433
613
  return all_nodes, all_edges, all_file_records
434
614