nexo-brain 7.20.11 → 7.20.13

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.11",
3
+ "version": "7.20.13",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.20.11` is the current packaged-runtime line. Patch release over v7.20.10Local Context now starts from real system volume roots plus mounted/removable/network volumes, filters system/cache/app/product artifacts, and injects relevant local evidence automatically into heartbeat, task-open and pre-action context.
21
+ Version `7.20.13` is the current packaged-runtime line. Patch release over v7.20.12Brain recovery now pauses all known DB writers before restoring `nexo.db`, and Doctor can repair the zero-byte/locked database state that made Desktop Local Memory show zero files.
22
+
23
+ Previously in `7.20.12`: patch release over v7.20.11 — Local Context now keeps the first index pass separate from live change tracking, persists the current indexing start time, caps compact context payloads for agents, and installs the Windows host scheduler needed to keep WSL indexing alive after reboots.
24
+
25
+ Previously in `7.20.11`: patch release over v7.20.10 — Local Context now starts from real system volume roots plus mounted/removable/network volumes, filters system/cache/app/product artifacts, and injects relevant local evidence automatically into heartbeat, task-open and pre-action context.
22
26
 
23
27
  Previously in `7.20.10`: patch release over v7.20.9 — Local Context manual refreshes now reconcile automatic roots every time, so newly mounted disks and upgraded default roots are picked up immediately from Desktop's "comprobar cambios" path.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.11",
3
+ "version": "7.20.13",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -1412,7 +1412,8 @@ def _self_heal_if_wiped() -> dict | None:
1412
1412
  db_looks_wiped,
1413
1413
  db_row_counts,
1414
1414
  find_latest_hourly_backup,
1415
- kill_nexo_mcp_servers,
1415
+ quiesce_nexo_db_writers,
1416
+ resume_nexo_launchagents,
1416
1417
  safe_sqlite_backup,
1417
1418
  validate_backup_matches_source,
1418
1419
  )
@@ -1467,11 +1468,29 @@ def _self_heal_if_wiped() -> dict | None:
1467
1468
  f"(reference={reference.name}, {ref_total} critical rows). Restoring..."
1468
1469
  )
1469
1470
 
1470
- # Kill any live MCP servers so they cannot overwrite the restored DB.
1471
- kill_report = kill_nexo_mcp_servers(dry_run=False)
1472
- if kill_report.get("terminated"):
1473
- _log(f"self-heal: terminated {kill_report['terminated']} live MCP server(s).")
1474
- time.sleep(0.5)
1471
+ # Pause any live DB writers so they cannot overwrite the restored DB or
1472
+ # keep stale handles open. Desktop installs have more writers than the MCP
1473
+ # server: local-index, email-monitor, followup-runner, watchdog and catchup.
1474
+ quiesce_report = quiesce_nexo_db_writers(dry_run=False)
1475
+ stopped_launchagents = list((quiesce_report.get("launchagents") or {}).get("stopped") or [])
1476
+ if quiesce_report.get("terminated") or stopped_launchagents:
1477
+ _log(
1478
+ "self-heal: quiesced DB writers "
1479
+ f"(terminated={quiesce_report.get('terminated', 0)}, "
1480
+ f"launchagents={len(stopped_launchagents)})."
1481
+ )
1482
+ if quiesce_report.get("errors"):
1483
+ _log(f"self-heal: DB writer quiesce warnings: {quiesce_report.get('errors')}")
1484
+
1485
+ def _resume_quiesced() -> dict | None:
1486
+ if not stopped_launchagents:
1487
+ return None
1488
+ report = resume_nexo_launchagents(stopped_launchagents)
1489
+ if report.get("started"):
1490
+ _log(f"self-heal: resumed {len(report['started'])} launchagent(s).")
1491
+ if report.get("errors"):
1492
+ _log(f"self-heal: launchagent resume warnings: {report.get('errors')}")
1493
+ return report
1475
1494
 
1476
1495
  # Snapshot the current (wiped) state so the heal is reversible.
1477
1496
  pre_heal_dir = paths.backups_dir() / f"pre-heal-{time.strftime('%Y-%m-%d-%H%M%S')}"
@@ -1497,27 +1516,34 @@ def _self_heal_if_wiped() -> dict | None:
1497
1516
  ok, err = safe_sqlite_backup(reference, primary)
1498
1517
  if not ok:
1499
1518
  _log(f"self-heal: restore copy failed: {err}")
1519
+ resume_report = _resume_quiesced()
1500
1520
  return {
1501
1521
  "action": "failed",
1502
1522
  "reason": "restore_copy_failed",
1503
1523
  "error": err,
1504
1524
  "reference": str(reference),
1505
1525
  "pre_heal_dir": str(pre_heal_dir),
1526
+ "quiesce": quiesce_report,
1527
+ "resume": resume_report,
1506
1528
  }
1507
1529
  valid, valid_err = validate_backup_matches_source(reference, primary, CRITICAL_TABLES)
1508
1530
  if not valid:
1509
1531
  _log(f"self-heal: post-restore validation failed: {valid_err}")
1532
+ resume_report = _resume_quiesced()
1510
1533
  return {
1511
1534
  "action": "failed",
1512
1535
  "reason": "validation_failed",
1513
1536
  "error": valid_err,
1514
1537
  "reference": str(reference),
1515
1538
  "pre_heal_dir": str(pre_heal_dir),
1539
+ "quiesce": quiesce_report,
1540
+ "resume": resume_report,
1516
1541
  }
1517
1542
 
1518
1543
  final_counts = db_row_counts(primary, CRITICAL_TABLES)
1519
1544
  final_total = sum(v for v in final_counts.values() if isinstance(v, int))
1520
1545
  _log(f"self-heal: restored {final_total} critical rows from {reference.name}.")
1546
+ resume_report = _resume_quiesced()
1521
1547
  try:
1522
1548
  SELF_HEAL_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
1523
1549
  SELF_HEAL_STATE_FILE.write_text(json.dumps({
@@ -1525,6 +1551,7 @@ def _self_heal_if_wiped() -> dict | None:
1525
1551
  "reference": str(reference),
1526
1552
  "critical_rows_restored": final_total,
1527
1553
  "pre_heal_dir": str(pre_heal_dir),
1554
+ "quiesced_launchagents": stopped_launchagents,
1528
1555
  }))
1529
1556
  except Exception as e:
1530
1557
  _log(f"self-heal: state write warning: {e}")
@@ -1535,7 +1562,9 @@ def _self_heal_if_wiped() -> dict | None:
1535
1562
  "reference_rows": ref_total,
1536
1563
  "restored_rows": final_total,
1537
1564
  "pre_heal_dir": str(pre_heal_dir),
1538
- "terminated_servers": kill_report.get("terminated", 0),
1565
+ "terminated_servers": int((quiesce_report.get("mcp") or {}).get("terminated") or 0),
1566
+ "quiesce": quiesce_report,
1567
+ "resume": resume_report,
1539
1568
  }
1540
1569
 
1541
1570
 
package/src/cli.py CHANGED
@@ -1361,6 +1361,10 @@ def _local_context_query(args) -> int:
1361
1361
  limit=int(getattr(args, "limit", 12) or 12),
1362
1362
  evidence_required=not bool(getattr(args, "no_evidence_required", False)),
1363
1363
  current_context=getattr(args, "current_context", "") or "",
1364
+ mode=getattr(args, "mode", "compact") or "compact",
1365
+ max_chars=int(getattr(args, "max_chars", 20000) or 0),
1366
+ include_entities=bool(getattr(args, "include_entities", False)),
1367
+ include_relations=bool(getattr(args, "include_relations", False)),
1364
1368
  ),
1365
1369
  args,
1366
1370
  )
@@ -3211,6 +3215,10 @@ def main():
3211
3215
  local_context_query_p.add_argument("--intent", default="answer", help="Intent label stored with the query audit row")
3212
3216
  local_context_query_p.add_argument("--limit", type=int, default=12, help="Maximum evidence rows")
3213
3217
  local_context_query_p.add_argument("--current-context", default="", help="Optional current conversation/task context")
3218
+ local_context_query_p.add_argument("--mode", choices=["compact", "full"], default="compact", help="Payload shape. Compact is safe for chat clients; full is for debugging.")
3219
+ local_context_query_p.add_argument("--max-chars", type=int, default=20000, help="Maximum JSON payload size before truncation. Use 0 to disable.")
3220
+ local_context_query_p.add_argument("--include-entities", action="store_true", help="Include matched entities in the JSON payload.")
3221
+ local_context_query_p.add_argument("--include-relations", action="store_true", help="Include graph relations in the JSON payload.")
3214
3222
  local_context_query_p.add_argument("--no-evidence-required", action="store_true", help="Allow empty evidence results")
3215
3223
  local_context_query_p.add_argument("--json", action="store_true", help="JSON output")
3216
3224
 
package/src/crons/sync.py CHANGED
@@ -35,6 +35,14 @@ if str(_runtime_root) not in sys.path:
35
35
 
36
36
  import paths
37
37
  from cron_recovery import is_cron_enabled, resolve_declared_schedule, should_run_at_load
38
+ try:
39
+ from windows_runtime import resolve_windows_host_binary, running_inside_wsl
40
+ except ImportError:
41
+ def resolve_windows_host_binary(command: str) -> str:
42
+ return ""
43
+
44
+ def running_inside_wsl() -> bool:
45
+ return False
38
46
  try:
39
47
  from runtime_power import (
40
48
  launchctl_side_effects_allowed,
@@ -548,6 +556,65 @@ def _install_linux_crontab_fallback(entries: list[str]) -> dict:
548
556
  return {"ok": True, "entries": len(entries)}
549
557
 
550
558
 
559
+ def _powershell_single_quote(value: str | os.PathLike[str]) -> str:
560
+ return "'" + str(value).replace("'", "''") + "'"
561
+
562
+
563
+ def _windows_argument_quote(value: str | os.PathLike[str]) -> str:
564
+ text = str(value)
565
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
566
+
567
+
568
+ def _sync_wsl_windows_host_local_index_task(dry_run: bool = False) -> dict:
569
+ if not running_inside_wsl():
570
+ return {"ok": True, "skipped": True, "reason": "not_wsl"}
571
+ powershell = resolve_windows_host_binary("powershell.exe")
572
+ if not powershell:
573
+ log("WARNING: Windows host PowerShell not available; local-index host task not installed.")
574
+ return {"ok": False, "skipped": True, "reason": "powershell_missing"}
575
+
576
+ distro = str(os.environ.get("WSL_DISTRO_NAME", "")).strip()
577
+ if not distro:
578
+ log("WARNING: WSL_DISTRO_NAME missing; local-index host task not installed.")
579
+ return {"ok": False, "skipped": True, "reason": "wsl_distro_missing"}
580
+
581
+ python_bin = "/usr/bin/python3" if Path("/usr/bin/python3").exists() else "python3"
582
+ script_path = _runtime_code_dir() / "scripts" / "nexo-local-index.py"
583
+ command = (
584
+ f"cd {shlex.quote(str(Path.home()))} && "
585
+ f"NEXO_HOME={shlex.quote(str(NEXO_HOME))} "
586
+ f"NEXO_CODE={shlex.quote(str(_runtime_code_dir()))} "
587
+ f"{shlex.quote(python_bin)} {shlex.quote(str(script_path))}"
588
+ )
589
+ wsl_args = " ".join(
590
+ _windows_argument_quote(arg)
591
+ for arg in ("-d", distro, "--exec", "/bin/bash", "-lc", command)
592
+ )
593
+ task_name = "NEXO Local Memory"
594
+ ps_script = (
595
+ f"$action = New-ScheduledTaskAction -Execute 'wsl.exe' -Argument {_powershell_single_quote(wsl_args)}; "
596
+ "$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) "
597
+ "-RepetitionInterval (New-TimeSpan -Minutes 1) -RepetitionDuration (New-TimeSpan -Days 3650); "
598
+ "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries "
599
+ "-StartWhenAvailable -MultipleInstances IgnoreNew; "
600
+ f"Register-ScheduledTask -TaskName {_powershell_single_quote(task_name)} -Action $action -Trigger $trigger "
601
+ "-Settings $settings -Description 'NEXO Local Memory background indexing' -Force | Out-Null; "
602
+ f"Start-ScheduledTask -TaskName {_powershell_single_quote(task_name)}"
603
+ )
604
+
605
+ if dry_run:
606
+ log(f" DRY-RUN: would install Windows host task: {task_name}")
607
+ return {"ok": True, "dry_run": True, "task_name": task_name, "argument": wsl_args}
608
+
609
+ result = subprocess.run([powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_script], capture_output=True, text=True, timeout=45)
610
+ if result.returncode != 0:
611
+ error = (result.stderr or result.stdout or "windows_host_task_install_failed").strip()
612
+ log(f"WARNING: Windows host local-index task install failed: {error}")
613
+ return {"ok": False, "task_name": task_name, "error": error}
614
+ log(f"Windows host task installed: {task_name}")
615
+ return {"ok": True, "task_name": task_name, "argument": wsl_args}
616
+
617
+
551
618
  def _enable_systemd_user_units(units: list[str]) -> dict:
552
619
  errors: list[str] = []
553
620
  daemon = subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True)
@@ -699,6 +766,7 @@ def sync(dry_run: bool = False):
699
766
  system = platform.system()
700
767
  if system == "Linux":
701
768
  sync_linux(dry_run)
769
+ _sync_wsl_windows_host_local_index_task(dry_run)
702
770
  return
703
771
  if system != "Darwin":
704
772
  log(f"Unsupported platform: {system}. Skipping.")
package/src/db_guard.py CHANGED
@@ -27,6 +27,8 @@ auto_update.py):
27
27
  safe_sqlite_backup(source, dest) -> tuple[bool, str | None]
28
28
  validate_backup_matches_source(source, dest, tables) -> tuple[bool, str | None]
29
29
  kill_nexo_mcp_servers(dry_run) -> dict
30
+ quiesce_nexo_db_writers(dry_run) -> dict
31
+ resume_nexo_launchagents(labels, dry_run) -> dict
30
32
  """
31
33
 
32
34
  from __future__ import annotations
@@ -80,6 +82,27 @@ HOURLY_BACKUP_GLOB = "nexo-*.db"
80
82
  # as an automatic self-heal source. 48h matches nexo-backup.sh retention.
81
83
  HOURLY_BACKUP_MAX_AGE = 48 * 3600
82
84
 
85
+ # Long-lived NEXO services that can keep ``nexo.db`` open while recovery tries
86
+ # to replace it. Keep this list conservative: only product-owned background
87
+ # processes that are safe to stop and restart.
88
+ NEXO_DB_WRITER_LAUNCHAGENTS: tuple[str, ...] = (
89
+ "com.nexo.local-index",
90
+ "com.nexo.email-monitor",
91
+ "com.nexo.followup-runner",
92
+ "com.nexo.watchdog",
93
+ "com.nexo.catchup",
94
+ "com.nexo.immune",
95
+ )
96
+
97
+ NEXO_DB_WRITER_MARKERS: tuple[str, ...] = (
98
+ "nexo-local-index.py",
99
+ "nexo-email-monitor.py",
100
+ "nexo-followup-runner.py",
101
+ "nexo-catchup.py",
102
+ "nexo-watchdog.sh",
103
+ "nexo-immune.py",
104
+ )
105
+
83
106
 
84
107
  # ── Types ───────────────────────────────────────────────────────────────
85
108
 
@@ -447,3 +470,252 @@ def _looks_like_nexo_mcp(cmd: str) -> bool:
447
470
  if "nexo_sdk" in lowered or "nexo-mcp" in lowered:
448
471
  return True
449
472
  return False
473
+
474
+
475
+ # ── DB writer quiescence ────────────────────────────────────────────────
476
+
477
+ def quiesce_nexo_db_writers(
478
+ dry_run: bool = False,
479
+ *,
480
+ stop_launchagents: bool = True,
481
+ settle_seconds: float = 0.75,
482
+ ) -> dict:
483
+ """Stop known NEXO background writers before replacing ``nexo.db``.
484
+
485
+ ``kill_nexo_mcp_servers`` is not enough for Desktop installs: local-index,
486
+ email monitor, followup-runner and catchup can keep a stale DB handle open
487
+ even after the MCP server exits. This helper is intentionally narrow and
488
+ only targets product-owned long-lived writers.
489
+ """
490
+ result: dict = {
491
+ "dry_run": dry_run,
492
+ "mcp": {},
493
+ "launchagents": {"stopped": [], "errors": [], "unsupported": False},
494
+ "processes": {"scanned": 0, "terminated": 0, "pids": [], "errors": []},
495
+ "terminated": 0,
496
+ "errors": [],
497
+ }
498
+
499
+ mcp_report = kill_nexo_mcp_servers(dry_run=dry_run)
500
+ result["mcp"] = mcp_report
501
+ result["terminated"] += int(mcp_report.get("terminated") or 0)
502
+ result["errors"].extend(mcp_report.get("errors") or [])
503
+
504
+ if stop_launchagents:
505
+ la_report = _stop_nexo_launchagents(dry_run=dry_run)
506
+ result["launchagents"] = la_report
507
+ result["errors"].extend(la_report.get("errors") or [])
508
+
509
+ process_report = _terminate_nexo_db_writer_processes(dry_run=dry_run)
510
+ result["processes"] = process_report
511
+ result["terminated"] += int(process_report.get("terminated") or 0)
512
+ result["errors"].extend(process_report.get("errors") or [])
513
+
514
+ if not dry_run and (result["terminated"] or result["launchagents"].get("stopped")):
515
+ time.sleep(max(settle_seconds, 0.0))
516
+ return result
517
+
518
+
519
+ def resume_nexo_launchagents(labels: list[str] | tuple[str, ...] | None = None, dry_run: bool = False) -> dict:
520
+ """Best-effort restart of LaunchAgents stopped by DB recovery."""
521
+ result: dict = {"dry_run": dry_run, "started": [], "errors": [], "unsupported": False}
522
+ if os.name != "posix" or sys_platform() != "darwin":
523
+ result["unsupported"] = True
524
+ return result
525
+ uid = os.getuid()
526
+ launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
527
+ chosen = tuple(labels or NEXO_DB_WRITER_LAUNCHAGENTS)
528
+ for label in chosen:
529
+ plist = launch_agents_dir / f"{label}.plist"
530
+ if not plist.is_file():
531
+ continue
532
+ target = f"gui/{uid}/{label}"
533
+ if dry_run:
534
+ result["started"].append(label)
535
+ continue
536
+ try:
537
+ subprocess.run(
538
+ ["launchctl", "bootstrap", f"gui/{uid}", str(plist)],
539
+ capture_output=True,
540
+ text=True,
541
+ timeout=5,
542
+ )
543
+ subprocess.run(
544
+ ["launchctl", "kickstart", "-k", target],
545
+ capture_output=True,
546
+ text=True,
547
+ timeout=5,
548
+ )
549
+ result["started"].append(label)
550
+ except Exception as exc:
551
+ result["errors"].append(f"{label}: {exc}")
552
+ return result
553
+
554
+
555
+ def sys_platform() -> str:
556
+ # Small indirection makes tests easy to monkeypatch without importing sys at
557
+ # module import time in older runtimes.
558
+ import sys
559
+ return sys.platform
560
+
561
+
562
+ def _stop_nexo_launchagents(dry_run: bool = False) -> dict:
563
+ result: dict = {"stopped": [], "errors": [], "unsupported": False}
564
+ if os.name != "posix" or sys_platform() != "darwin":
565
+ result["unsupported"] = True
566
+ return result
567
+ uid = os.getuid()
568
+ launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
569
+ for label in NEXO_DB_WRITER_LAUNCHAGENTS:
570
+ plist = launch_agents_dir / f"{label}.plist"
571
+ if not plist.is_file():
572
+ continue
573
+ if dry_run:
574
+ result["stopped"].append(label)
575
+ continue
576
+ try:
577
+ proc = subprocess.run(
578
+ ["launchctl", "bootout", f"gui/{uid}", str(plist)],
579
+ capture_output=True,
580
+ text=True,
581
+ timeout=5,
582
+ )
583
+ except Exception as exc:
584
+ result["errors"].append(f"{label}: {exc}")
585
+ continue
586
+ if proc.returncode == 0:
587
+ result["stopped"].append(label)
588
+ else:
589
+ stderr = (proc.stderr or "").strip()
590
+ # launchctl returns non-zero when an agent is already unloaded. That
591
+ # is not a recovery blocker, so keep it as quiet evidence.
592
+ if stderr and "No such process" not in stderr and "not found" not in stderr:
593
+ result["errors"].append(f"{label}: {stderr[:200]}")
594
+ return result
595
+
596
+
597
+ def _terminate_nexo_db_writer_processes(dry_run: bool = False) -> dict:
598
+ result: dict = {"scanned": 0, "terminated": 0, "pids": [], "errors": [], "dry_run": dry_run}
599
+ if os.name == "posix":
600
+ return _terminate_posix_db_writer_processes(dry_run=dry_run)
601
+ if os.name == "nt":
602
+ return _terminate_windows_db_writer_processes(dry_run=dry_run)
603
+ result["errors"].append("unsupported platform")
604
+ return result
605
+
606
+
607
+ def _terminate_posix_db_writer_processes(dry_run: bool = False) -> dict:
608
+ result: dict = {"scanned": 0, "terminated": 0, "pids": [], "errors": [], "dry_run": dry_run}
609
+ try:
610
+ proc = subprocess.run(
611
+ ["ps", "-axo", "pid=,command="],
612
+ capture_output=True,
613
+ text=True,
614
+ timeout=5,
615
+ )
616
+ except Exception as exc:
617
+ result["errors"].append(f"ps failed: {exc}")
618
+ return result
619
+ if proc.returncode != 0:
620
+ result["errors"].append(f"ps exit {proc.returncode}: {proc.stderr.strip()[:200]}")
621
+ return result
622
+
623
+ my_pid = os.getpid()
624
+ for raw in proc.stdout.splitlines():
625
+ line = raw.strip()
626
+ if not line:
627
+ continue
628
+ head, _, rest = line.partition(" ")
629
+ if not head.isdigit():
630
+ continue
631
+ pid = int(head)
632
+ if pid == my_pid:
633
+ continue
634
+ cmd = rest.strip()
635
+ if not _looks_like_nexo_db_writer(cmd):
636
+ continue
637
+ result["scanned"] += 1
638
+ result["pids"].append({"pid": pid, "command": cmd[:180]})
639
+ if dry_run:
640
+ continue
641
+ try:
642
+ os.kill(pid, signal.SIGTERM)
643
+ result["terminated"] += 1
644
+ except ProcessLookupError:
645
+ pass
646
+ except Exception as exc:
647
+ result["errors"].append(f"kill {pid} failed: {exc}")
648
+ return result
649
+
650
+
651
+ def _terminate_windows_db_writer_processes(dry_run: bool = False) -> dict:
652
+ result: dict = {"scanned": 0, "terminated": 0, "pids": [], "errors": [], "dry_run": dry_run}
653
+ ps_script = (
654
+ "Get-CimInstance Win32_Process | "
655
+ "Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
656
+ )
657
+ try:
658
+ proc = subprocess.run(
659
+ ["powershell", "-NoProfile", "-Command", ps_script],
660
+ capture_output=True,
661
+ text=True,
662
+ timeout=10,
663
+ )
664
+ except Exception as exc:
665
+ result["errors"].append(f"powershell process scan failed: {exc}")
666
+ return result
667
+ if proc.returncode != 0:
668
+ result["errors"].append(f"powershell exit {proc.returncode}: {proc.stderr.strip()[:200]}")
669
+ return result
670
+ try:
671
+ import json
672
+ rows = json.loads(proc.stdout or "[]")
673
+ except Exception as exc:
674
+ result["errors"].append(f"process json parse failed: {exc}")
675
+ return result
676
+ if isinstance(rows, dict):
677
+ rows = [rows]
678
+ my_pid = os.getpid()
679
+ for row in rows if isinstance(rows, list) else []:
680
+ try:
681
+ pid = int(row.get("ProcessId"))
682
+ except Exception:
683
+ continue
684
+ if pid == my_pid:
685
+ continue
686
+ cmd = str(row.get("CommandLine") or "")
687
+ if not _looks_like_nexo_db_writer(cmd):
688
+ continue
689
+ result["scanned"] += 1
690
+ result["pids"].append({"pid": pid, "command": cmd[:180]})
691
+ if dry_run:
692
+ continue
693
+ try:
694
+ subprocess.run(
695
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
696
+ capture_output=True,
697
+ text=True,
698
+ timeout=5,
699
+ )
700
+ result["terminated"] += 1
701
+ except Exception as exc:
702
+ result["errors"].append(f"taskkill {pid} failed: {exc}")
703
+ return result
704
+
705
+
706
+ def _looks_like_nexo_db_writer(cmd: str) -> bool:
707
+ if not cmd:
708
+ return False
709
+ lowered = cmd.lower()
710
+ if _looks_like_nexo_mcp(cmd):
711
+ return True
712
+ if "nexo-cron-wrapper.sh" in lowered and any(label in lowered for label in (
713
+ "local-index",
714
+ "email-monitor",
715
+ "followup-runner",
716
+ "watchdog",
717
+ "catchup",
718
+ "immune",
719
+ )):
720
+ return True
721
+ return any(marker in lowered for marker in NEXO_DB_WRITER_MARKERS)