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.
Files changed (95) hide show
  1. sifty/__init__.py +3 -0
  2. sifty/__main__.py +6 -0
  3. sifty/ai/__init__.py +1 -0
  4. sifty/ai/advisor.py +71 -0
  5. sifty/ai/agent.py +227 -0
  6. sifty/ai/client.py +176 -0
  7. sifty/ai/context.py +92 -0
  8. sifty/ai/tools.py +546 -0
  9. sifty/cli/__init__.py +1 -0
  10. sifty/cli/app.py +475 -0
  11. sifty/cli/commands/__init__.py +1 -0
  12. sifty/cli/commands/ai_group.py +62 -0
  13. sifty/cli/commands/apps.py +190 -0
  14. sifty/cli/commands/cleanup.py +181 -0
  15. sifty/cli/commands/config_cmd.py +133 -0
  16. sifty/cli/commands/disk.py +124 -0
  17. sifty/cli/commands/junk.py +87 -0
  18. sifty/cli/commands/optimize.py +92 -0
  19. sifty/cli/commands/organize.py +85 -0
  20. sifty/cli/commands/profile.py +58 -0
  21. sifty/cli/commands/purge.py +97 -0
  22. sifty/cli/commands/schedule.py +66 -0
  23. sifty/cli/commands/services.py +64 -0
  24. sifty/cli/commands/startup.py +57 -0
  25. sifty/cli/commands/updates.py +75 -0
  26. sifty/cli/commands/watch.py +58 -0
  27. sifty/cli/output.py +26 -0
  28. sifty/console.py +49 -0
  29. sifty/core/__init__.py +4 -0
  30. sifty/core/apps.py +113 -0
  31. sifty/core/checkup.py +156 -0
  32. sifty/core/cleanup.py +141 -0
  33. sifty/core/disk.py +117 -0
  34. sifty/core/history.py +154 -0
  35. sifty/core/junk.py +395 -0
  36. sifty/core/leftovers.py +222 -0
  37. sifty/core/models.py +114 -0
  38. sifty/core/monitor.py +113 -0
  39. sifty/core/optimize.py +225 -0
  40. sifty/core/organize.py +138 -0
  41. sifty/core/profiles.py +66 -0
  42. sifty/core/purge.py +132 -0
  43. sifty/core/registry_scan.py +129 -0
  44. sifty/core/safety.py +152 -0
  45. sifty/core/schedule.py +78 -0
  46. sifty/core/selfupdate.py +77 -0
  47. sifty/core/services.py +70 -0
  48. sifty/core/startup.py +111 -0
  49. sifty/core/undo.py +28 -0
  50. sifty/core/updates.py +65 -0
  51. sifty/core/vcs.py +163 -0
  52. sifty/core/watch.py +22 -0
  53. sifty/infra/__init__.py +1 -0
  54. sifty/infra/config.py +165 -0
  55. sifty/infra/logging.py +98 -0
  56. sifty/tui/__init__.py +1 -0
  57. sifty/tui/app.py +143 -0
  58. sifty/tui/commands.py +44 -0
  59. sifty/tui/modals.py +32 -0
  60. sifty/tui/screens/__init__.py +1 -0
  61. sifty/tui/screens/path_picker.py +72 -0
  62. sifty/tui/state.py +44 -0
  63. sifty/tui/styles.tcss +264 -0
  64. sifty/tui/views/__init__.py +64 -0
  65. sifty/tui/views/ai.py +354 -0
  66. sifty/tui/views/apps.py +300 -0
  67. sifty/tui/views/base.py +16 -0
  68. sifty/tui/views/cleanup.py +215 -0
  69. sifty/tui/views/disk.py +119 -0
  70. sifty/tui/views/group.py +107 -0
  71. sifty/tui/views/home.py +202 -0
  72. sifty/tui/views/junk.py +141 -0
  73. sifty/tui/views/monitor.py +115 -0
  74. sifty/tui/views/optimize.py +111 -0
  75. sifty/tui/views/purge.py +183 -0
  76. sifty/tui/views/reports.py +100 -0
  77. sifty/tui/views/services.py +116 -0
  78. sifty/tui/views/startup.py +104 -0
  79. sifty/tui/views/updates.py +125 -0
  80. sifty/tui/widgets.py +28 -0
  81. sifty/windows/__init__.py +5 -0
  82. sifty/windows/admin.py +95 -0
  83. sifty/windows/hyperv.py +67 -0
  84. sifty/windows/notify.py +26 -0
  85. sifty/windows/recyclebin.py +66 -0
  86. sifty/windows/registry.py +88 -0
  87. sifty/windows/scheduler.py +59 -0
  88. sifty/windows/services_api.py +68 -0
  89. sifty/windows/winget.py +46 -0
  90. sifty-0.6.0.dist-info/METADATA +254 -0
  91. sifty-0.6.0.dist-info/RECORD +95 -0
  92. sifty-0.6.0.dist-info/WHEEL +5 -0
  93. sifty-0.6.0.dist-info/entry_points.txt +2 -0
  94. sifty-0.6.0.dist-info/licenses/LICENSE +21 -0
  95. sifty-0.6.0.dist-info/top_level.txt +1 -0
sifty/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sifty — an AI-assisted Windows maintenance CLI that sifts the junk from the keep."""
2
+
3
+ __version__ = "0.6.0"
sifty/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m sifty`` as an alternative to the ``sifty`` console script."""
2
+
3
+ from .cli.app import entrypoint
4
+
5
+ if __name__ == "__main__":
6
+ entrypoint()
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)