nexo-brain 7.23.2 → 7.23.4

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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -1
  3. package/package.json +1 -1
  4. package/scripts/sync_release_artifacts.py +28 -0
  5. package/src/auto_update.py +25 -47
  6. package/src/automation_reconciler.py +383 -0
  7. package/src/automation_supervisor.py +86 -9
  8. package/src/backup_retention.py +70 -0
  9. package/src/cli.py +55 -2
  10. package/src/cognitive/_core.py +4 -3
  11. package/src/cognitive_paths.py +194 -0
  12. package/src/dashboard/app.py +2 -1
  13. package/src/db/_episodic.py +85 -7
  14. package/src/db/_schema.py +81 -0
  15. package/src/db/_skills.py +3 -3
  16. package/src/disk_recovery/__init__.py +11 -0
  17. package/src/disk_recovery/handlers/__init__.py +1 -0
  18. package/src/disk_recovery/handlers/common.py +37 -0
  19. package/src/disk_recovery/handlers/macos.py +39 -0
  20. package/src/disk_recovery/handlers/windows.py +49 -0
  21. package/src/disk_recovery/registry.py +135 -0
  22. package/src/doctor/providers/boot.py +115 -15
  23. package/src/kg_populate.py +2 -5
  24. package/src/paths.py +321 -5
  25. package/src/plugins/update.py +14 -36
  26. package/src/pre_answer_router.py +21 -0
  27. package/src/runtime_service.py +30 -3
  28. package/src/runtime_versioning.py +272 -10
  29. package/src/script_registry.py +3 -2
  30. package/src/scripts/backfill_task_owner.py +10 -4
  31. package/src/scripts/deep-sleep/apply_findings.py +2 -5
  32. package/src/scripts/deep-sleep/collect.py +2 -5
  33. package/src/scripts/nexo-cognitive-decay.py +2 -1
  34. package/src/scripts/nexo-daily-self-audit.py +36 -10
  35. package/src/scripts/nexo-followup-runner.py +1 -1
  36. package/src/scripts/nexo-immune.py +2 -1
  37. package/src/scripts/nexo-migrate.py +2 -3
  38. package/src/scripts/post_disk_recovery_sweep.py +75 -0
  39. package/src/scripts/prune_runtime_backups.py +78 -11
  40. package/src/server.py +13 -1
  41. package/src/storage_router.py +2 -3
  42. package/src/support_snapshot.py +25 -0
  43. package/src/transcript_index.py +234 -0
  44. package/src/transcript_utils.py +31 -8
  45. package/src/user_data_portability.py +2 -3
  46. package/tool-enforcement-map.json +15 -0
@@ -318,6 +318,61 @@ def cleanup_old_diaries(retention_days: int = 180) -> int:
318
318
  return cursor.rowcount
319
319
 
320
320
 
321
+ def _diary_quality_tier(source: str = "", summary: str = "", mental_state: str = "") -> str:
322
+ normalized_source = (source or "").strip().lower()
323
+ blob = f"{summary or ''} {mental_state or ''}".lower()
324
+ if normalized_source in {"human", "operator", "manual"}:
325
+ return "human_authored"
326
+ if normalized_source == "auto-close":
327
+ if "0 heartbeats" in blob or "minimal diary" in blob or "[auto-" in blob:
328
+ return "auto_close_minimal"
329
+ return "agent_authored"
330
+ if normalized_source in {"cron", "automation"} or "[auto-" in blob:
331
+ return "fallback_minimal"
332
+ return "agent_authored"
333
+
334
+
335
+ def _diary_quality_score(
336
+ *,
337
+ summary: str = "",
338
+ decisions: str = "",
339
+ pending: str = "",
340
+ context_next: str = "",
341
+ self_critique: str = "",
342
+ mental_state: str = "",
343
+ tier: str = "",
344
+ ) -> int:
345
+ score = 0
346
+ if (summary or "").strip():
347
+ score += 25
348
+ if (decisions or "").strip():
349
+ score += 20
350
+ if (pending or "").strip():
351
+ score += 15
352
+ if (context_next or "").strip():
353
+ score += 20
354
+ if (self_critique or "").strip():
355
+ score += 20
356
+ if (mental_state or "").strip():
357
+ score += 5
358
+ if tier in {"auto_close_minimal", "fallback_minimal"}:
359
+ score = max(0, score - 40)
360
+ if tier == "human_authored":
361
+ score += 10
362
+ return min(score, 100)
363
+
364
+
365
+ def _diary_order_sql() -> str:
366
+ return (
367
+ "CASE quality_tier "
368
+ "WHEN 'human_authored' THEN 0 "
369
+ "WHEN 'agent_authored' THEN 1 "
370
+ "WHEN 'auto_close_minimal' THEN 2 "
371
+ "WHEN 'fallback_minimal' THEN 3 "
372
+ "ELSE 4 END, quality_score DESC, created_at DESC"
373
+ )
374
+
375
+
321
376
  def write_session_diary(session_id: str, decisions: str, summary: str,
322
377
  discarded: str = '', pending: str = '',
323
378
  context_next: str = '', mental_state: str = '',
@@ -326,10 +381,34 @@ def write_session_diary(session_id: str, decisions: str, summary: str,
326
381
  """Write a session diary entry with mental state and self-critique for continuity."""
327
382
  conn = get_db()
328
383
  cleanup_old_diaries()
384
+ quality_tier = _diary_quality_tier(source=source, summary=summary, mental_state=mental_state)
385
+ quality_score = _diary_quality_score(
386
+ summary=summary,
387
+ decisions=decisions,
388
+ pending=pending,
389
+ context_next=context_next,
390
+ self_critique=self_critique,
391
+ mental_state=mental_state,
392
+ tier=quality_tier,
393
+ )
329
394
  cursor = conn.execute(
330
- "INSERT INTO session_diary (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source) "
331
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
332
- (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source)
395
+ "INSERT INTO session_diary (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source, quality_tier, quality_score) "
396
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
397
+ (
398
+ session_id,
399
+ decisions,
400
+ discarded,
401
+ pending,
402
+ context_next,
403
+ mental_state,
404
+ summary,
405
+ domain,
406
+ user_signals,
407
+ self_critique,
408
+ source,
409
+ quality_tier,
410
+ quality_score,
411
+ )
333
412
  )
334
413
  conn.commit()
335
414
  did = cursor.lastrowid
@@ -601,19 +680,19 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
601
680
 
602
681
  if session_id:
603
682
  rows = conn.execute(
604
- f"SELECT * FROM session_diary WHERE session_id = ?{domain_clause} ORDER BY created_at DESC",
683
+ f"SELECT * FROM session_diary WHERE session_id = ?{domain_clause} ORDER BY {_diary_order_sql()}",
605
684
  (session_id,) + domain_params
606
685
  ).fetchall()
607
686
  elif last_day:
608
687
  rows = conn.execute(
609
688
  f"SELECT * FROM session_diary "
610
689
  f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
611
- f"ORDER BY created_at DESC",
690
+ f"ORDER BY {_diary_order_sql()}",
612
691
  domain_params
613
692
  ).fetchall()
614
693
  else:
615
694
  rows = conn.execute(
616
- f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY created_at DESC LIMIT ?",
695
+ f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY {_diary_order_sql()} LIMIT ?",
617
696
  domain_params + (last_n,)
618
697
  ).fetchall()
619
698
  return [dict(r) for r in rows]
@@ -759,4 +838,3 @@ def recall(query: str, days: int = 30) -> list[dict]:
759
838
 
760
839
  results.sort(key=lambda r: r.get('created_at', ''), reverse=True)
761
840
  return results[:20]
762
-
package/src/db/_schema.py CHANGED
@@ -2002,6 +2002,84 @@ def _m64_local_context_live_dirs(conn):
2002
2002
  )
2003
2003
 
2004
2004
 
2005
+ def _backfill_diary_quality(conn):
2006
+ for table in ("session_diary", "diary_archive"):
2007
+ conn.execute(f"""
2008
+ UPDATE {table}
2009
+ SET quality_tier = CASE
2010
+ WHEN source = 'auto-close' THEN 'auto_close_minimal'
2011
+ WHEN source IN ('cron', 'automation') OR COALESCE(summary, '') LIKE '[AUTO-%' THEN 'fallback_minimal'
2012
+ ELSE 'agent_authored'
2013
+ END
2014
+ WHERE quality_tier IS NULL
2015
+ OR quality_tier = ''
2016
+ OR quality_tier = 'agent_authored'
2017
+ """)
2018
+ conn.execute(f"""
2019
+ UPDATE {table}
2020
+ SET quality_score =
2021
+ CASE WHEN COALESCE(summary, '') != '' THEN 25 ELSE 0 END +
2022
+ CASE WHEN COALESCE(decisions, '') != '' THEN 20 ELSE 0 END +
2023
+ CASE WHEN COALESCE(pending, '') != '' THEN 15 ELSE 0 END +
2024
+ CASE WHEN COALESCE(context_next, '') != '' THEN 20 ELSE 0 END +
2025
+ CASE WHEN COALESCE(self_critique, '') != '' THEN 20 ELSE 0 END
2026
+ WHERE quality_score IS NULL OR quality_score = 0
2027
+ """)
2028
+
2029
+
2030
+ def _m65_diary_quality(conn):
2031
+ _m4_session_diary_columns(conn)
2032
+ _m7_diary_source_and_draft(conn)
2033
+ _m10_diary_archive(conn)
2034
+ _migrate_add_column(conn, "session_diary", "quality_tier", "TEXT DEFAULT 'agent_authored'")
2035
+ _migrate_add_column(conn, "session_diary", "quality_score", "INTEGER DEFAULT 0")
2036
+ _migrate_add_column(conn, "diary_archive", "quality_tier", "TEXT DEFAULT 'agent_authored'")
2037
+ _migrate_add_column(conn, "diary_archive", "quality_score", "INTEGER DEFAULT 0")
2038
+ _backfill_diary_quality(conn)
2039
+ _migrate_add_index(conn, "idx_session_diary_quality", "session_diary", "quality_tier, quality_score, created_at")
2040
+ _migrate_add_index(conn, "idx_diary_archive_quality", "diary_archive", "quality_tier, quality_score, created_at")
2041
+
2042
+
2043
+ def _m66_transcript_index(conn):
2044
+ conn.execute("""
2045
+ CREATE TABLE IF NOT EXISTS transcript_index (
2046
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2047
+ source_client TEXT NOT NULL,
2048
+ conversation_id TEXT DEFAULT '',
2049
+ session_id TEXT DEFAULT '',
2050
+ message_count INTEGER DEFAULT 0,
2051
+ user_message_count INTEGER DEFAULT 0,
2052
+ first_user_at TEXT DEFAULT '',
2053
+ last_user_at TEXT DEFAULT '',
2054
+ path_ref TEXT NOT NULL,
2055
+ display_name TEXT DEFAULT '',
2056
+ indexed_at TEXT DEFAULT (datetime('now')),
2057
+ modified_at TEXT DEFAULT '',
2058
+ content_hash TEXT NOT NULL,
2059
+ sanitized_summary TEXT DEFAULT '',
2060
+ metadata_json TEXT DEFAULT '{}',
2061
+ UNIQUE(source_client, path_ref)
2062
+ )
2063
+ """)
2064
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_transcript_index_client_modified ON transcript_index(source_client, modified_at)")
2065
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_transcript_index_session ON transcript_index(session_id)")
2066
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_transcript_index_conversation ON transcript_index(conversation_id)")
2067
+
2068
+
2069
+ def _m67_diary_quality_backfill_repair(conn):
2070
+ """Repair DBs that already ran the original m65 default-only backfill."""
2071
+ _m4_session_diary_columns(conn)
2072
+ _m7_diary_source_and_draft(conn)
2073
+ _m10_diary_archive(conn)
2074
+ _migrate_add_column(conn, "session_diary", "quality_tier", "TEXT DEFAULT 'agent_authored'")
2075
+ _migrate_add_column(conn, "session_diary", "quality_score", "INTEGER DEFAULT 0")
2076
+ _migrate_add_column(conn, "diary_archive", "quality_tier", "TEXT DEFAULT 'agent_authored'")
2077
+ _migrate_add_column(conn, "diary_archive", "quality_score", "INTEGER DEFAULT 0")
2078
+ _backfill_diary_quality(conn)
2079
+ _migrate_add_index(conn, "idx_session_diary_quality", "session_diary", "quality_tier, quality_score, created_at")
2080
+ _migrate_add_index(conn, "idx_diary_archive_quality", "diary_archive", "quality_tier, quality_score, created_at")
2081
+
2082
+
2005
2083
  MIGRATIONS = [
2006
2084
  (1, "learnings_columns", _m1_learnings_columns),
2007
2085
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -2067,6 +2145,9 @@ MIGRATIONS = [
2067
2145
  (62, "memory_observations_fts_trigger_fix", _m62_memory_observations_fts_trigger_fix),
2068
2146
  (63, "local_context_layer", _m63_local_context_layer),
2069
2147
  (64, "local_context_live_dirs", _m64_local_context_live_dirs),
2148
+ (65, "diary_quality", _m65_diary_quality),
2149
+ (66, "transcript_index", _m66_transcript_index),
2150
+ (67, "diary_quality_backfill_repair", _m67_diary_quality_backfill_repair),
2070
2151
  ]
2071
2152
 
2072
2153
 
package/src/db/_skills.py CHANGED
@@ -568,9 +568,7 @@ def retire_superseded_personal_skills(*, dry_run: bool = False) -> dict:
568
568
  continue
569
569
 
570
570
  if backup_root is None:
571
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H%M%S")
572
- backup_root = paths.backups_dir() / f"retired-personal-skills-{timestamp}"
573
- backup_root.mkdir(parents=True, exist_ok=True)
571
+ backup_root = paths.create_backup_dir("retired-personal-skills")
574
572
  target = backup_root / child.name
575
573
  suffix = 2
576
574
  while target.exists():
@@ -588,6 +586,8 @@ def retire_superseded_personal_skills(*, dry_run: bool = False) -> dict:
588
586
  })
589
587
  except Exception as exc:
590
588
  report["errors"].append({"path": str(child), "error": str(exc)})
589
+ if backup_root is not None:
590
+ paths.finalize_backup_snapshot(backup_root)
591
591
  return report
592
592
 
593
593
 
@@ -0,0 +1,11 @@
1
+ """Post-disk-recovery self-heal hooks."""
2
+
3
+ from .registry import RecoveryContext, RecoveryRegistry, RecoveryResult, build_default_registry, run_sweep
4
+
5
+ __all__ = [
6
+ "RecoveryContext",
7
+ "RecoveryRegistry",
8
+ "RecoveryResult",
9
+ "build_default_registry",
10
+ "run_sweep",
11
+ ]
@@ -0,0 +1 @@
1
+ """Platform handlers for disk recovery sweeps."""
@@ -0,0 +1,37 @@
1
+ """Common app restart handlers used after disk pressure is relieved."""
2
+ from __future__ import annotations
3
+
4
+ from disk_recovery.registry import RecoveryContext, RecoveryRegistry, RecoveryResult
5
+
6
+
7
+ def _has_process(ctx: RecoveryContext, *needles: str) -> bool:
8
+ lowered = {name.lower() for name in ctx.processes}
9
+ return any(needle.lower() in lowered for needle in needles)
10
+
11
+
12
+ def _commands_for(ctx: RecoveryContext, app: str) -> list[list[str]]:
13
+ if ctx.platform == "darwin":
14
+ return [["killall", app], ["open", "-a", app]]
15
+ exe = f"{app}.exe" if not app.lower().endswith(".exe") else app
16
+ return [
17
+ ["taskkill", "/IM", exe, "/F"],
18
+ ["powershell", "-NoProfile", "-Command", f"Start-Process '{app}'"],
19
+ ]
20
+
21
+
22
+ def _restart_if_running(ctx: RecoveryContext, app: str, *process_names: str) -> RecoveryResult:
23
+ if not _has_process(ctx, *process_names):
24
+ return RecoveryResult(app=app, touched=False, detail="not_running")
25
+ commands = _commands_for(ctx, app)
26
+ errors: list[str] = []
27
+ for command in commands:
28
+ result = ctx.run(command)
29
+ if not result.get("ok"):
30
+ errors.append(str(result.get("error") or result.get("stderr") or "command_failed"))
31
+ return RecoveryResult(app=app, touched=True, commands=commands, errors=errors)
32
+
33
+
34
+ def register_common_handlers(registry: RecoveryRegistry) -> None:
35
+ registry.register("dropbox", {"darwin", "windows"}, lambda ctx: _restart_if_running(ctx, "Dropbox", "dropbox", "dropbox.exe"))
36
+ registry.register("google_drive", {"darwin", "windows"}, lambda ctx: _restart_if_running(ctx, "Google Drive", "google drive", "googledrivefs", "googledrivefs.exe"))
37
+ registry.register("slack", {"darwin", "windows"}, lambda ctx: _restart_if_running(ctx, "Slack", "slack", "slack.exe"))
@@ -0,0 +1,39 @@
1
+ """macOS sync handlers for post-disk-recovery sweep."""
2
+ from __future__ import annotations
3
+
4
+ from disk_recovery.registry import RecoveryContext, RecoveryRegistry, RecoveryResult
5
+
6
+
7
+ def _run_commands(ctx: RecoveryContext, app: str, commands: list[list[str]]) -> RecoveryResult:
8
+ errors: list[str] = []
9
+ for command in commands:
10
+ result = ctx.run(command)
11
+ if not result.get("ok"):
12
+ errors.append(str(result.get("error") or result.get("stderr") or "command_failed"))
13
+ return RecoveryResult(app=app, touched=True, commands=commands, errors=errors)
14
+
15
+
16
+ def calendar_mail_handler(ctx: RecoveryContext) -> RecoveryResult:
17
+ commands = [
18
+ ["killall", "CalendarAgent"],
19
+ ["killall", "Calendar"],
20
+ ["osascript", "-e", 'tell application "Mail" to check for new mail'],
21
+ ]
22
+ return _run_commands(ctx, "macos_calendar_mail", commands)
23
+
24
+
25
+ def icloud_handler(ctx: RecoveryContext) -> RecoveryResult:
26
+ commands: list[list[str]] = []
27
+ processes = {name.lower() for name in ctx.processes}
28
+ if "cloudd" in processes:
29
+ commands.append(["killall", "cloudd"])
30
+ if "bird" in processes:
31
+ commands.append(["killall", "bird"])
32
+ if not commands:
33
+ return RecoveryResult(app="icloud_drive", touched=False, detail="not_running")
34
+ return _run_commands(ctx, "icloud_drive", commands)
35
+
36
+
37
+ def register_macos_handlers(registry: RecoveryRegistry) -> None:
38
+ registry.register("macos_calendar_mail", {"darwin"}, calendar_mail_handler)
39
+ registry.register("icloud_drive", {"darwin"}, icloud_handler)
@@ -0,0 +1,49 @@
1
+ """Windows sync handlers for post-disk-recovery sweep."""
2
+ from __future__ import annotations
3
+
4
+ from disk_recovery.registry import RecoveryContext, RecoveryRegistry, RecoveryResult
5
+
6
+
7
+ def _run_commands(ctx: RecoveryContext, app: str, commands: list[list[str]]) -> RecoveryResult:
8
+ errors: list[str] = []
9
+ for command in commands:
10
+ result = ctx.run(command)
11
+ if not result.get("ok"):
12
+ errors.append(str(result.get("error") or result.get("stderr") or "command_failed"))
13
+ return RecoveryResult(app=app, touched=True, commands=commands, errors=errors)
14
+
15
+
16
+ def onesync_handler(ctx: RecoveryContext) -> RecoveryResult:
17
+ commands = [
18
+ ["powershell", "-NoProfile", "-Command", "Get-Service OneSyncSvc* | Restart-Service -Force"],
19
+ ]
20
+ return _run_commands(ctx, "windows_onesync", commands)
21
+
22
+
23
+ def outlook_handler(ctx: RecoveryContext) -> RecoveryResult:
24
+ commands = [[
25
+ "powershell",
26
+ "-NoProfile",
27
+ "-Command",
28
+ "$o = New-Object -ComObject Outlook.Application; $o.Session.SendAndReceive($false)",
29
+ ]]
30
+ return _run_commands(ctx, "outlook_send_receive", commands)
31
+
32
+
33
+ def onedrive_handler(ctx: RecoveryContext) -> RecoveryResult:
34
+ processes = {name.lower() for name in ctx.processes}
35
+ if "onedrive.exe" not in processes and "onedrive" not in processes:
36
+ return RecoveryResult(app="onedrive", touched=False, detail="not_running")
37
+ local = ctx.env.get("LOCALAPPDATA", r"%LOCALAPPDATA%")
38
+ exe = local + r"\Microsoft\OneDrive\OneDrive.exe"
39
+ commands = [
40
+ [exe, "/shutdown"],
41
+ ["powershell", "-NoProfile", "-Command", f"Start-Process '{exe}'"],
42
+ ]
43
+ return _run_commands(ctx, "onedrive", commands)
44
+
45
+
46
+ def register_windows_handlers(registry: RecoveryRegistry) -> None:
47
+ registry.register("windows_onesync", {"windows"}, onesync_handler)
48
+ registry.register("outlook_send_receive", {"windows"}, outlook_handler)
49
+ registry.register("onedrive", {"windows"}, onedrive_handler)
@@ -0,0 +1,135 @@
1
+ """Extensible registry for post-disk-recovery app sync nudges."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import platform as platform_module
6
+ import subprocess
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import Callable
9
+
10
+
11
+ Runner = Callable[[list[str]], dict]
12
+ Handler = Callable[["RecoveryContext"], "RecoveryResult"]
13
+
14
+
15
+ @dataclass
16
+ class RecoveryContext:
17
+ platform: str
18
+ processes: set[str] = field(default_factory=set)
19
+ dry_run: bool = False
20
+ env: dict[str, str] = field(default_factory=lambda: dict(os.environ))
21
+ runner: Runner | None = None
22
+
23
+ def run(self, command: list[str]) -> dict:
24
+ if self.dry_run:
25
+ return {"ok": True, "dry_run": True, "command": command}
26
+ runner = self.runner or default_runner
27
+ return runner(command)
28
+
29
+
30
+ @dataclass
31
+ class RecoveryResult:
32
+ app: str
33
+ touched: bool
34
+ commands: list[list[str]] = field(default_factory=list)
35
+ errors: list[str] = field(default_factory=list)
36
+ detail: str = ""
37
+
38
+
39
+ def default_runner(command: list[str]) -> dict:
40
+ try:
41
+ proc = subprocess.run(command, capture_output=True, text=True, timeout=20)
42
+ return {
43
+ "ok": proc.returncode == 0,
44
+ "returncode": proc.returncode,
45
+ "stdout": proc.stdout[-1000:],
46
+ "stderr": proc.stderr[-1000:],
47
+ "command": command,
48
+ }
49
+ except Exception as exc:
50
+ return {"ok": False, "error": str(exc), "command": command}
51
+
52
+
53
+ def default_processes(system: str | None = None) -> set[str]:
54
+ system = (system or platform_module.system()).lower()
55
+ command = ["tasklist"] if system == "windows" else ["ps", "-axo", "comm="]
56
+ try:
57
+ proc = subprocess.run(command, capture_output=True, text=True, timeout=5)
58
+ except Exception:
59
+ return set()
60
+ names: set[str] = set()
61
+ for raw in proc.stdout.splitlines():
62
+ name = _process_name_from_line(raw, windows=system == "windows")
63
+ if not name:
64
+ continue
65
+ names.add(name)
66
+ return names
67
+
68
+
69
+ def _process_name_from_line(raw: str, *, windows: bool) -> str:
70
+ line = str(raw or "").strip()
71
+ if not line:
72
+ return ""
73
+ if not windows:
74
+ return line.strip('"').rsplit("/", 1)[-1].lower()
75
+ lower = line.lower()
76
+ if lower.startswith("image name") or lower.startswith("="):
77
+ return ""
78
+ if line.startswith('"') and "," in line:
79
+ return line.split(",", 1)[0].strip().strip('"').lower()
80
+ return line.split(None, 1)[0].strip().strip('"').lower()
81
+
82
+
83
+ class RecoveryRegistry:
84
+ def __init__(self) -> None:
85
+ self._handlers: list[tuple[str, set[str], Handler]] = []
86
+
87
+ def register(self, name: str, platforms: set[str], handler: Handler) -> None:
88
+ self._handlers.append((name, {p.lower() for p in platforms}, handler))
89
+
90
+ def run(
91
+ self,
92
+ *,
93
+ platform: str | None = None,
94
+ processes: set[str] | None = None,
95
+ dry_run: bool = False,
96
+ runner: Runner | None = None,
97
+ env: dict[str, str] | None = None,
98
+ ) -> dict:
99
+ system = (platform or platform_module.system()).lower()
100
+ ctx = RecoveryContext(
101
+ platform=system,
102
+ processes=processes if processes is not None else default_processes(system),
103
+ dry_run=dry_run,
104
+ env=env or dict(os.environ),
105
+ runner=runner,
106
+ )
107
+ results: list[RecoveryResult] = []
108
+ for _name, platforms, handler in self._handlers:
109
+ if "all" not in platforms and system not in platforms:
110
+ continue
111
+ results.append(handler(ctx))
112
+ touched = [result.app for result in results if result.touched]
113
+ return {
114
+ "ok": not any(result.errors for result in results),
115
+ "platform": system,
116
+ "dry_run": dry_run,
117
+ "touched_apps": touched,
118
+ "results": [asdict(result) for result in results],
119
+ }
120
+
121
+
122
+ def build_default_registry() -> RecoveryRegistry:
123
+ from .handlers.common import register_common_handlers
124
+ from .handlers.macos import register_macos_handlers
125
+ from .handlers.windows import register_windows_handlers
126
+
127
+ registry = RecoveryRegistry()
128
+ register_common_handlers(registry)
129
+ register_macos_handlers(registry)
130
+ register_windows_handlers(registry)
131
+ return registry
132
+
133
+
134
+ def run_sweep(**kwargs) -> dict:
135
+ return build_default_registry().run(**kwargs)