sifty 0.6.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.
- sifty/__init__.py +3 -0
- sifty/__main__.py +6 -0
- sifty/ai/__init__.py +1 -0
- sifty/ai/advisor.py +71 -0
- sifty/ai/agent.py +227 -0
- sifty/ai/client.py +176 -0
- sifty/ai/context.py +92 -0
- sifty/ai/tools.py +546 -0
- sifty/cli/__init__.py +1 -0
- sifty/cli/app.py +475 -0
- sifty/cli/commands/__init__.py +1 -0
- sifty/cli/commands/ai_group.py +62 -0
- sifty/cli/commands/apps.py +190 -0
- sifty/cli/commands/cleanup.py +181 -0
- sifty/cli/commands/config_cmd.py +133 -0
- sifty/cli/commands/disk.py +124 -0
- sifty/cli/commands/junk.py +87 -0
- sifty/cli/commands/optimize.py +92 -0
- sifty/cli/commands/organize.py +85 -0
- sifty/cli/commands/profile.py +58 -0
- sifty/cli/commands/purge.py +97 -0
- sifty/cli/commands/schedule.py +66 -0
- sifty/cli/commands/services.py +64 -0
- sifty/cli/commands/startup.py +57 -0
- sifty/cli/commands/updates.py +75 -0
- sifty/cli/commands/watch.py +58 -0
- sifty/cli/output.py +26 -0
- sifty/console.py +49 -0
- sifty/core/__init__.py +4 -0
- sifty/core/apps.py +113 -0
- sifty/core/checkup.py +156 -0
- sifty/core/cleanup.py +141 -0
- sifty/core/disk.py +117 -0
- sifty/core/history.py +154 -0
- sifty/core/junk.py +395 -0
- sifty/core/leftovers.py +222 -0
- sifty/core/models.py +114 -0
- sifty/core/monitor.py +113 -0
- sifty/core/optimize.py +225 -0
- sifty/core/organize.py +138 -0
- sifty/core/profiles.py +66 -0
- sifty/core/purge.py +132 -0
- sifty/core/registry_scan.py +129 -0
- sifty/core/safety.py +152 -0
- sifty/core/schedule.py +78 -0
- sifty/core/selfupdate.py +77 -0
- sifty/core/services.py +70 -0
- sifty/core/startup.py +111 -0
- sifty/core/undo.py +28 -0
- sifty/core/updates.py +65 -0
- sifty/core/vcs.py +163 -0
- sifty/core/watch.py +22 -0
- sifty/infra/__init__.py +1 -0
- sifty/infra/config.py +165 -0
- sifty/infra/logging.py +98 -0
- sifty/tui/__init__.py +1 -0
- sifty/tui/app.py +143 -0
- sifty/tui/commands.py +44 -0
- sifty/tui/modals.py +32 -0
- sifty/tui/screens/__init__.py +1 -0
- sifty/tui/screens/path_picker.py +72 -0
- sifty/tui/state.py +44 -0
- sifty/tui/styles.tcss +264 -0
- sifty/tui/views/__init__.py +64 -0
- sifty/tui/views/ai.py +354 -0
- sifty/tui/views/apps.py +300 -0
- sifty/tui/views/base.py +16 -0
- sifty/tui/views/cleanup.py +215 -0
- sifty/tui/views/disk.py +119 -0
- sifty/tui/views/group.py +107 -0
- sifty/tui/views/home.py +202 -0
- sifty/tui/views/junk.py +141 -0
- sifty/tui/views/monitor.py +115 -0
- sifty/tui/views/optimize.py +111 -0
- sifty/tui/views/purge.py +183 -0
- sifty/tui/views/reports.py +100 -0
- sifty/tui/views/services.py +116 -0
- sifty/tui/views/startup.py +104 -0
- sifty/tui/views/updates.py +125 -0
- sifty/tui/widgets.py +28 -0
- sifty/windows/__init__.py +5 -0
- sifty/windows/admin.py +95 -0
- sifty/windows/hyperv.py +67 -0
- sifty/windows/notify.py +26 -0
- sifty/windows/recyclebin.py +66 -0
- sifty/windows/registry.py +88 -0
- sifty/windows/scheduler.py +59 -0
- sifty/windows/services_api.py +68 -0
- sifty/windows/winget.py +46 -0
- sifty-0.6.0.dist-info/METADATA +254 -0
- sifty-0.6.0.dist-info/RECORD +95 -0
- sifty-0.6.0.dist-info/WHEEL +5 -0
- sifty-0.6.0.dist-info/entry_points.txt +2 -0
- sifty-0.6.0.dist-info/licenses/LICENSE +21 -0
- sifty-0.6.0.dist-info/top_level.txt +1 -0
sifty/__init__.py
ADDED
sifty/__main__.py
ADDED
sifty/ai/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local AI layer (Ollama) — advises, never acts on its own."""
|
sifty/ai/advisor.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""AI advisory prompts.
|
|
2
|
+
|
|
3
|
+
The advisor only ever receives *metadata* — names, sizes, paths, extensions,
|
|
4
|
+
counts — never file contents. It explains and recommends; it never deletes.
|
|
5
|
+
The calling command does the acting, with the usual dry-run/confirm safeguards.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .client import OllamaClient, OllamaUnavailable
|
|
11
|
+
|
|
12
|
+
SYSTEM_PROMPT = (
|
|
13
|
+
"You are Sifty, a careful Windows maintenance assistant embedded in a CLI/TUI "
|
|
14
|
+
"tool. You are given only file/app metadata, never file contents. Be concise, "
|
|
15
|
+
"practical, and cautious; when unsure whether something is safe to remove, say so. "
|
|
16
|
+
"Format answers in Markdown.\n\n"
|
|
17
|
+
"To remove an installed program, ALWAYS recommend a proper uninstall — Sifty's own "
|
|
18
|
+
"`apps` command (which uses winget under the hood), `winget uninstall`, or Windows "
|
|
19
|
+
"Settings > Apps. NEVER tell the user to manually delete a program's folder under "
|
|
20
|
+
"C:\\Program Files, C:\\Program Files (x86), or to hand-edit files in C:\\Windows, "
|
|
21
|
+
"ProgramData, or their personal documents — Sifty refuses those paths anyway, and "
|
|
22
|
+
"manual deletion leaves the registry and the system in a broken state. Do not "
|
|
23
|
+
"suggest `DISM` or `sfc /scannow` unless the user explicitly reports system file "
|
|
24
|
+
"corruption; they are not part of uninstalling an app.\n\n"
|
|
25
|
+
"Sifty deletes safely (to the Recycle Bin, dry-run by default), so point users at "
|
|
26
|
+
"Sifty's commands rather than destructive manual steps."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Back-compat alias for internal callers.
|
|
30
|
+
_SYSTEM = SYSTEM_PROMPT
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe(client: OllamaClient, user_prompt: str) -> str | None:
|
|
34
|
+
"""Run a prompt, returning None if the AI is unavailable."""
|
|
35
|
+
if not client.is_available():
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return client.chat(_SYSTEM, user_prompt)
|
|
39
|
+
except OllamaUnavailable:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def explain_item(client: OllamaClient, name: str, path: str, size_human: str) -> str | None:
|
|
44
|
+
"""Explain what an item is and whether it's safe to remove."""
|
|
45
|
+
return _safe(
|
|
46
|
+
client,
|
|
47
|
+
f"What is this Windows item, and is it generally safe to delete?\n"
|
|
48
|
+
f"Name: {name}\nPath: {path}\nSize: {size_human}\n"
|
|
49
|
+
f"Answer in 2-3 sentences.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def summarize_disk(client: OllamaClient, items: list[tuple[str, str]], question: str) -> str | None:
|
|
54
|
+
"""Answer a natural-language question about the biggest disk items."""
|
|
55
|
+
listing = "\n".join(f"- {name}: {size}" for name, size in items)
|
|
56
|
+
return _safe(
|
|
57
|
+
client,
|
|
58
|
+
f"Here are the largest items in a directory:\n{listing}\n\n"
|
|
59
|
+
f"User question: {question}\n"
|
|
60
|
+
f"Give a brief, practical answer and flag anything risky to delete.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def suggest_organization(client: OllamaClient, sample_names: list[str]) -> str | None:
|
|
65
|
+
"""Propose a folder scheme for a messy directory from sample filenames."""
|
|
66
|
+
listing = "\n".join(f"- {n}" for n in sample_names[:40])
|
|
67
|
+
return _safe(
|
|
68
|
+
client,
|
|
69
|
+
f"These are sample filenames from a cluttered folder:\n{listing}\n\n"
|
|
70
|
+
f"Suggest a simple folder structure to organize them. Be concise.",
|
|
71
|
+
)
|
sifty/ai/agent.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""AI agent loop with autonomy levels.
|
|
2
|
+
|
|
3
|
+
The agent sends the user's request to Ollama with a tool registry. Ollama may
|
|
4
|
+
respond with one or more tool calls; the agent dispatches them (subject to the
|
|
5
|
+
autonomy level and a confirm callback), appends the results, and re-submits
|
|
6
|
+
until the model produces a plain text answer.
|
|
7
|
+
|
|
8
|
+
Autonomy levels:
|
|
9
|
+
``ask`` — confirm every ``low`` or ``high`` risk tool before running.
|
|
10
|
+
``low_risk_auto`` — auto-run ``low`` risk tools; confirm ``high`` ones.
|
|
11
|
+
``full_auto`` — run all tools automatically (still routes through safety.trash).
|
|
12
|
+
|
|
13
|
+
The active level is read from a small override file (set via the TUI) layered
|
|
14
|
+
over the static ``ai.autonomy`` config default — see :func:`current_autonomy`.
|
|
15
|
+
|
|
16
|
+
Models that don't emit ``tool_calls`` (not tool-capable) produce a plain reply
|
|
17
|
+
on the first iteration; the agent yields that as a :class:`FallbackEvent` so
|
|
18
|
+
callers can detect the downgrade.
|
|
19
|
+
|
|
20
|
+
Events are yielded as the agent progresses so callers (TUI, CLI) can display
|
|
21
|
+
each step live instead of waiting for the whole chain.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
from collections.abc import Callable, Iterator
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from ..infra.config import app_data_dir, load_config
|
|
32
|
+
from .client import OllamaClient, OllamaUnavailable
|
|
33
|
+
from .tools import TOOLS, Tool, ToolResult
|
|
34
|
+
from .tools import get as get_tool
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("sifty.ai")
|
|
37
|
+
|
|
38
|
+
_MAX_ITERATIONS = 10
|
|
39
|
+
_VALID_LEVELS = ("ask", "low_risk_auto", "full_auto")
|
|
40
|
+
|
|
41
|
+
# Appended to the system prompt in agentic mode so the model adds insight rather
|
|
42
|
+
# than re-dumping data the UI already renders as tables.
|
|
43
|
+
TOOL_USE_NOTE = (
|
|
44
|
+
"\n\nYou can call tools to inspect and maintain this machine. Tool results are "
|
|
45
|
+
"shown to the user directly (large results as tables), so do NOT repeat the raw "
|
|
46
|
+
"data back — give a short, useful interpretation and a clear recommendation. "
|
|
47
|
+
"Before any destructive action (clean_junk, uninstall_app, apply_updates) explain "
|
|
48
|
+
"what you're about to do; the user is asked to approve it."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Event types
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ToolCallEvent:
|
|
58
|
+
"""The model is requesting a tool call."""
|
|
59
|
+
tool_name: str
|
|
60
|
+
args: dict
|
|
61
|
+
risk: str # from the Tool definition
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ToolResultEvent:
|
|
66
|
+
"""A tool has been executed (or skipped due to a denied confirm)."""
|
|
67
|
+
tool_name: str
|
|
68
|
+
result: str
|
|
69
|
+
skipped: bool = False # True when the user declined to run it
|
|
70
|
+
table: ToolResult | None = None # structured output for rich UI rendering
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class FinalAnswerEvent:
|
|
75
|
+
"""The model produced a plain text reply — the agent is done."""
|
|
76
|
+
text: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class FallbackEvent:
|
|
81
|
+
"""The model doesn't support tools; plain advisory answer returned."""
|
|
82
|
+
text: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
AgentEvent = ToolCallEvent | ToolResultEvent | FinalAnswerEvent | FallbackEvent
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Autonomy: config default + user override file
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _override_file():
|
|
93
|
+
return app_data_dir() / "ai_state.json"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def autonomy_from_config(config=None) -> str:
|
|
97
|
+
config = config or load_config()
|
|
98
|
+
return config.section("ai").get("autonomy", "ask")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def current_autonomy(config=None) -> str:
|
|
102
|
+
"""The active autonomy level: user override file wins, else config default."""
|
|
103
|
+
path = _override_file()
|
|
104
|
+
if path.exists():
|
|
105
|
+
try:
|
|
106
|
+
val = json.loads(path.read_text(encoding="utf-8")).get("autonomy")
|
|
107
|
+
if val in _VALID_LEVELS:
|
|
108
|
+
return val
|
|
109
|
+
except (ValueError, OSError):
|
|
110
|
+
pass
|
|
111
|
+
return autonomy_from_config(config)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def set_autonomy(level: str) -> bool:
|
|
115
|
+
"""Persist the active autonomy level. Returns False for an invalid level."""
|
|
116
|
+
if level not in _VALID_LEVELS:
|
|
117
|
+
return False
|
|
118
|
+
try:
|
|
119
|
+
_override_file().write_text(json.dumps({"autonomy": level}), encoding="utf-8")
|
|
120
|
+
return True
|
|
121
|
+
except OSError:
|
|
122
|
+
logger.exception("failed to persist autonomy level")
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _needs_confirm(risk: str, autonomy: str) -> bool:
|
|
127
|
+
"""Return True if this risk level requires a confirm under the given autonomy."""
|
|
128
|
+
if risk == "read":
|
|
129
|
+
return False
|
|
130
|
+
if autonomy == "full_auto":
|
|
131
|
+
return False
|
|
132
|
+
if autonomy == "low_risk_auto" and risk == "low":
|
|
133
|
+
return False
|
|
134
|
+
return True # "ask" confirms low+high; "low_risk_auto" confirms high
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Agent loop
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def run(
|
|
142
|
+
client: OllamaClient,
|
|
143
|
+
messages: list[dict],
|
|
144
|
+
*,
|
|
145
|
+
autonomy: str = "ask",
|
|
146
|
+
confirm: Callable[[str], bool] | None = None,
|
|
147
|
+
tools: list[Tool] | None = None,
|
|
148
|
+
) -> Iterator[AgentEvent]:
|
|
149
|
+
"""Drive an agentic conversation and yield :data:`AgentEvent` instances.
|
|
150
|
+
|
|
151
|
+
``messages`` is the full Ollama-format conversation history (including the
|
|
152
|
+
system message). ``confirm`` is called with a human-readable prompt when a
|
|
153
|
+
tool requires confirmation; return ``True`` to proceed, ``False`` to skip.
|
|
154
|
+
Defaults to always-refuse (safe) when not provided.
|
|
155
|
+
"""
|
|
156
|
+
if confirm is None:
|
|
157
|
+
confirm = lambda _: False # noqa: E731 — safe default, not interactive
|
|
158
|
+
|
|
159
|
+
active_tools = tools if tools is not None else TOOLS
|
|
160
|
+
schemas = [t.to_ollama() for t in active_tools]
|
|
161
|
+
tool_map = {t.name: t for t in active_tools}
|
|
162
|
+
|
|
163
|
+
current_messages = list(messages)
|
|
164
|
+
|
|
165
|
+
for _ in range(_MAX_ITERATIONS):
|
|
166
|
+
try:
|
|
167
|
+
msg = client.chat_once(current_messages, tools=schemas)
|
|
168
|
+
except OllamaUnavailable as exc:
|
|
169
|
+
logger.warning("agent: Ollama unavailable: %s", exc)
|
|
170
|
+
yield FinalAnswerEvent(text=f"(AI unavailable: {exc})")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
tool_calls = msg.get("tool_calls") or []
|
|
174
|
+
|
|
175
|
+
if not tool_calls:
|
|
176
|
+
text = (msg.get("content") or "").strip() or "(no response)"
|
|
177
|
+
# A plain reply on the very first turn means the model ignored tools.
|
|
178
|
+
is_fallback = len(current_messages) == len(messages)
|
|
179
|
+
yield (FallbackEvent(text=text) if is_fallback else FinalAnswerEvent(text=text))
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
current_messages.append(msg)
|
|
183
|
+
|
|
184
|
+
for call in tool_calls:
|
|
185
|
+
fn = call.get("function", {})
|
|
186
|
+
name = fn.get("name", "")
|
|
187
|
+
raw_args = fn.get("arguments", {})
|
|
188
|
+
args = raw_args if isinstance(raw_args, dict) else {}
|
|
189
|
+
|
|
190
|
+
tool = tool_map.get(name) or get_tool(name)
|
|
191
|
+
if tool is None:
|
|
192
|
+
result_text = f"Unknown tool: {name}"
|
|
193
|
+
yield ToolResultEvent(tool_name=name, result=result_text)
|
|
194
|
+
current_messages.append({"role": "tool", "content": result_text})
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
yield ToolCallEvent(tool_name=name, args=args, risk=tool.risk)
|
|
198
|
+
|
|
199
|
+
if _needs_confirm(tool.risk, autonomy):
|
|
200
|
+
if not confirm(_confirm_prompt(tool, args)):
|
|
201
|
+
result_text = f"(user declined to run {name})"
|
|
202
|
+
yield ToolResultEvent(tool_name=name, result=result_text, skipped=True)
|
|
203
|
+
current_messages.append({"role": "tool", "content": result_text})
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
result = tool.handler(args)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
logger.exception("tool %s failed", name)
|
|
210
|
+
result = ToolResult(summary=f"Error running {name}: {exc}")
|
|
211
|
+
|
|
212
|
+
if isinstance(result, ToolResult):
|
|
213
|
+
result_text = result.summary
|
|
214
|
+
table = result if result.has_table else None
|
|
215
|
+
else:
|
|
216
|
+
result_text = str(result)
|
|
217
|
+
table = None
|
|
218
|
+
|
|
219
|
+
yield ToolResultEvent(tool_name=name, result=result_text, table=table)
|
|
220
|
+
current_messages.append({"role": "tool", "content": result_text})
|
|
221
|
+
|
|
222
|
+
yield FinalAnswerEvent(text="(agent reached the iteration limit without a final answer)")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _confirm_prompt(tool: Tool, args: dict) -> str:
|
|
226
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in args.items()) if args else ""
|
|
227
|
+
return f"Run {tool.name}({args_str}) — risk: {tool.risk}"
|
sifty/ai/client.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Thin wrapper around a local Ollama server.
|
|
2
|
+
|
|
3
|
+
Everything degrades gracefully: if Ollama isn't running, :func:`is_available`
|
|
4
|
+
returns ``False`` and callers fall back to non-AI behaviour instead of crashing.
|
|
5
|
+
|
|
6
|
+
Chat is **streamed**. A local model has to be loaded into RAM/VRAM on its first
|
|
7
|
+
request (a cold start that can take many seconds) and then emits tokens slowly.
|
|
8
|
+
A single blocking request with one wall-clock budget covers *all* of that at
|
|
9
|
+
once, so a model that is working — just slow — trips the timeout and the user
|
|
10
|
+
gets nothing. Streaming turns the timeout into "time between tokens" instead of
|
|
11
|
+
"time for the whole answer", so a slow-but-alive model keeps going and callers
|
|
12
|
+
can show progress as it arrives.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from ..infra.config import load_config
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OllamaUnavailable(Exception):
|
|
27
|
+
"""Raised when the local Ollama server can't be reached."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class OllamaClient:
|
|
32
|
+
host: str
|
|
33
|
+
model: str
|
|
34
|
+
timeout: float
|
|
35
|
+
keep_alive: str = "10m"
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_config(cls, config=None) -> OllamaClient:
|
|
39
|
+
config = config or load_config()
|
|
40
|
+
ai = config.section("ai")
|
|
41
|
+
return cls(
|
|
42
|
+
host=ai.get("host", "http://localhost:11434"),
|
|
43
|
+
model=ai.get("model", "qwen2.5:3b"),
|
|
44
|
+
timeout=float(ai.get("timeout_seconds", 60)),
|
|
45
|
+
keep_alive=str(ai.get("keep_alive", "10m")),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _timeout(self) -> httpx.Timeout:
|
|
49
|
+
"""Split timeout: connecting is instant; generation is the slow part.
|
|
50
|
+
|
|
51
|
+
``timeout`` bounds the wait *between* streamed chunks (and the initial
|
|
52
|
+
model load), not the total length of the answer.
|
|
53
|
+
"""
|
|
54
|
+
return httpx.Timeout(self.timeout, connect=5.0, write=10.0, pool=5.0)
|
|
55
|
+
|
|
56
|
+
def is_available(self) -> bool:
|
|
57
|
+
"""Return True if the Ollama server responds to a tags request."""
|
|
58
|
+
try:
|
|
59
|
+
resp = httpx.get(f"{self.host}/api/tags", timeout=3.0)
|
|
60
|
+
return resp.status_code == 200
|
|
61
|
+
except httpx.HTTPError:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def list_models(self) -> list[str]:
|
|
65
|
+
"""Return the names of locally-pulled Ollama models (empty on error)."""
|
|
66
|
+
try:
|
|
67
|
+
resp = httpx.get(f"{self.host}/api/tags", timeout=3.0)
|
|
68
|
+
if resp.status_code != 200:
|
|
69
|
+
return []
|
|
70
|
+
return [m.get("name", "") for m in resp.json().get("models", [])]
|
|
71
|
+
except httpx.HTTPError:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
def _payload(self, messages: list[dict], tools: list[dict] | None = None) -> dict:
|
|
75
|
+
payload: dict = {
|
|
76
|
+
"model": self.model,
|
|
77
|
+
"stream": True,
|
|
78
|
+
"keep_alive": self.keep_alive,
|
|
79
|
+
"messages": messages,
|
|
80
|
+
}
|
|
81
|
+
if tools:
|
|
82
|
+
payload["tools"] = tools
|
|
83
|
+
return payload
|
|
84
|
+
|
|
85
|
+
def _build_messages(self, system: str, user: str) -> list[dict]:
|
|
86
|
+
return [
|
|
87
|
+
{"role": "system", "content": system},
|
|
88
|
+
{"role": "user", "content": user},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def chat_stream(
|
|
92
|
+
self,
|
|
93
|
+
system: str,
|
|
94
|
+
user: str,
|
|
95
|
+
*,
|
|
96
|
+
messages: list[dict] | None = None,
|
|
97
|
+
) -> Iterator[str]:
|
|
98
|
+
"""Stream the assistant's reply token-by-token.
|
|
99
|
+
|
|
100
|
+
Pass ``messages`` (a full Ollama-format list) to continue a conversation;
|
|
101
|
+
omit it for a single-turn exchange built from ``system`` + ``user``.
|
|
102
|
+
Yields content chunks as they arrive. Raises :class:`OllamaUnavailable`
|
|
103
|
+
with a human-readable message on transport errors or timeouts.
|
|
104
|
+
"""
|
|
105
|
+
payload_messages = messages if messages is not None else self._build_messages(system, user)
|
|
106
|
+
try:
|
|
107
|
+
with httpx.stream(
|
|
108
|
+
"POST",
|
|
109
|
+
f"{self.host}/api/chat",
|
|
110
|
+
json=self._payload(payload_messages),
|
|
111
|
+
timeout=self._timeout(),
|
|
112
|
+
) as resp:
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
for line in resp.iter_lines():
|
|
115
|
+
if not line:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(line)
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
continue
|
|
121
|
+
chunk = data.get("message", {}).get("content", "")
|
|
122
|
+
if chunk:
|
|
123
|
+
yield chunk
|
|
124
|
+
if data.get("done"):
|
|
125
|
+
break
|
|
126
|
+
except httpx.TimeoutException as exc:
|
|
127
|
+
raise OllamaUnavailable(
|
|
128
|
+
"timed out — the model may still be loading; try again"
|
|
129
|
+
) from exc
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
raise OllamaUnavailable(str(exc)) from exc
|
|
132
|
+
|
|
133
|
+
def chat(
|
|
134
|
+
self,
|
|
135
|
+
system: str,
|
|
136
|
+
user: str,
|
|
137
|
+
*,
|
|
138
|
+
messages: list[dict] | None = None,
|
|
139
|
+
) -> str:
|
|
140
|
+
"""Send a chat and return the full assistant text.
|
|
141
|
+
|
|
142
|
+
Pass ``messages`` to continue a multi-turn conversation; omit for a
|
|
143
|
+
single-turn exchange. Consumes :meth:`chat_stream` internally.
|
|
144
|
+
"""
|
|
145
|
+
return "".join(self.chat_stream(system, user, messages=messages)).strip()
|
|
146
|
+
|
|
147
|
+
def chat_once(
|
|
148
|
+
self,
|
|
149
|
+
messages: list[dict],
|
|
150
|
+
tools: list[dict] | None = None,
|
|
151
|
+
) -> dict:
|
|
152
|
+
"""Non-streaming POST to /api/chat; returns the full assistant message dict.
|
|
153
|
+
|
|
154
|
+
Use for agentic steps where you need to inspect ``tool_calls`` before
|
|
155
|
+
deciding whether to dispatch or yield a final answer. The returned dict
|
|
156
|
+
is shaped like ``{"role": "assistant", "content": "...", "tool_calls": [...]}``;
|
|
157
|
+
``tool_calls`` is absent (or empty) when the model produces a plain reply.
|
|
158
|
+
Raises :class:`OllamaUnavailable` on network or HTTP errors.
|
|
159
|
+
"""
|
|
160
|
+
payload = self._payload(messages, tools)
|
|
161
|
+
payload["stream"] = False
|
|
162
|
+
try:
|
|
163
|
+
resp = httpx.post(
|
|
164
|
+
f"{self.host}/api/chat",
|
|
165
|
+
json=payload,
|
|
166
|
+
timeout=self._timeout(),
|
|
167
|
+
)
|
|
168
|
+
resp.raise_for_status()
|
|
169
|
+
data = resp.json()
|
|
170
|
+
return data.get("message", {"role": "assistant", "content": ""})
|
|
171
|
+
except httpx.TimeoutException as exc:
|
|
172
|
+
raise OllamaUnavailable(
|
|
173
|
+
"timed out — the model may still be loading; try again"
|
|
174
|
+
) from exc
|
|
175
|
+
except httpx.HTTPError as exc:
|
|
176
|
+
raise OllamaUnavailable(str(exc)) from exc
|
sifty/ai/context.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Machine context snapshot for the AI advisor.
|
|
2
|
+
|
|
3
|
+
Builds a compact, *metadata-only* description of the current system state —
|
|
4
|
+
volumes, junk totals, recent history — that is injected into the AI system
|
|
5
|
+
prompt so it can give advice that is specific to *this* machine.
|
|
6
|
+
|
|
7
|
+
Rules that must never change:
|
|
8
|
+
- File *contents* are never included, only names, sizes, paths, and counts.
|
|
9
|
+
- Building the snapshot must degrade gracefully (any OS call can fail).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..console import human_size
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build(*, include_junk: bool = True, include_volumes: bool = True,
|
|
18
|
+
include_history: bool = True) -> str:
|
|
19
|
+
"""Return a Markdown-formatted system context string for injection into prompts."""
|
|
20
|
+
sections: list[str] = []
|
|
21
|
+
|
|
22
|
+
if include_volumes:
|
|
23
|
+
section = _volume_section()
|
|
24
|
+
if section:
|
|
25
|
+
sections.append(section)
|
|
26
|
+
|
|
27
|
+
if include_junk:
|
|
28
|
+
section = _junk_section()
|
|
29
|
+
if section:
|
|
30
|
+
sections.append(section)
|
|
31
|
+
|
|
32
|
+
if include_history:
|
|
33
|
+
section = _history_section()
|
|
34
|
+
if section:
|
|
35
|
+
sections.append(section)
|
|
36
|
+
|
|
37
|
+
if not sections:
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
return "## Current machine context\n\n" + "\n\n".join(sections)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _volume_section() -> str:
|
|
44
|
+
try:
|
|
45
|
+
from ..core import disk
|
|
46
|
+
vols = disk.volumes()
|
|
47
|
+
except Exception:
|
|
48
|
+
return ""
|
|
49
|
+
if not vols:
|
|
50
|
+
return ""
|
|
51
|
+
lines = ["**Disk volumes:**"]
|
|
52
|
+
for v in vols:
|
|
53
|
+
lines.append(
|
|
54
|
+
f"- {v.mountpoint} ({v.fstype}): {human_size(v.free)} free / "
|
|
55
|
+
f"{human_size(v.total)} total ({v.percent:.0f}% used)"
|
|
56
|
+
)
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _junk_section() -> str:
|
|
61
|
+
try:
|
|
62
|
+
from ..core import junk
|
|
63
|
+
cats = junk.scan()
|
|
64
|
+
except Exception:
|
|
65
|
+
return ""
|
|
66
|
+
total_bytes = sum(c.size for c in cats)
|
|
67
|
+
total_files = sum(c.file_count for c in cats)
|
|
68
|
+
if not cats:
|
|
69
|
+
return ""
|
|
70
|
+
lines = [f"**Junk scan:** {total_files:,} files, {human_size(total_bytes)} reclaimable"]
|
|
71
|
+
for c in cats:
|
|
72
|
+
if c.size > 0:
|
|
73
|
+
lines.append(f"- {c.category.label}: {human_size(c.size)} ({c.file_count:,} files)")
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _history_section() -> str:
|
|
78
|
+
try:
|
|
79
|
+
from ..core import history
|
|
80
|
+
runs = history.recent_runs(5)
|
|
81
|
+
summ = history.summary()
|
|
82
|
+
except Exception:
|
|
83
|
+
return ""
|
|
84
|
+
if not runs:
|
|
85
|
+
return ""
|
|
86
|
+
lines = [
|
|
87
|
+
f"**Cleanup history:** {summ['runs']} runs, "
|
|
88
|
+
f"{human_size(summ['bytes_freed'])} reclaimed total"
|
|
89
|
+
]
|
|
90
|
+
for r in runs[:3]:
|
|
91
|
+
lines.append(f"- {r.ts[:10]}: {r.action} — {human_size(r.bytes_freed)} freed")
|
|
92
|
+
return "\n".join(lines)
|