logicroot 0.2.0__py3-none-any.whl

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 (147) hide show
  1. logicroot/__init__.py +17 -0
  2. logicroot/__main__.py +8 -0
  3. logicroot/assistant/__init__.py +57 -0
  4. logicroot/assistant/base.py +85 -0
  5. logicroot/assistant/context.py +113 -0
  6. logicroot/assistant/local.py +198 -0
  7. logicroot/assistant/manager.py +107 -0
  8. logicroot/assistant/models.py +117 -0
  9. logicroot/assistant/openai.py +167 -0
  10. logicroot/cleanup/__init__.py +34 -0
  11. logicroot/cleanup/executor.py +259 -0
  12. logicroot/cleanup/manager.py +46 -0
  13. logicroot/cleanup/models.py +133 -0
  14. logicroot/cleanup/planner.py +219 -0
  15. logicroot/cli.py +558 -0
  16. logicroot/cmd/__init__.py +47 -0
  17. logicroot/cmd/_common.py +82 -0
  18. logicroot/cmd/ask.py +100 -0
  19. logicroot/cmd/cleanup.py +92 -0
  20. logicroot/cmd/diagnose.py +77 -0
  21. logicroot/cmd/gpu.py +50 -0
  22. logicroot/cmd/logs.py +177 -0
  23. logicroot/cmd/network.py +159 -0
  24. logicroot/cmd/processes.py +235 -0
  25. logicroot/cmd/services.py +206 -0
  26. logicroot/cmd/status.py +66 -0
  27. logicroot/cmd/storage.py +202 -0
  28. logicroot/config.py +419 -0
  29. logicroot/constants.py +226 -0
  30. logicroot/core/__init__.py +3 -0
  31. logicroot/core/capabilities.py +266 -0
  32. logicroot/core/command.py +266 -0
  33. logicroot/core/permissions.py +114 -0
  34. logicroot/core/platform.py +163 -0
  35. logicroot/core/validation.py +174 -0
  36. logicroot/diagnostics/__init__.py +21 -0
  37. logicroot/diagnostics/boot.py +154 -0
  38. logicroot/diagnostics/engine.py +200 -0
  39. logicroot/diagnostics/models.py +102 -0
  40. logicroot/diagnostics/rules.py +569 -0
  41. logicroot/exceptions.py +102 -0
  42. logicroot/hardware/__init__.py +8 -0
  43. logicroot/hardware/backend.py +199 -0
  44. logicroot/hardware/backends/__init__.py +9 -0
  45. logicroot/hardware/backends/linux.py +131 -0
  46. logicroot/hardware/backends/macos.py +874 -0
  47. logicroot/hardware/backends/powershell.py +102 -0
  48. logicroot/hardware/backends/windows.py +1182 -0
  49. logicroot/hardware/battery.py +123 -0
  50. logicroot/hardware/bluetooth.py +94 -0
  51. logicroot/hardware/cache.py +131 -0
  52. logicroot/hardware/capabilities.py +743 -0
  53. logicroot/hardware/context.py +149 -0
  54. logicroot/hardware/cpu.py +236 -0
  55. logicroot/hardware/discovery.py +234 -0
  56. logicroot/hardware/display.py +109 -0
  57. logicroot/hardware/gpu.py +243 -0
  58. logicroot/hardware/memory.py +88 -0
  59. logicroot/hardware/models.py +784 -0
  60. logicroot/hardware/network.py +313 -0
  61. logicroot/hardware/packages.py +107 -0
  62. logicroot/hardware/pci.py +187 -0
  63. logicroot/hardware/storage.py +192 -0
  64. logicroot/hardware/system.py +191 -0
  65. logicroot/hardware/systemd.py +86 -0
  66. logicroot/hardware/thermal.py +91 -0
  67. logicroot/hardware/tools.py +120 -0
  68. logicroot/hardware/usb.py +137 -0
  69. logicroot/logs/__init__.py +16 -0
  70. logicroot/logs/journal.py +160 -0
  71. logicroot/logs/manager.py +67 -0
  72. logicroot/logs/models.py +122 -0
  73. logicroot/network/__init__.py +38 -0
  74. logicroot/network/connections.py +175 -0
  75. logicroot/network/foreign.py +604 -0
  76. logicroot/network/interfaces.py +277 -0
  77. logicroot/network/manager.py +151 -0
  78. logicroot/network/models.py +150 -0
  79. logicroot/network/ping.py +170 -0
  80. logicroot/processes/__init__.py +30 -0
  81. logicroot/processes/foreign.py +213 -0
  82. logicroot/processes/manager.py +412 -0
  83. logicroot/processes/models.py +175 -0
  84. logicroot/processes/reader.py +243 -0
  85. logicroot/providers/__init__.py +5 -0
  86. logicroot/providers/gpu/__init__.py +21 -0
  87. logicroot/providers/gpu/amd.py +135 -0
  88. logicroot/providers/gpu/base.py +99 -0
  89. logicroot/providers/gpu/collector.py +153 -0
  90. logicroot/providers/gpu/intel.py +114 -0
  91. logicroot/providers/gpu/nvidia.py +185 -0
  92. logicroot/providers/package/__init__.py +26 -0
  93. logicroot/providers/package/apt.py +26 -0
  94. logicroot/providers/package/base.py +127 -0
  95. logicroot/providers/package/dnf.py +37 -0
  96. logicroot/providers/package/pacman.py +25 -0
  97. logicroot/providers/package/zypper.py +24 -0
  98. logicroot/providers/services/__init__.py +6 -0
  99. logicroot/providers/services/base.py +40 -0
  100. logicroot/providers/services/systemd.py +11 -0
  101. logicroot/services/__init__.py +25 -0
  102. logicroot/services/foreign.py +230 -0
  103. logicroot/services/manager.py +149 -0
  104. logicroot/services/models.py +116 -0
  105. logicroot/services/systemd.py +200 -0
  106. logicroot/storage/__init__.py +27 -0
  107. logicroot/storage/analyzer.py +373 -0
  108. logicroot/storage/manager.py +86 -0
  109. logicroot/storage/models.py +128 -0
  110. logicroot/storage/scanner.py +252 -0
  111. logicroot/system/__init__.py +38 -0
  112. logicroot/system/foreign.py +373 -0
  113. logicroot/system/models.py +248 -0
  114. logicroot/system/monitor.py +145 -0
  115. logicroot/system/reader.py +629 -0
  116. logicroot/system/sampler.py +311 -0
  117. logicroot/ui/__init__.py +24 -0
  118. logicroot/ui/app.py +186 -0
  119. logicroot/ui/console.py +63 -0
  120. logicroot/ui/launcher.py +57 -0
  121. logicroot/ui/panels.py +417 -0
  122. logicroot/ui/screens/__init__.py +25 -0
  123. logicroot/ui/screens/base.py +59 -0
  124. logicroot/ui/screens/cleanup.py +130 -0
  125. logicroot/ui/screens/dashboard.py +109 -0
  126. logicroot/ui/screens/diagnose.py +83 -0
  127. logicroot/ui/screens/gpu.py +87 -0
  128. logicroot/ui/screens/logs.py +107 -0
  129. logicroot/ui/screens/network.py +165 -0
  130. logicroot/ui/screens/processes.py +199 -0
  131. logicroot/ui/screens/services.py +173 -0
  132. logicroot/ui/screens/storage.py +147 -0
  133. logicroot/ui/theme.py +114 -0
  134. logicroot/ui/views.py +628 -0
  135. logicroot/ui/widgets/__init__.py +13 -0
  136. logicroot/ui/widgets/dialogs.py +65 -0
  137. logicroot/ui/widgets/gauges.py +113 -0
  138. logicroot/utils/__init__.py +1 -0
  139. logicroot/utils/fs.py +172 -0
  140. logicroot/utils/logging.py +183 -0
  141. logicroot/utils/serialize.py +168 -0
  142. logicroot/utils/text.py +144 -0
  143. logicroot-0.2.0.dist-info/METADATA +1188 -0
  144. logicroot-0.2.0.dist-info/RECORD +147 -0
  145. logicroot-0.2.0.dist-info/WHEEL +4 -0
  146. logicroot-0.2.0.dist-info/entry_points.txt +2 -0
  147. logicroot-0.2.0.dist-info/licenses/LICENSE +21 -0
logicroot/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """LOGICROOT - Your Linux Command Center.
2
+
3
+ LOGICROOT is a hardware-aware, capability-driven, local-first Linux system command
4
+ center. The package is import-safe on any platform: every Linux-specific
5
+ subsystem degrades gracefully when the kernel interfaces it needs are absent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = ["__version__", "get_version"]
11
+
12
+ __version__ = "0.2.0"
13
+
14
+
15
+ def get_version() -> str:
16
+ """Return the installed LOGICROOT version string."""
17
+ return __version__
logicroot/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m logicroot`` to behave exactly like the ``logicroot`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logicroot.cli import run
6
+
7
+ if __name__ == "__main__":
8
+ run()
@@ -0,0 +1,57 @@
1
+ """Optional AI assistant (Milestone 13).
2
+
3
+ Disabled by default, offline-first, and text-only: the assistant answers
4
+ questions *about* the system and never acts on it. See :mod:`logicroot.assistant.base`
5
+ for the safety contract every provider follows.
6
+ """
7
+
8
+ from logicroot.assistant.base import (
9
+ MAX_QUESTION_CHARS,
10
+ AssistantProvider,
11
+ context_is_safe,
12
+ sanitise_question,
13
+ scrub,
14
+ )
15
+ from logicroot.assistant.context import build_context
16
+ from logicroot.assistant.local import LocalProvider
17
+ from logicroot.assistant.manager import Assistant, for_system
18
+ from logicroot.assistant.models import (
19
+ AssistantConfig,
20
+ AssistantContext,
21
+ AssistantMessage,
22
+ AssistantReply,
23
+ AssistantRole,
24
+ ProviderKind,
25
+ )
26
+ from logicroot.assistant.openai import (
27
+ DEFAULT_BASE_URL,
28
+ DEFAULT_MODEL,
29
+ ENV_BASE_URL,
30
+ ENV_MODEL,
31
+ SYSTEM_PROMPT,
32
+ OpenAICompatibleProvider,
33
+ )
34
+
35
+ __all__ = [
36
+ "DEFAULT_BASE_URL",
37
+ "DEFAULT_MODEL",
38
+ "ENV_BASE_URL",
39
+ "ENV_MODEL",
40
+ "MAX_QUESTION_CHARS",
41
+ "SYSTEM_PROMPT",
42
+ "Assistant",
43
+ "AssistantConfig",
44
+ "AssistantContext",
45
+ "AssistantMessage",
46
+ "AssistantProvider",
47
+ "AssistantReply",
48
+ "AssistantRole",
49
+ "LocalProvider",
50
+ "OpenAICompatibleProvider",
51
+ "ProviderKind",
52
+ "build_context",
53
+ "context_is_safe",
54
+ "for_system",
55
+ "sanitise_question",
56
+ "scrub",
57
+ ]
@@ -0,0 +1,85 @@
1
+ """Assistant provider contract and safety rules.
2
+
3
+ Opt-in is enforced by construction: providers do nothing unless asked, the
4
+ network provider refuses to run without an explicit key *and* an explicit
5
+ ``enabled`` flag, and the safety rules below apply to every provider equally.
6
+
7
+ What the assistant will never do, by design:
8
+
9
+ * execute commands or change the system (it returns text; the user acts)
10
+ * send secrets: API keys, tokens, and passwords are stripped from every
11
+ context bundle before any provider sees it
12
+ * send file contents, command lines, or anything beyond coarse aggregates
13
+ * make network calls unless the provider kind is ``openai-compatible`` and
14
+ the user explicitly enabled it for this invocation
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Protocol
21
+
22
+ from logicroot.assistant.models import AssistantContext, AssistantReply
23
+ from logicroot.constants import SECRET_KEY_PATTERN
24
+
25
+
26
+ class AssistantProvider(Protocol):
27
+ """What every assistant backend implements."""
28
+
29
+ #: Backend id (``local``, ``openai-compatible``).
30
+ name: str
31
+
32
+ @property
33
+ def available(self) -> bool:
34
+ """True when this backend can answer right now."""
35
+ ...
36
+
37
+ def ask(self, question: str, context: AssistantContext) -> AssistantReply:
38
+ """Answer ``question`` given ``context`` (text in, text out)."""
39
+ ...
40
+
41
+
42
+ #: Hard ceiling on a question: extremely long input is either a paste accident
43
+ #: or an injection attempt, and neither deserves a network round-trip.
44
+ MAX_QUESTION_CHARS = 2000
45
+
46
+ _SECRET_RE = re.compile(SECRET_KEY_PATTERN, re.IGNORECASE)
47
+ #: ``password=...``/``token ...`` shaped fragments inside a question or context.
48
+ _SECRET_VALUE_RE = re.compile(
49
+ r"(?i)(api[_-]?key|token|secret|password|passwd|authorization|bearer)\s*[:=]\s*\S+"
50
+ )
51
+
52
+
53
+ def sanitise_question(question: str) -> str:
54
+ """Trim and bound a user question, refusing empties and pastes."""
55
+ text = question.strip()
56
+ if not text:
57
+ from logicroot.exceptions import UsageError
58
+
59
+ raise UsageError(
60
+ "No question asked.",
61
+ hint="Try: logicroot ask 'why is my disk full?'",
62
+ )
63
+ if len(text) > MAX_QUESTION_CHARS:
64
+ text = text[:MAX_QUESTION_CHARS]
65
+ return text
66
+
67
+
68
+ def scrub(text: str) -> str:
69
+ """Redact ``key=value`` secret fragments from text bound for a provider."""
70
+ return _SECRET_VALUE_RE.sub(r"\1=[redacted]", text)
71
+
72
+
73
+ def context_is_safe(context: AssistantContext) -> bool:
74
+ """True when the context bundle contains no secret-shaped fragments."""
75
+ bundle = context.to_prompt()
76
+ return _SECRET_VALUE_RE.search(bundle) is None
77
+
78
+
79
+ __all__ = [
80
+ "MAX_QUESTION_CHARS",
81
+ "AssistantProvider",
82
+ "context_is_safe",
83
+ "sanitise_question",
84
+ "scrub",
85
+ ]
@@ -0,0 +1,113 @@
1
+ """Building the context bundle the assistant reasons over.
2
+
3
+ The context is assembled from data LOGICROOT already holds - the hardware profile,
4
+ the latest diagnosis, and the process table - and reduced to short summary
5
+ lines. Two filters keep it safe:
6
+
7
+ * process command lines are dropped in favour of bare names (command lines
8
+ routinely contain passwords, tokens, and file paths)
9
+ * the bundle is size-bounded; findings and processes are the first things
10
+ truncated, never the hardware facts that ground the answer
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from logicroot.assistant.base import scrub
18
+ from logicroot.assistant.models import AssistantContext
19
+ from logicroot.utils.logging import get_logger
20
+ from logicroot.utils.text import human_bytes
21
+
22
+ logger = get_logger("assistant.context")
23
+
24
+
25
+ def build_context(
26
+ profile: Any | None = None,
27
+ *,
28
+ findings: tuple[str, ...] = (),
29
+ top_processes: tuple[str, ...] = (),
30
+ capabilities_note: str = "",
31
+ max_chars: int = 6000,
32
+ ) -> AssistantContext:
33
+ """Assemble an :class:`AssistantContext` from LOGICROOT data."""
34
+ context = AssistantContext(
35
+ hostname=_profile_attr(profile, "hostname"),
36
+ os_summary=_os_summary(profile),
37
+ cpu_summary=_cpu_summary(profile),
38
+ memory_summary=_memory_summary(profile),
39
+ disk_summary=_disk_summary(profile),
40
+ gpu_summary=_gpu_summary(profile),
41
+ findings=tuple(findings[:10]),
42
+ top_processes=tuple(top_processes[:5]),
43
+ capabilities_note=capabilities_note,
44
+ )
45
+ prompt = context.to_prompt()
46
+ if len(prompt) > max_chars:
47
+ # Truncate the variable parts first, keeping the hardware facts intact.
48
+ import dataclasses
49
+
50
+ context = dataclasses.replace(context, findings=(), top_processes=())
51
+ return context
52
+
53
+
54
+ def _profile_attr(profile: Any | None, name: str) -> str:
55
+ if profile is None:
56
+ return ""
57
+ value = getattr(profile, name, "")
58
+ return scrub(str(value)) if value else ""
59
+
60
+
61
+ def _os_summary(profile: Any | None) -> str:
62
+ if profile is None:
63
+ return ""
64
+ os_info = getattr(profile, "os", None)
65
+ if os_info is None:
66
+ return ""
67
+ return getattr(os_info, "display_name", "") or ""
68
+
69
+
70
+ def _cpu_summary(profile: Any | None) -> str:
71
+ if profile is None:
72
+ return ""
73
+ cpu = getattr(profile, "cpu", None)
74
+ if cpu is None:
75
+ return ""
76
+ return getattr(cpu, "display_name", "") or ""
77
+
78
+
79
+ def _memory_summary(profile: Any | None) -> str:
80
+ if profile is None:
81
+ return ""
82
+ memory = getattr(profile, "memory", None)
83
+ if memory is None:
84
+ return ""
85
+ total = getattr(memory, "total_bytes", None)
86
+ return f"{human_bytes(total)} total" if total else ""
87
+
88
+
89
+ def _disk_summary(profile: Any | None) -> str:
90
+ if profile is None:
91
+ return ""
92
+ devices = getattr(profile, "storage_devices", None) or []
93
+ if not devices:
94
+ return ""
95
+ parts = []
96
+ for device in devices[:3]:
97
+ size = getattr(device, "size_bytes", None)
98
+ kind = getattr(device, "transport", "") or getattr(device, "kind", "")
99
+ parts.append(f"{human_bytes(size)} {kind}".strip())
100
+ text = ", ".join(part for part in parts if part)
101
+ extra = len(devices) - 3
102
+ return f"{text} (+{extra} more)" if extra > 0 else text
103
+
104
+
105
+ def _gpu_summary(profile: Any | None) -> str:
106
+ if profile is None:
107
+ return ""
108
+ gpus = getattr(profile, "gpus", None) or []
109
+ names = [getattr(gpu, "display_name", "") or "GPU" for gpu in gpus]
110
+ return ", ".join(names)
111
+
112
+
113
+ __all__ = ["build_context"]
@@ -0,0 +1,198 @@
1
+ """Offline assistant: useful answers with no network, no key, no account.
2
+
3
+ The local provider is deliberately *not* a language model. It is a small
4
+ rule-based responder that reads the question for intent (disk pressure? failed
5
+ services? heat? "what can you do"?) and answers from the context LOGICROOT already
6
+ gathered - findings first, hardware facts second. Because it never leaves the
7
+ machine, it is always available and always private.
8
+
9
+ This is also the default provider, which keeps the promise that ``logicroot ask``
10
+ works out of the box with nothing to configure and nothing to leak.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+
18
+ from logicroot.assistant.models import AssistantContext, AssistantReply, ProviderKind
19
+
20
+
21
+ @dataclass
22
+ class LocalProvider:
23
+ """Answers from LOGICROOT data with no network access."""
24
+
25
+ name: str = "local"
26
+
27
+ @property
28
+ def available(self) -> bool:
29
+ """Always True: there is nothing to install, configure, or reach."""
30
+ return True
31
+
32
+ def ask(self, question: str, context: AssistantContext) -> AssistantReply:
33
+ """Answer ``question`` from ``context`` using intent matching."""
34
+ text = _answer(question.strip(), context)
35
+ return AssistantReply(text=text, provider=ProviderKind.LOCAL, model="logicroot-local/1")
36
+
37
+
38
+ def _answer(question: str, context: AssistantContext) -> str:
39
+ """Pick the best response section for the question's intent."""
40
+ lowered = question.casefold()
41
+ if _wants(lowered, ("disk", "storage", "space", "full", "large", "clean")):
42
+ return _disk_answer(context)
43
+ if _wants(lowered, ("memory", "ram", "swap", "oom")):
44
+ return _memory_answer(context)
45
+ if _wants(lowered, ("cpu", "load", "slow", "hot", "heat", "temperature", "fan")):
46
+ return _cpu_answer(context)
47
+ if _wants(lowered, ("service", "daemon", "failed", "boot", "startup")):
48
+ return _service_answer(context)
49
+ if _wants(lowered, ("network", "wifi", "internet", "ping", "connection")):
50
+ return _network_answer(context)
51
+ if _wants(lowered, ("gpu", "nvidia", "graphics", "display")):
52
+ return _gpu_answer(context)
53
+ if _wants(lowered, ("battery", "power", "charge")):
54
+ return _battery_answer(context)
55
+ if _wants(lowered, ("help", "what can you", "commands", "how do i use")):
56
+ return _help_answer()
57
+ return _general_answer(question, context)
58
+
59
+
60
+ def _wants(lowered: str, keywords: tuple[str, ...]) -> bool:
61
+ return any(re.search(rf"\b{re.escape(keyword)}\b", lowered) for keyword in keywords)
62
+
63
+
64
+ def _relevant(context: AssistantContext, keywords: tuple[str, ...]) -> list[str]:
65
+ """Findings mentioning any keyword, most relevant first."""
66
+ scored: list[tuple[int, str]] = []
67
+ for finding in context.findings:
68
+ lowered = finding.casefold()
69
+ score = sum(1 for keyword in keywords if keyword in lowered)
70
+ if score:
71
+ scored.append((score, finding))
72
+ scored.sort(reverse=True)
73
+ return [finding for _score, finding in scored]
74
+
75
+
76
+ def _disk_answer(context: AssistantContext) -> str:
77
+ lines = ["Disk picture:"]
78
+ lines.append(f"- Storage: {context.disk_summary or 'unknown'}")
79
+ relevant = _relevant(context, ("disk", "filesystem", "inode", "full", "storage"))
80
+ if relevant:
81
+ lines.append("- What LOGICROOT found:")
82
+ lines.extend(f" * {finding}" for finding in relevant[:5])
83
+ lines.append("- Suggested next steps:")
84
+ lines.append(" * logicroot storage scan ~ (what is using the space)")
85
+ lines.append(" * logicroot storage large (biggest files)")
86
+ lines.append(" * logicroot cleanup (safe reclaim preview)")
87
+ else:
88
+ lines.append("- No disk findings. Try 'logicroot storage scan ~' for a breakdown, or 'logicroot cleanup' for reclaimable space.")
89
+ return "\n".join(lines)
90
+
91
+
92
+ def _memory_answer(context: AssistantContext) -> str:
93
+ lines = ["Memory picture:"]
94
+ lines.append(f"- Memory: {context.memory_summary or 'unknown'}")
95
+ relevant = _relevant(context, ("memory", "swap", "oom"))
96
+ if relevant:
97
+ lines.append("- What LOGICROOT found:")
98
+ lines.extend(f" * {finding}" for finding in relevant[:5])
99
+ lines.append("- Suggested next step: logicroot processes --sort memory")
100
+ else:
101
+ lines.append("- No memory findings. 'logicroot processes --sort memory' ranks current usage.")
102
+ if context.top_processes:
103
+ lines.append("- Busiest processes right now:")
104
+ lines.extend(f" * {process}" for process in context.top_processes[:5])
105
+ return "\n".join(lines)
106
+
107
+
108
+ def _cpu_answer(context: AssistantContext) -> str:
109
+ lines = ["CPU picture:"]
110
+ lines.append(f"- CPU: {context.cpu_summary or 'unknown'}")
111
+ relevant = _relevant(context, ("cpu", "load", "thermal", "overheat", "temperature"))
112
+ if relevant:
113
+ lines.append("- What LOGICROOT found:")
114
+ lines.extend(f" * {finding}" for finding in relevant[:5])
115
+ lines.append("- Suggested next step: logicroot processes --sort cpu")
116
+ else:
117
+ lines.append("- No CPU findings. 'logicroot processes --sort cpu' shows the current load.")
118
+ return "\n".join(lines)
119
+
120
+
121
+ def _service_answer(context: AssistantContext) -> str:
122
+ lines = ["Services picture:"]
123
+ relevant = _relevant(context, ("service", "failed", "boot", "unit"))
124
+ if relevant:
125
+ lines.append("- What LOGICROOT found:")
126
+ lines.extend(f" * {finding}" for finding in relevant[:5])
127
+ lines.append("- Suggested next steps:")
128
+ lines.append(" * logicroot services --failed (everything that failed)")
129
+ lines.append(" * logicroot logs --errors (why they failed)")
130
+ else:
131
+ lines.append("- No service findings. 'logicroot services' lists everything; '--failed' filters to problems.")
132
+ return "\n".join(lines)
133
+
134
+
135
+ def _network_answer(context: AssistantContext) -> str:
136
+ lines = ["Network picture:"]
137
+ relevant = _relevant(context, ("network", "interface", "gateway", "error"))
138
+ if relevant:
139
+ lines.append("- What LOGICROOT found:")
140
+ lines.extend(f" * {finding}" for finding in relevant[:5])
141
+ else:
142
+ lines.append("- No network findings.")
143
+ lines.append("- Suggested next steps:")
144
+ lines.append(" * logicroot network (interfaces, gateway, DNS)")
145
+ lines.append(" * logicroot network connections (who is talking to whom)")
146
+ lines.append(" * logicroot network ping <host> (reachability)")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def _gpu_answer(context: AssistantContext) -> str:
151
+ lines = ["GPU picture:"]
152
+ lines.append(f"- GPUs: {context.gpu_summary or 'none detected'}")
153
+ relevant = _relevant(context, ("gpu", "vram", "nvidia", "temperature"))
154
+ if relevant:
155
+ lines.append("- What LOGICROOT found:")
156
+ lines.extend(f" * {finding}" for finding in relevant[:5])
157
+ lines.append("- Suggested next step: logicroot gpu (live utilisation, memory, temperature)")
158
+ return "\n".join(lines)
159
+
160
+
161
+ def _battery_answer(context: AssistantContext) -> str:
162
+ lines = ["Battery picture:"]
163
+ relevant = _relevant(context, ("battery", "charge", "power"))
164
+ if relevant:
165
+ lines.append("- What LOGICROOT found:")
166
+ lines.extend(f" * {finding}" for finding in relevant[:5])
167
+ else:
168
+ lines.append("- No battery findings. 'logicroot hardware' shows the detected battery, if any.")
169
+ return "\n".join(lines)
170
+
171
+
172
+ def _help_answer() -> str:
173
+ return "\n".join(
174
+ [
175
+ "I answer from what LOGICROOT measured on this machine - findings first, hardware second.",
176
+ "Try questions like:",
177
+ "- 'why is my disk full?' / 'what is using my memory?' / 'why is it slow?'",
178
+ "Or skip me and go straight to the source:",
179
+ "- logicroot diagnose (full health check with suggestions)",
180
+ "- logicroot processes (what is running right now)",
181
+ "- logicroot hardware (what this machine is)",
182
+ ]
183
+ )
184
+
185
+
186
+ def _general_answer(question: str, context: AssistantContext) -> str:
187
+ lines = [f"Here is what I know about '{question}':"]
188
+ if context.findings:
189
+ lines.append("- Current findings:")
190
+ lines.extend(f" * {finding}" for finding in context.findings[:5])
191
+ lines.append("For the full picture with suggestions, run: logicroot diagnose")
192
+ else:
193
+ lines.append("- LOGICROOT reports no active findings on this machine.")
194
+ lines.append("For the full picture, run: logicroot diagnose")
195
+ return "\n".join(lines)
196
+
197
+
198
+ __all__ = ["LocalProvider"]
@@ -0,0 +1,107 @@
1
+ """Assistant orchestration: answer locally by default, network only on demand.
2
+
3
+ ``Assistant.ask`` implements the opt-in contract in one place:
4
+
5
+ * **Default: local.** The offline provider answers from LOGICROOT data. Nothing
6
+ leaves the machine, nothing needs configuring.
7
+ * **``--ai``: network.** The OpenAI-compatible provider answers, but only when
8
+ ``LOGICROOT_AI_API_KEY`` is set. Without the key the user gets setup instructions
9
+ instead of a silent fallback that pretends to be AI.
10
+
11
+ Either way the reply is text. The assistant has no tools, no shell, and no
12
+ ability to act - it can suggest ``logicroot diagnose``, but only the user can run it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from logicroot.assistant.base import AssistantProvider, sanitise_question
21
+ from logicroot.assistant.context import build_context
22
+ from logicroot.assistant.local import LocalProvider
23
+ from logicroot.assistant.models import (
24
+ AssistantConfig,
25
+ AssistantContext,
26
+ AssistantReply,
27
+ ProviderKind,
28
+ )
29
+ from logicroot.assistant.openai import OpenAICompatibleProvider
30
+ from logicroot.utils.logging import get_logger
31
+
32
+ logger = get_logger("assistant.manager")
33
+
34
+
35
+ @dataclass
36
+ class Assistant:
37
+ """Answers questions about this machine, locally unless asked otherwise."""
38
+
39
+ config: AssistantConfig = field(default_factory=AssistantConfig)
40
+ local: LocalProvider = field(default_factory=LocalProvider)
41
+
42
+ def provider_for(self, *, use_ai: bool) -> AssistantProvider:
43
+ """Pick the backend: network only when requested *and* configured."""
44
+ if use_ai and self.config.network_allowed:
45
+ provider = OpenAICompatibleProvider.from_environment(timeout=self.config.timeout)
46
+ if provider is not None:
47
+ if self.config.model:
48
+ provider.model = self.config.model
49
+ if self.config.base_url:
50
+ provider.base_url = self.config.base_url.rstrip("/")
51
+ return provider
52
+ return _UnconfiguredProvider()
53
+ return self.local
54
+
55
+ def ask(
56
+ self,
57
+ question: str,
58
+ context: AssistantContext | None = None,
59
+ *,
60
+ use_ai: bool = False,
61
+ profile: Any | None = None,
62
+ findings: tuple[str, ...] = (),
63
+ top_processes: tuple[str, ...] = (),
64
+ ) -> AssistantReply:
65
+ """Answer ``question``, building context from ``profile`` when needed."""
66
+ clean = sanitise_question(question)
67
+ active = context or build_context(
68
+ profile,
69
+ findings=findings,
70
+ top_processes=top_processes,
71
+ max_chars=self.config.max_context_chars,
72
+ )
73
+ provider = self.provider_for(use_ai=use_ai)
74
+ logger.debug("assistant answering via %s", provider.name)
75
+ return provider.ask(clean, active)
76
+
77
+
78
+ @dataclass
79
+ class _UnconfiguredProvider:
80
+ """Explains how to enable AI when ``--ai`` was passed without a key."""
81
+
82
+ name: str = "openai-compatible"
83
+
84
+ @property
85
+ def available(self) -> bool:
86
+ return False
87
+
88
+ def ask(self, question: str, context: AssistantContext) -> AssistantReply:
89
+ _ = (question, context)
90
+ return AssistantReply(
91
+ text=(
92
+ "AI answers need two things:\n"
93
+ "1. A key: set LOGICROOT_AI_API_KEY (or LOGICROOT_AI_BASE_URL plus a key for a local server).\n"
94
+ "2. Consent per question: re-run with --ai.\n\n"
95
+ "Nothing was sent anywhere. Run the same question without --ai for the offline answer."
96
+ ),
97
+ provider=ProviderKind.OPENAI_COMPATIBLE,
98
+ model="",
99
+ )
100
+
101
+
102
+ def for_system(config: AssistantConfig | None = None) -> Assistant:
103
+ """Build an assistant with the given (or default disabled) configuration."""
104
+ return Assistant(config=config or AssistantConfig())
105
+
106
+
107
+ __all__ = ["Assistant", "for_system"]