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.
- logicroot/__init__.py +17 -0
- logicroot/__main__.py +8 -0
- logicroot/assistant/__init__.py +57 -0
- logicroot/assistant/base.py +85 -0
- logicroot/assistant/context.py +113 -0
- logicroot/assistant/local.py +198 -0
- logicroot/assistant/manager.py +107 -0
- logicroot/assistant/models.py +117 -0
- logicroot/assistant/openai.py +167 -0
- logicroot/cleanup/__init__.py +34 -0
- logicroot/cleanup/executor.py +259 -0
- logicroot/cleanup/manager.py +46 -0
- logicroot/cleanup/models.py +133 -0
- logicroot/cleanup/planner.py +219 -0
- logicroot/cli.py +558 -0
- logicroot/cmd/__init__.py +47 -0
- logicroot/cmd/_common.py +82 -0
- logicroot/cmd/ask.py +100 -0
- logicroot/cmd/cleanup.py +92 -0
- logicroot/cmd/diagnose.py +77 -0
- logicroot/cmd/gpu.py +50 -0
- logicroot/cmd/logs.py +177 -0
- logicroot/cmd/network.py +159 -0
- logicroot/cmd/processes.py +235 -0
- logicroot/cmd/services.py +206 -0
- logicroot/cmd/status.py +66 -0
- logicroot/cmd/storage.py +202 -0
- logicroot/config.py +419 -0
- logicroot/constants.py +226 -0
- logicroot/core/__init__.py +3 -0
- logicroot/core/capabilities.py +266 -0
- logicroot/core/command.py +266 -0
- logicroot/core/permissions.py +114 -0
- logicroot/core/platform.py +163 -0
- logicroot/core/validation.py +174 -0
- logicroot/diagnostics/__init__.py +21 -0
- logicroot/diagnostics/boot.py +154 -0
- logicroot/diagnostics/engine.py +200 -0
- logicroot/diagnostics/models.py +102 -0
- logicroot/diagnostics/rules.py +569 -0
- logicroot/exceptions.py +102 -0
- logicroot/hardware/__init__.py +8 -0
- logicroot/hardware/backend.py +199 -0
- logicroot/hardware/backends/__init__.py +9 -0
- logicroot/hardware/backends/linux.py +131 -0
- logicroot/hardware/backends/macos.py +874 -0
- logicroot/hardware/backends/powershell.py +102 -0
- logicroot/hardware/backends/windows.py +1182 -0
- logicroot/hardware/battery.py +123 -0
- logicroot/hardware/bluetooth.py +94 -0
- logicroot/hardware/cache.py +131 -0
- logicroot/hardware/capabilities.py +743 -0
- logicroot/hardware/context.py +149 -0
- logicroot/hardware/cpu.py +236 -0
- logicroot/hardware/discovery.py +234 -0
- logicroot/hardware/display.py +109 -0
- logicroot/hardware/gpu.py +243 -0
- logicroot/hardware/memory.py +88 -0
- logicroot/hardware/models.py +784 -0
- logicroot/hardware/network.py +313 -0
- logicroot/hardware/packages.py +107 -0
- logicroot/hardware/pci.py +187 -0
- logicroot/hardware/storage.py +192 -0
- logicroot/hardware/system.py +191 -0
- logicroot/hardware/systemd.py +86 -0
- logicroot/hardware/thermal.py +91 -0
- logicroot/hardware/tools.py +120 -0
- logicroot/hardware/usb.py +137 -0
- logicroot/logs/__init__.py +16 -0
- logicroot/logs/journal.py +160 -0
- logicroot/logs/manager.py +67 -0
- logicroot/logs/models.py +122 -0
- logicroot/network/__init__.py +38 -0
- logicroot/network/connections.py +175 -0
- logicroot/network/foreign.py +604 -0
- logicroot/network/interfaces.py +277 -0
- logicroot/network/manager.py +151 -0
- logicroot/network/models.py +150 -0
- logicroot/network/ping.py +170 -0
- logicroot/processes/__init__.py +30 -0
- logicroot/processes/foreign.py +213 -0
- logicroot/processes/manager.py +412 -0
- logicroot/processes/models.py +175 -0
- logicroot/processes/reader.py +243 -0
- logicroot/providers/__init__.py +5 -0
- logicroot/providers/gpu/__init__.py +21 -0
- logicroot/providers/gpu/amd.py +135 -0
- logicroot/providers/gpu/base.py +99 -0
- logicroot/providers/gpu/collector.py +153 -0
- logicroot/providers/gpu/intel.py +114 -0
- logicroot/providers/gpu/nvidia.py +185 -0
- logicroot/providers/package/__init__.py +26 -0
- logicroot/providers/package/apt.py +26 -0
- logicroot/providers/package/base.py +127 -0
- logicroot/providers/package/dnf.py +37 -0
- logicroot/providers/package/pacman.py +25 -0
- logicroot/providers/package/zypper.py +24 -0
- logicroot/providers/services/__init__.py +6 -0
- logicroot/providers/services/base.py +40 -0
- logicroot/providers/services/systemd.py +11 -0
- logicroot/services/__init__.py +25 -0
- logicroot/services/foreign.py +230 -0
- logicroot/services/manager.py +149 -0
- logicroot/services/models.py +116 -0
- logicroot/services/systemd.py +200 -0
- logicroot/storage/__init__.py +27 -0
- logicroot/storage/analyzer.py +373 -0
- logicroot/storage/manager.py +86 -0
- logicroot/storage/models.py +128 -0
- logicroot/storage/scanner.py +252 -0
- logicroot/system/__init__.py +38 -0
- logicroot/system/foreign.py +373 -0
- logicroot/system/models.py +248 -0
- logicroot/system/monitor.py +145 -0
- logicroot/system/reader.py +629 -0
- logicroot/system/sampler.py +311 -0
- logicroot/ui/__init__.py +24 -0
- logicroot/ui/app.py +186 -0
- logicroot/ui/console.py +63 -0
- logicroot/ui/launcher.py +57 -0
- logicroot/ui/panels.py +417 -0
- logicroot/ui/screens/__init__.py +25 -0
- logicroot/ui/screens/base.py +59 -0
- logicroot/ui/screens/cleanup.py +130 -0
- logicroot/ui/screens/dashboard.py +109 -0
- logicroot/ui/screens/diagnose.py +83 -0
- logicroot/ui/screens/gpu.py +87 -0
- logicroot/ui/screens/logs.py +107 -0
- logicroot/ui/screens/network.py +165 -0
- logicroot/ui/screens/processes.py +199 -0
- logicroot/ui/screens/services.py +173 -0
- logicroot/ui/screens/storage.py +147 -0
- logicroot/ui/theme.py +114 -0
- logicroot/ui/views.py +628 -0
- logicroot/ui/widgets/__init__.py +13 -0
- logicroot/ui/widgets/dialogs.py +65 -0
- logicroot/ui/widgets/gauges.py +113 -0
- logicroot/utils/__init__.py +1 -0
- logicroot/utils/fs.py +172 -0
- logicroot/utils/logging.py +183 -0
- logicroot/utils/serialize.py +168 -0
- logicroot/utils/text.py +144 -0
- logicroot-0.2.0.dist-info/METADATA +1188 -0
- logicroot-0.2.0.dist-info/RECORD +147 -0
- logicroot-0.2.0.dist-info/WHEEL +4 -0
- logicroot-0.2.0.dist-info/entry_points.txt +2 -0
- 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,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"]
|