hal0ai 0.9.8__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 (311) hide show
  1. hal0/__init__.py +18 -0
  2. hal0/activity/__init__.py +433 -0
  3. hal0/agents/__init__.py +31 -0
  4. hal0/agents/budget.py +565 -0
  5. hal0/agents/hermes/__init__.py +16 -0
  6. hal0/agents/hermes/driver.py +400 -0
  7. hal0/agents/hermes/plugins/__init__.py +6 -0
  8. hal0/agents/hermes/plugins/memory_hindsight/README.md +80 -0
  9. hal0/agents/hermes/plugins/memory_hindsight/__init__.py +33 -0
  10. hal0/agents/hermes/plugins/memory_hindsight/_client.py +179 -0
  11. hal0/agents/hermes/plugins/memory_hindsight/plugin.yaml +10 -0
  12. hal0/agents/hermes/plugins/memory_hindsight/provider.py +294 -0
  13. hal0/agents/hermes_provision.py +5314 -0
  14. hal0/agents/hermes_refresh.py +32 -0
  15. hal0/agents/hermes_templates/AGENTS.md.j2 +61 -0
  16. hal0/agents/hermes_templates/HERMES.md.j2 +44 -0
  17. hal0/agents/hermes_templates/MCP-CLIENTS.md.j2 +93 -0
  18. hal0/agents/hermes_templates/SOUL.md.j2 +44 -0
  19. hal0/agents/hermes_templates/STATE.md.j2 +28 -0
  20. hal0/agents/manager.py +675 -0
  21. hal0/agents/mcp_client.py +282 -0
  22. hal0/agents/opencode/__init__.py +7 -0
  23. hal0/agents/opencode/driver.py +164 -0
  24. hal0/agents/persona.py +94 -0
  25. hal0/agents/personas.py +625 -0
  26. hal0/agents/pi_coder/__init__.py +7 -0
  27. hal0/agents/pi_coder/driver.py +324 -0
  28. hal0/agents/pi_coder/plugins/hal0-memory/index.ts +582 -0
  29. hal0/agents/pi_coder/plugins/hal0-provider/index.ts +156 -0
  30. hal0/agents/pi_coder/themes/hal0.json +58 -0
  31. hal0/agents/provision_engine.py +445 -0
  32. hal0/agents/turnstone/__init__.py +7 -0
  33. hal0/agents/turnstone/driver.py +197 -0
  34. hal0/agents/turnstone_provision.py +871 -0
  35. hal0/api/__init__.py +1739 -0
  36. hal0/api/_audit.py +74 -0
  37. hal0/api/_env_store.py +173 -0
  38. hal0/api/_redact.py +101 -0
  39. hal0/api/_settings_apply.py +310 -0
  40. hal0/api/agents/__init__.py +1 -0
  41. hal0/api/agents/_auth.py +242 -0
  42. hal0/api/agents/budget.py +371 -0
  43. hal0/api/agents/chat_proxy.py +572 -0
  44. hal0/api/agents/memory_stats.py +232 -0
  45. hal0/api/agents/personas.py +414 -0
  46. hal0/api/agents/restart.py +262 -0
  47. hal0/api/deps.py +73 -0
  48. hal0/api/image_cache.py +176 -0
  49. hal0/api/mcp_mount.py +291 -0
  50. hal0/api/middleware/__init__.py +0 -0
  51. hal0/api/middleware/error_codes.py +142 -0
  52. hal0/api/middleware/log_scrub.py +91 -0
  53. hal0/api/middleware/request_id.py +31 -0
  54. hal0/api/openrouter/__init__.py +14 -0
  55. hal0/api/openrouter/_loopback.py +86 -0
  56. hal0/api/openrouter/auth.py +47 -0
  57. hal0/api/plugins/__init__.py +28 -0
  58. hal0/api/plugins/manifest_proxy.py +600 -0
  59. hal0/api/routes/__init__.py +0 -0
  60. hal0/api/routes/_memory_subgraph.py +156 -0
  61. hal0/api/routes/activity.py +217 -0
  62. hal0/api/routes/agents.py +321 -0
  63. hal0/api/routes/approvals.py +200 -0
  64. hal0/api/routes/backends.py +455 -0
  65. hal0/api/routes/benchmarks.py +480 -0
  66. hal0/api/routes/board.py +383 -0
  67. hal0/api/routes/board_chat.py +1327 -0
  68. hal0/api/routes/board_ws.py +177 -0
  69. hal0/api/routes/capabilities.py +105 -0
  70. hal0/api/routes/chat_templates.py +141 -0
  71. hal0/api/routes/comfyui.py +951 -0
  72. hal0/api/routes/config.py +328 -0
  73. hal0/api/routes/dashboard_layout.py +168 -0
  74. hal0/api/routes/events.py +207 -0
  75. hal0/api/routes/hardware.py +671 -0
  76. hal0/api/routes/health.py +299 -0
  77. hal0/api/routes/hf.py +195 -0
  78. hal0/api/routes/images.py +53 -0
  79. hal0/api/routes/installer.py +696 -0
  80. hal0/api/routes/journal.py +320 -0
  81. hal0/api/routes/logs.py +250 -0
  82. hal0/api/routes/mcp.py +895 -0
  83. hal0/api/routes/memory.py +1205 -0
  84. hal0/api/routes/memory_admin.py +674 -0
  85. hal0/api/routes/meta.py +92 -0
  86. hal0/api/routes/models.py +2509 -0
  87. hal0/api/routes/npu.py +407 -0
  88. hal0/api/routes/ports.py +46 -0
  89. hal0/api/routes/power.py +138 -0
  90. hal0/api/routes/profiles.py +329 -0
  91. hal0/api/routes/providers.py +638 -0
  92. hal0/api/routes/proxmox.py +227 -0
  93. hal0/api/routes/secrets.py +243 -0
  94. hal0/api/routes/services.py +285 -0
  95. hal0/api/routes/services_health.py +276 -0
  96. hal0/api/routes/settings.py +564 -0
  97. hal0/api/routes/slots.py +1846 -0
  98. hal0/api/routes/stacks.py +547 -0
  99. hal0/api/routes/throughput.py +154 -0
  100. hal0/api/routes/updater.py +963 -0
  101. hal0/api/routes/v1.py +1542 -0
  102. hal0/bench/__init__.py +20 -0
  103. hal0/bench/cli.py +1034 -0
  104. hal0/bench/control.py +127 -0
  105. hal0/bench/evalrun.py +516 -0
  106. hal0/bench/evals/agentic/fixtures/cipher-chain/README.md +2 -0
  107. hal0/bench/evals/agentic/fixtures/cipher-chain/secret.txt +1 -0
  108. hal0/bench/evals/agentic/fixtures/codebase-combine/README.md +2 -0
  109. hal0/bench/evals/agentic/fixtures/codebase-combine/lib/util.py +5 -0
  110. hal0/bench/evals/agentic/fixtures/codebase-combine/src/config.py +5 -0
  111. hal0/bench/evals/agentic/fixtures/dep-trace/a.py +8 -0
  112. hal0/bench/evals/agentic/fixtures/dep-trace/keys.py +3 -0
  113. hal0/bench/evals/agentic/fixtures/dep-trace/registry.json +1 -0
  114. hal0/bench/evals/agentic/fixtures/grep-hunt/deep/nested/license.conf +1 -0
  115. hal0/bench/evals/agentic/fixtures/grep-hunt/deep/readme.txt +1 -0
  116. hal0/bench/evals/agentic/fixtures/grep-hunt/mod_1.py +2 -0
  117. hal0/bench/evals/agentic/fixtures/grep-hunt/mod_2.py +2 -0
  118. hal0/bench/evals/agentic/fixtures/grep-hunt/mod_3.py +2 -0
  119. hal0/bench/evals/agentic/fixtures/grep-hunt/pods.yaml +3 -0
  120. hal0/bench/evals/agentic/fixtures/loop-aggregate/notes.md +3 -0
  121. hal0/bench/evals/agentic/fixtures/loop-aggregate/orders.csv +8 -0
  122. hal0/bench/parsers.py +250 -0
  123. hal0/bench/planner.py +409 -0
  124. hal0/bench/publish.py +197 -0
  125. hal0/bench/regress.py +117 -0
  126. hal0/bench/runner.py +587 -0
  127. hal0/bench/schema.py +271 -0
  128. hal0/bench/store.py +275 -0
  129. hal0/bench/suites.py +181 -0
  130. hal0/board/__init__.py +271 -0
  131. hal0/bundles/__init__.py +33 -0
  132. hal0/bundles/eligibility.py +101 -0
  133. hal0/bundles/schema.py +226 -0
  134. hal0/bundles/tiers.py +138 -0
  135. hal0/capabilities/__init__.py +22 -0
  136. hal0/capabilities/catalog.py +978 -0
  137. hal0/capabilities/config.py +411 -0
  138. hal0/capabilities/orchestrator.py +889 -0
  139. hal0/capabilities/profile_fit.py +52 -0
  140. hal0/cli/__init__.py +1 -0
  141. hal0/cli/_shared.py +136 -0
  142. hal0/cli/agent_commands.py +1502 -0
  143. hal0/cli/agent_shim.py +753 -0
  144. hal0/cli/app_commands.py +158 -0
  145. hal0/cli/bench_commands.py +33 -0
  146. hal0/cli/capabilities_commands.py +332 -0
  147. hal0/cli/comfyui_commands.py +85 -0
  148. hal0/cli/config_commands.py +257 -0
  149. hal0/cli/doctor_commands.py +1420 -0
  150. hal0/cli/doctor_verify.py +385 -0
  151. hal0/cli/main.py +307 -0
  152. hal0/cli/mcp_commands.py +373 -0
  153. hal0/cli/memory_bank_commands.py +412 -0
  154. hal0/cli/memory_commands.py +517 -0
  155. hal0/cli/memory_migrate_commands.py +580 -0
  156. hal0/cli/memory_mm_commands.py +109 -0
  157. hal0/cli/memory_ops_commands.py +177 -0
  158. hal0/cli/memory_recall_commands.py +84 -0
  159. hal0/cli/migrate_commands.py +761 -0
  160. hal0/cli/model_commands.py +496 -0
  161. hal0/cli/registry_commands.py +307 -0
  162. hal0/cli/setup_command.py +329 -0
  163. hal0/cli/setup_copy.py +92 -0
  164. hal0/cli/setup_install.py +251 -0
  165. hal0/cli/setup_plan.py +219 -0
  166. hal0/cli/setup_ui.py +1015 -0
  167. hal0/cli/slot_commands.py +778 -0
  168. hal0/cli/update_commands.py +734 -0
  169. hal0/cli/upstream_commands.py +556 -0
  170. hal0/comfyui/__init__.py +1 -0
  171. hal0/comfyui/capabilities.py +199 -0
  172. hal0/comfyui/fetch.py +265 -0
  173. hal0/comfyui/orchestrate.py +221 -0
  174. hal0/comfyui/provision.py +159 -0
  175. hal0/comfyui/selection.py +33 -0
  176. hal0/config/__init__.py +35 -0
  177. hal0/config/env.py +109 -0
  178. hal0/config/loader.py +842 -0
  179. hal0/config/locking.py +111 -0
  180. hal0/config/migrations/__init__.py +181 -0
  181. hal0/config/migrations/v1.py +37 -0
  182. hal0/config/network.py +137 -0
  183. hal0/config/paths.py +333 -0
  184. hal0/config/schema.py +3085 -0
  185. hal0/dashboard/__init__.py +1 -0
  186. hal0/dashboard/layout_store.py +194 -0
  187. hal0/dispatcher/__init__.py +44 -0
  188. hal0/dispatcher/_capability_resolve.py +243 -0
  189. hal0/dispatcher/_npu_common.py +21 -0
  190. hal0/dispatcher/memory_dispatcher.py +108 -0
  191. hal0/dispatcher/npu_swap_status.py +149 -0
  192. hal0/dispatcher/npu_trio.py +251 -0
  193. hal0/dispatcher/router.py +1456 -0
  194. hal0/dispatcher/single_flight.py +112 -0
  195. hal0/errors.py +207 -0
  196. hal0/events/__init__.py +270 -0
  197. hal0/hardware/__init__.py +26 -0
  198. hal0/hardware/gpu_view.py +220 -0
  199. hal0/hardware/probe.py +1129 -0
  200. hal0/hardware/pve.py +471 -0
  201. hal0/hardware/recommend.py +265 -0
  202. hal0/hardware/stats.py +295 -0
  203. hal0/install/__init__.py +1 -0
  204. hal0/install/answers.py +477 -0
  205. hal0/install/extensions.py +179 -0
  206. hal0/install/network.py +245 -0
  207. hal0/install/orchestrate.py +606 -0
  208. hal0/install/perms.py +439 -0
  209. hal0/install/profile_derive.py +165 -0
  210. hal0/install/static_seeds.py +87 -0
  211. hal0/install/suggest.py +106 -0
  212. hal0/installer/__init__.py +11 -0
  213. hal0/journal/__init__.py +18 -0
  214. hal0/mcp/__init__.py +42 -0
  215. hal0/mcp/admin.py +1681 -0
  216. hal0/mcp/approval_queue.py +368 -0
  217. hal0/mcp/browser_server.py +400 -0
  218. hal0/mcp/installed.py +325 -0
  219. hal0/mcp/manifest.py +459 -0
  220. hal0/mcp/memory.py +676 -0
  221. hal0/mcp/probes.py +448 -0
  222. hal0/memory/__init__.py +105 -0
  223. hal0/memory/extraction_env.py +124 -0
  224. hal0/memory/hindsight_client.py +144 -0
  225. hal0/memory/hindsight_provider.py +609 -0
  226. hal0/memory/honcho_env.py +240 -0
  227. hal0/memory/honcho_migrate.py +582 -0
  228. hal0/memory/migrate.py +38 -0
  229. hal0/memory/namespace.py +159 -0
  230. hal0/memory/pgvector_provider.py +179 -0
  231. hal0/memory/provider.py +246 -0
  232. hal0/model_fit.py +114 -0
  233. hal0/model_meta/__init__.py +684 -0
  234. hal0/normalize/__init__.py +11 -0
  235. hal0/normalize/messages.py +61 -0
  236. hal0/normalize/resolver.py +164 -0
  237. hal0/normalize/thinking.py +75 -0
  238. hal0/omni_router/__init__.py +38 -0
  239. hal0/omni_router/dispatch.py +426 -0
  240. hal0/omni_router/filter.py +126 -0
  241. hal0/omni_router/route_to_chat.py +227 -0
  242. hal0/omni_router/router.py +282 -0
  243. hal0/omni_router/tool_definitions.json +206 -0
  244. hal0/omni_router/tools.py +129 -0
  245. hal0/openwebui/__init__.py +22 -0
  246. hal0/openwebui/env_writer.py +191 -0
  247. hal0/openwebui/image_pin.py +138 -0
  248. hal0/ports.py +216 -0
  249. hal0/profiles/__init__.py +311 -0
  250. hal0/profiles/portable.py +119 -0
  251. hal0/providers/__init__.py +76 -0
  252. hal0/providers/_gpu.py +214 -0
  253. hal0/providers/base.py +255 -0
  254. hal0/providers/comfyui.py +538 -0
  255. hal0/providers/comfyui_workflows.py +315 -0
  256. hal0/providers/container.py +1713 -0
  257. hal0/providers/flm.py +1115 -0
  258. hal0/providers/kokoro.py +231 -0
  259. hal0/providers/llama_server.py +182 -0
  260. hal0/providers/npu_columns.py +268 -0
  261. hal0/providers/qwen3tts.py +257 -0
  262. hal0/providers/workflows/__init__.py +24 -0
  263. hal0/providers/workflows/sd15_simple.json +81 -0
  264. hal0/providers/workflows/sdxl_turbo_simple.json +81 -0
  265. hal0/registry/__init__.py +38 -0
  266. hal0/registry/curated.py +1093 -0
  267. hal0/registry/detect.py +414 -0
  268. hal0/registry/discover.py +419 -0
  269. hal0/registry/gguf_header.py +345 -0
  270. hal0/registry/model.py +228 -0
  271. hal0/registry/model_store.py +321 -0
  272. hal0/registry/pull.py +1534 -0
  273. hal0/registry/seeds/haloai_models.json +682 -0
  274. hal0/registry/store.py +541 -0
  275. hal0/registry/update_check.py +149 -0
  276. hal0/release/__init__.py +1 -0
  277. hal0/release/channel.py +83 -0
  278. hal0/release/notes.py +98 -0
  279. hal0/services/__init__.py +26 -0
  280. hal0/services/mdns.py +150 -0
  281. hal0/services/registry.py +155 -0
  282. hal0/services/systemd.py +211 -0
  283. hal0/slot_config/__init__.py +640 -0
  284. hal0/slot_view/__init__.py +831 -0
  285. hal0/slots/__init__.py +28 -0
  286. hal0/slots/arbiter.py +709 -0
  287. hal0/slots/argv.py +304 -0
  288. hal0/slots/capacity.py +482 -0
  289. hal0/slots/manager.py +4145 -0
  290. hal0/slots/metrics.py +83 -0
  291. hal0/slots/state.py +368 -0
  292. hal0/slots/ttft_samples.py +130 -0
  293. hal0/stacks/__init__.py +153 -0
  294. hal0/stacks/apply.py +456 -0
  295. hal0/stacks/portable.py +391 -0
  296. hal0/stacks/state.py +110 -0
  297. hal0/templates/__init__.py +39 -0
  298. hal0/templates/chat/chatml.jinja +7 -0
  299. hal0/templates/chat/llama3.jinja +9 -0
  300. hal0/templates/chat/qwen3.6-27b-mtp.jinja +287 -0
  301. hal0/updater/__init__.py +57 -0
  302. hal0/updater/updater.py +2252 -0
  303. hal0/upstreams/__init__.py +35 -0
  304. hal0/upstreams/filters.py +75 -0
  305. hal0/upstreams/integrations.py +237 -0
  306. hal0/upstreams/registry.py +854 -0
  307. hal0ai-0.9.8.dist-info/METADATA +543 -0
  308. hal0ai-0.9.8.dist-info/RECORD +311 -0
  309. hal0ai-0.9.8.dist-info/WHEEL +4 -0
  310. hal0ai-0.9.8.dist-info/entry_points.txt +3 -0
  311. hal0ai-0.9.8.dist-info/licenses/LICENSE +201 -0
hal0/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """hal0 — open-source home AI inference platform."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ try:
7
+ # Distribution name is "hal0ai" (PyPI rejects "hal0"); the fallback to
8
+ # "hal0" covers a transitional editable install whose .dist-info still
9
+ # carries the old name until it's re-`pip install`-ed.
10
+ try:
11
+ __version__ = _pkg_version("hal0ai")
12
+ except PackageNotFoundError:
13
+ __version__ = _pkg_version("hal0")
14
+ except PackageNotFoundError:
15
+ # Importing the source tree without `pip install`-ing it (e.g. a
16
+ # `python -c "import hal0"` from a repo clone) bypasses metadata.
17
+ # Surface a clear sentinel rather than a confusing crash.
18
+ __version__ = "0.0.0+source"
@@ -0,0 +1,433 @@
1
+ """Durable activity / audit store — hal0's source of truth for change.
2
+
3
+ The footer :class:`hal0.events.EventBus` is a fast, in-RAM ring that is lost
4
+ on restart and carries no before/after state or success/failure outcome. This
5
+ module is its durable complement: a SQLite table recording every
6
+ config-mutating user action and system state change, with the *outcome*
7
+ captured **after** the operation runs — so the record confirms the change
8
+ actually took effect (or truthfully says it failed).
9
+
10
+ Design: single-file package mirroring ``events/__init__.py``. Uses the stdlib
11
+ ``sqlite3`` (the house pattern) with a per-call connection, WAL journaling, and
12
+ an idempotent ``CREATE TABLE IF NOT EXISTS`` schema. Writes are wrapped in
13
+ ``asyncio.to_thread`` so the event loop never blocks on disk.
14
+
15
+ Two write paths feed one table:
16
+ * :func:`audit_action` — context manager around a mutation handler. Captures
17
+ before-state, runs the body, records ``outcome="ok"`` + after-state on
18
+ success or ``outcome="error"`` + the exception message on failure (then
19
+ re-raises). This is the confirmation guarantee.
20
+ * :meth:`AuditStore.record_event` — adapts an EventBus event into a durable
21
+ ``kind="event"`` row, so structural state changes (``slot.state``,
22
+ ``pull.*``, ``system.*``) are persisted for free.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import csv
29
+ import io
30
+ import json
31
+ import sqlite3
32
+ import time
33
+ from collections.abc import AsyncIterator
34
+ from contextlib import asynccontextmanager
35
+ from dataclasses import dataclass, field
36
+ from datetime import UTC, datetime, timedelta
37
+ from pathlib import Path
38
+ from typing import Any, Literal
39
+
40
+ __all__ = ["ActionRecorder", "AuditStore", "audit_action"]
41
+
42
+ Kind = Literal["action", "event"]
43
+ Severity = Literal["info", "warn", "error", "ok"]
44
+ Outcome = Literal["ok", "error", "pending"]
45
+
46
+ # Keys whose values must never be persisted, even inside before/after blobs.
47
+ # Provider credential writes record the *fact* and *actor*, never the secret.
48
+ _SECRET_KEYS = {"api_key", "apikey", "key", "token", "secret", "password", "credential"}
49
+ _REDACTED = "***"
50
+
51
+ # Categories the type-prefix → category mapping recognises for mirrored events.
52
+ _KNOWN_CATEGORIES = {
53
+ "slot",
54
+ "model",
55
+ "profile",
56
+ "capability",
57
+ "backend",
58
+ "provider",
59
+ "mcp",
60
+ "agent",
61
+ "approval",
62
+ "updater",
63
+ "comfyui",
64
+ "pull",
65
+ "system",
66
+ }
67
+
68
+ _COLUMNS = [
69
+ "id",
70
+ "ts",
71
+ "kind",
72
+ "category",
73
+ "action",
74
+ "target",
75
+ "actor",
76
+ "severity",
77
+ "outcome",
78
+ "message",
79
+ "before",
80
+ "after",
81
+ "error",
82
+ "duration_ms",
83
+ "request_id",
84
+ ]
85
+
86
+ _SCHEMA = """
87
+ CREATE TABLE IF NOT EXISTS audit (
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ ts TEXT NOT NULL,
90
+ kind TEXT NOT NULL,
91
+ category TEXT NOT NULL,
92
+ action TEXT NOT NULL,
93
+ target TEXT,
94
+ actor TEXT,
95
+ severity TEXT NOT NULL,
96
+ outcome TEXT,
97
+ message TEXT,
98
+ before TEXT,
99
+ after TEXT,
100
+ error TEXT,
101
+ duration_ms INTEGER,
102
+ request_id TEXT
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts);
105
+ CREATE INDEX IF NOT EXISTS idx_audit_category ON audit(category);
106
+ CREATE INDEX IF NOT EXISTS idx_audit_action ON audit(action);
107
+ CREATE INDEX IF NOT EXISTS idx_audit_severity ON audit(severity);
108
+ CREATE INDEX IF NOT EXISTS idx_audit_outcome ON audit(outcome);
109
+ """
110
+
111
+ _SCHEMA_VERSION = 1
112
+
113
+
114
+ def _now_iso() -> str:
115
+ """ISO-8601 UTC timestamp with microsecond precision (matches EventBus)."""
116
+ return datetime.now(UTC).isoformat()
117
+
118
+
119
+ def _redact(value: Any) -> Any:
120
+ """Recursively replace secret-looking values so they never hit disk."""
121
+ if isinstance(value, dict):
122
+ return {
123
+ k: (_REDACTED if k.lower() in _SECRET_KEYS else _redact(v)) for k, v in value.items()
124
+ }
125
+ if isinstance(value, list):
126
+ return [_redact(v) for v in value]
127
+ return value
128
+
129
+
130
+ def _dump(value: Any) -> str | None:
131
+ """JSON-encode a before/after blob (after redaction), or None."""
132
+ if value is None:
133
+ return None
134
+ return json.dumps(_redact(value), separators=(",", ":"), default=str)
135
+
136
+
137
+ @dataclass
138
+ class ActionRecorder:
139
+ """Mutable handle yielded by :func:`audit_action`.
140
+
141
+ The handler fills ``after`` (and may override ``message``) to enrich the
142
+ record written when the ``with`` block exits successfully.
143
+ """
144
+
145
+ after: dict[str, Any] | None = None
146
+ message: str | None = None
147
+ target: str | None = None
148
+ extra: dict[str, Any] = field(default_factory=dict)
149
+
150
+
151
+ class AuditStore:
152
+ """Durable SQLite-backed audit trail. The single source of truth surfaced
153
+ by ``/api/activity``."""
154
+
155
+ def __init__(
156
+ self,
157
+ db_path: Path | str,
158
+ *,
159
+ retention_days: int = 30,
160
+ max_rows: int | None = 50_000,
161
+ ) -> None:
162
+ self.db_path = Path(db_path)
163
+ self.retention_days = retention_days
164
+ self.max_rows = max_rows
165
+
166
+ # ── connection ────────────────────────────────────────────────────────
167
+ def _connect(self) -> sqlite3.Connection:
168
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
169
+ conn = sqlite3.connect(self.db_path, timeout=5.0)
170
+ conn.row_factory = sqlite3.Row
171
+ conn.execute("PRAGMA journal_mode=WAL")
172
+ conn.execute("PRAGMA busy_timeout=5000")
173
+ return conn
174
+
175
+ def init_schema(self) -> None:
176
+ """Create the table + indexes if absent. Idempotent; first-boot safe."""
177
+ with self._connect() as conn:
178
+ conn.executescript(_SCHEMA)
179
+ conn.execute(f"PRAGMA user_version={_SCHEMA_VERSION}")
180
+ conn.commit()
181
+
182
+ # ── write ─────────────────────────────────────────────────────────────
183
+ def _insert(
184
+ self,
185
+ *,
186
+ kind: Kind,
187
+ category: str,
188
+ action: str,
189
+ target: str | None,
190
+ actor: str | None,
191
+ severity: Severity,
192
+ outcome: Outcome | None,
193
+ message: str | None,
194
+ before: Any,
195
+ after: Any,
196
+ error: str | None,
197
+ duration_ms: int | None,
198
+ request_id: str | None,
199
+ ) -> int:
200
+ with self._connect() as conn:
201
+ cur = conn.execute(
202
+ """
203
+ INSERT INTO audit
204
+ (ts, kind, category, action, target, actor, severity,
205
+ outcome, message, before, after, error, duration_ms, request_id)
206
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
207
+ """,
208
+ (
209
+ _now_iso(),
210
+ kind,
211
+ category,
212
+ action,
213
+ target,
214
+ actor,
215
+ severity,
216
+ outcome,
217
+ message,
218
+ _dump(before),
219
+ _dump(after),
220
+ error,
221
+ duration_ms,
222
+ request_id,
223
+ ),
224
+ )
225
+ conn.commit()
226
+ return int(cur.lastrowid or 0)
227
+
228
+ async def record(
229
+ self,
230
+ *,
231
+ kind: Kind,
232
+ category: str,
233
+ action: str,
234
+ target: str | None = None,
235
+ actor: str | None = None,
236
+ severity: Severity = "info",
237
+ outcome: Outcome | None = None,
238
+ message: str | None = None,
239
+ before: Any = None,
240
+ after: Any = None,
241
+ error: str | None = None,
242
+ duration_ms: int | None = None,
243
+ request_id: str | None = None,
244
+ ) -> int:
245
+ """Persist one audit row and return its monotonic id. Never blocks the
246
+ event loop (offloaded to a worker thread)."""
247
+ return await asyncio.to_thread(
248
+ self._insert,
249
+ kind=kind,
250
+ category=category,
251
+ action=action,
252
+ target=target,
253
+ actor=actor,
254
+ severity=severity,
255
+ outcome=outcome,
256
+ message=message,
257
+ before=before,
258
+ after=after,
259
+ error=error,
260
+ duration_ms=duration_ms,
261
+ request_id=request_id,
262
+ )
263
+
264
+ async def record_event(self, event: dict[str, Any]) -> int:
265
+ """Adapt a :class:`hal0.events.EventBus` event into a durable row."""
266
+ etype = str(event.get("type", "system.event"))
267
+ data = event.get("data") or {}
268
+ head = etype.split(".", 1)[0]
269
+ category = head if head in _KNOWN_CATEGORIES else "system"
270
+ target = (
271
+ data.get("slot")
272
+ or data.get("target")
273
+ or data.get("model")
274
+ or _source_target(event.get("source"))
275
+ )
276
+ return await self.record(
277
+ kind="event",
278
+ category=category,
279
+ action=etype,
280
+ target=target,
281
+ actor="system",
282
+ severity=event.get("severity", "info"),
283
+ outcome=None,
284
+ message=event.get("message"),
285
+ after=data or None,
286
+ )
287
+
288
+ # ── read ──────────────────────────────────────────────────────────────
289
+ def query(
290
+ self,
291
+ *,
292
+ since: int = 0,
293
+ category: str | None = None,
294
+ action: str | None = None,
295
+ severity: str | None = None,
296
+ outcome: str | None = None,
297
+ actor: str | None = None,
298
+ kind: str | None = None,
299
+ search: str | None = None,
300
+ limit: int = 200,
301
+ ) -> list[sqlite3.Row]:
302
+ """Return rows newest-first matching the filters. ``action`` accepts a
303
+ glob (``slot.*``); ``search`` is a free-text LIKE across
304
+ message/target/action."""
305
+ clauses: list[str] = ["id > ?"]
306
+ params: list[Any] = [since]
307
+ for col, val in (
308
+ ("category", category),
309
+ ("severity", severity),
310
+ ("outcome", outcome),
311
+ ("actor", actor),
312
+ ("kind", kind),
313
+ ):
314
+ if val:
315
+ clauses.append(f"{col} = ?")
316
+ params.append(val)
317
+ if action:
318
+ clauses.append("action GLOB ?")
319
+ params.append(action)
320
+ if search:
321
+ like = f"%{search}%"
322
+ clauses.append("(message LIKE ? OR target LIKE ? OR action LIKE ?)")
323
+ params.extend([like, like, like])
324
+ sql = (
325
+ f"SELECT {', '.join(_COLUMNS)} FROM audit "
326
+ f"WHERE {' AND '.join(clauses)} "
327
+ f"ORDER BY id DESC LIMIT ?"
328
+ )
329
+ params.append(max(1, min(limit, 5000)))
330
+ with self._connect() as conn:
331
+ return conn.execute(sql, params).fetchall()
332
+
333
+ def export(self, *, fmt: Literal["csv", "json"], **filters: Any) -> str:
334
+ """Render the (filtered) trail as CSV or JSON for download/archival."""
335
+ filters.setdefault("limit", 5000)
336
+ rows = self.query(**filters)
337
+ dicts = [{c: r[c] for c in _COLUMNS} for r in rows]
338
+ if fmt == "json":
339
+ return json.dumps(dicts, indent=2, default=str)
340
+ buf = io.StringIO()
341
+ writer = csv.DictWriter(buf, fieldnames=_COLUMNS, extrasaction="ignore")
342
+ writer.writeheader()
343
+ for d in dicts:
344
+ writer.writerow(d)
345
+ return buf.getvalue()
346
+
347
+ # ── retention ─────────────────────────────────────────────────────────
348
+ def _prune_sync(self) -> int:
349
+ cutoff = (datetime.now(UTC) - timedelta(days=self.retention_days)).isoformat()
350
+ dropped = 0
351
+ with self._connect() as conn:
352
+ cur = conn.execute("DELETE FROM audit WHERE ts < ?", (cutoff,))
353
+ dropped += cur.rowcount or 0
354
+ if self.max_rows is not None:
355
+ cur = conn.execute(
356
+ "DELETE FROM audit WHERE id NOT IN "
357
+ "(SELECT id FROM audit ORDER BY id DESC LIMIT ?)",
358
+ (self.max_rows,),
359
+ )
360
+ dropped += cur.rowcount or 0
361
+ conn.commit()
362
+ return dropped
363
+
364
+ async def prune(self) -> int:
365
+ """Drop rows older than ``retention_days`` and trim to ``max_rows``,
366
+ keeping the newest. Returns the number deleted."""
367
+ return await asyncio.to_thread(self._prune_sync)
368
+
369
+
370
+ def _source_target(source: Any) -> str | None:
371
+ """Best-effort target from an event ``source`` like ``slot:chat``."""
372
+ if isinstance(source, str) and ":" in source:
373
+ return source.split(":", 1)[1]
374
+ return source if isinstance(source, str) else None
375
+
376
+
377
+ @asynccontextmanager
378
+ async def audit_action(
379
+ store: AuditStore,
380
+ *,
381
+ category: str,
382
+ action: str,
383
+ target: str | None,
384
+ actor: str | None,
385
+ before: dict[str, Any] | None = None,
386
+ message: str | None = None,
387
+ request_id: str | None = None,
388
+ ) -> AsyncIterator[ActionRecorder]:
389
+ """Record a user-initiated mutation with a truthful outcome.
390
+
391
+ On clean exit, writes ``outcome="ok"`` (severity ``ok``) with the
392
+ recorder's ``after`` state and elapsed time. On exception, writes
393
+ ``outcome="error"`` (severity ``error``) carrying the exception message,
394
+ then re-raises so the normal error envelope still fires. This is the
395
+ "confirmation it actually took place" guarantee.
396
+ """
397
+ rec = ActionRecorder(target=target)
398
+ t0 = time.monotonic()
399
+ try:
400
+ yield rec
401
+ except Exception as exc:
402
+ await store.record(
403
+ kind="action",
404
+ category=category,
405
+ action=action,
406
+ target=rec.target if rec.target is not None else target,
407
+ actor=actor,
408
+ severity="error",
409
+ outcome="error",
410
+ message=rec.message or message or f"{action} failed",
411
+ before=before,
412
+ after=rec.after,
413
+ error=str(exc),
414
+ duration_ms=int((time.monotonic() - t0) * 1000),
415
+ request_id=request_id,
416
+ )
417
+ raise
418
+ else:
419
+ await store.record(
420
+ kind="action",
421
+ category=category,
422
+ action=action,
423
+ target=rec.target if rec.target is not None else target,
424
+ actor=actor,
425
+ severity="ok",
426
+ outcome="ok",
427
+ message=rec.message or message or action,
428
+ before=before,
429
+ after=rec.after,
430
+ error=None,
431
+ duration_ms=int((time.monotonic() - t0) * 1000),
432
+ request_id=request_id,
433
+ )
@@ -0,0 +1,31 @@
1
+ """hal0 Agents subsystem (Phase 8, v0.2).
2
+
3
+ Bundled third-party agent apps. Single-pick at install. Shim-first
4
+ ownership: hal0 owns the install scripts under ``installer/agents/*.sh``;
5
+ this package wraps them in Python so the CLI + API speak one interface.
6
+
7
+ This is NOT a first-party agent runtime — by design: "Bundle,
8
+ don't build". The drivers in :mod:`hal0.agents.pi_coder` and
9
+ :mod:`hal0.agents.hermes` only handle install/uninstall wiring + config
10
+ writes. Runtime is whatever the bundled upstream does natively.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from hal0.agents.manager import (
16
+ BUNDLED_AGENTS,
17
+ AgentAlreadyInstalledError,
18
+ AgentManager,
19
+ AgentNotFoundError,
20
+ AgentRecord,
21
+ HermesNotHal0AwareError,
22
+ )
23
+
24
+ __all__ = [
25
+ "BUNDLED_AGENTS",
26
+ "AgentAlreadyInstalledError",
27
+ "AgentManager",
28
+ "AgentNotFoundError",
29
+ "AgentRecord",
30
+ "HermesNotHal0AwareError",
31
+ ]