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.
- hal0/__init__.py +18 -0
- hal0/activity/__init__.py +433 -0
- hal0/agents/__init__.py +31 -0
- hal0/agents/budget.py +565 -0
- hal0/agents/hermes/__init__.py +16 -0
- hal0/agents/hermes/driver.py +400 -0
- hal0/agents/hermes/plugins/__init__.py +6 -0
- hal0/agents/hermes/plugins/memory_hindsight/README.md +80 -0
- hal0/agents/hermes/plugins/memory_hindsight/__init__.py +33 -0
- hal0/agents/hermes/plugins/memory_hindsight/_client.py +179 -0
- hal0/agents/hermes/plugins/memory_hindsight/plugin.yaml +10 -0
- hal0/agents/hermes/plugins/memory_hindsight/provider.py +294 -0
- hal0/agents/hermes_provision.py +5314 -0
- hal0/agents/hermes_refresh.py +32 -0
- hal0/agents/hermes_templates/AGENTS.md.j2 +61 -0
- hal0/agents/hermes_templates/HERMES.md.j2 +44 -0
- hal0/agents/hermes_templates/MCP-CLIENTS.md.j2 +93 -0
- hal0/agents/hermes_templates/SOUL.md.j2 +44 -0
- hal0/agents/hermes_templates/STATE.md.j2 +28 -0
- hal0/agents/manager.py +675 -0
- hal0/agents/mcp_client.py +282 -0
- hal0/agents/opencode/__init__.py +7 -0
- hal0/agents/opencode/driver.py +164 -0
- hal0/agents/persona.py +94 -0
- hal0/agents/personas.py +625 -0
- hal0/agents/pi_coder/__init__.py +7 -0
- hal0/agents/pi_coder/driver.py +324 -0
- hal0/agents/pi_coder/plugins/hal0-memory/index.ts +582 -0
- hal0/agents/pi_coder/plugins/hal0-provider/index.ts +156 -0
- hal0/agents/pi_coder/themes/hal0.json +58 -0
- hal0/agents/provision_engine.py +445 -0
- hal0/agents/turnstone/__init__.py +7 -0
- hal0/agents/turnstone/driver.py +197 -0
- hal0/agents/turnstone_provision.py +871 -0
- hal0/api/__init__.py +1739 -0
- hal0/api/_audit.py +74 -0
- hal0/api/_env_store.py +173 -0
- hal0/api/_redact.py +101 -0
- hal0/api/_settings_apply.py +310 -0
- hal0/api/agents/__init__.py +1 -0
- hal0/api/agents/_auth.py +242 -0
- hal0/api/agents/budget.py +371 -0
- hal0/api/agents/chat_proxy.py +572 -0
- hal0/api/agents/memory_stats.py +232 -0
- hal0/api/agents/personas.py +414 -0
- hal0/api/agents/restart.py +262 -0
- hal0/api/deps.py +73 -0
- hal0/api/image_cache.py +176 -0
- hal0/api/mcp_mount.py +291 -0
- hal0/api/middleware/__init__.py +0 -0
- hal0/api/middleware/error_codes.py +142 -0
- hal0/api/middleware/log_scrub.py +91 -0
- hal0/api/middleware/request_id.py +31 -0
- hal0/api/openrouter/__init__.py +14 -0
- hal0/api/openrouter/_loopback.py +86 -0
- hal0/api/openrouter/auth.py +47 -0
- hal0/api/plugins/__init__.py +28 -0
- hal0/api/plugins/manifest_proxy.py +600 -0
- hal0/api/routes/__init__.py +0 -0
- hal0/api/routes/_memory_subgraph.py +156 -0
- hal0/api/routes/activity.py +217 -0
- hal0/api/routes/agents.py +321 -0
- hal0/api/routes/approvals.py +200 -0
- hal0/api/routes/backends.py +455 -0
- hal0/api/routes/benchmarks.py +480 -0
- hal0/api/routes/board.py +383 -0
- hal0/api/routes/board_chat.py +1327 -0
- hal0/api/routes/board_ws.py +177 -0
- hal0/api/routes/capabilities.py +105 -0
- hal0/api/routes/chat_templates.py +141 -0
- hal0/api/routes/comfyui.py +951 -0
- hal0/api/routes/config.py +328 -0
- hal0/api/routes/dashboard_layout.py +168 -0
- hal0/api/routes/events.py +207 -0
- hal0/api/routes/hardware.py +671 -0
- hal0/api/routes/health.py +299 -0
- hal0/api/routes/hf.py +195 -0
- hal0/api/routes/images.py +53 -0
- hal0/api/routes/installer.py +696 -0
- hal0/api/routes/journal.py +320 -0
- hal0/api/routes/logs.py +250 -0
- hal0/api/routes/mcp.py +895 -0
- hal0/api/routes/memory.py +1205 -0
- hal0/api/routes/memory_admin.py +674 -0
- hal0/api/routes/meta.py +92 -0
- hal0/api/routes/models.py +2509 -0
- hal0/api/routes/npu.py +407 -0
- hal0/api/routes/ports.py +46 -0
- hal0/api/routes/power.py +138 -0
- hal0/api/routes/profiles.py +329 -0
- hal0/api/routes/providers.py +638 -0
- hal0/api/routes/proxmox.py +227 -0
- hal0/api/routes/secrets.py +243 -0
- hal0/api/routes/services.py +285 -0
- hal0/api/routes/services_health.py +276 -0
- hal0/api/routes/settings.py +564 -0
- hal0/api/routes/slots.py +1846 -0
- hal0/api/routes/stacks.py +547 -0
- hal0/api/routes/throughput.py +154 -0
- hal0/api/routes/updater.py +963 -0
- hal0/api/routes/v1.py +1542 -0
- hal0/bench/__init__.py +20 -0
- hal0/bench/cli.py +1034 -0
- hal0/bench/control.py +127 -0
- hal0/bench/evalrun.py +516 -0
- hal0/bench/evals/agentic/fixtures/cipher-chain/README.md +2 -0
- hal0/bench/evals/agentic/fixtures/cipher-chain/secret.txt +1 -0
- hal0/bench/evals/agentic/fixtures/codebase-combine/README.md +2 -0
- hal0/bench/evals/agentic/fixtures/codebase-combine/lib/util.py +5 -0
- hal0/bench/evals/agentic/fixtures/codebase-combine/src/config.py +5 -0
- hal0/bench/evals/agentic/fixtures/dep-trace/a.py +8 -0
- hal0/bench/evals/agentic/fixtures/dep-trace/keys.py +3 -0
- hal0/bench/evals/agentic/fixtures/dep-trace/registry.json +1 -0
- hal0/bench/evals/agentic/fixtures/grep-hunt/deep/nested/license.conf +1 -0
- hal0/bench/evals/agentic/fixtures/grep-hunt/deep/readme.txt +1 -0
- hal0/bench/evals/agentic/fixtures/grep-hunt/mod_1.py +2 -0
- hal0/bench/evals/agentic/fixtures/grep-hunt/mod_2.py +2 -0
- hal0/bench/evals/agentic/fixtures/grep-hunt/mod_3.py +2 -0
- hal0/bench/evals/agentic/fixtures/grep-hunt/pods.yaml +3 -0
- hal0/bench/evals/agentic/fixtures/loop-aggregate/notes.md +3 -0
- hal0/bench/evals/agentic/fixtures/loop-aggregate/orders.csv +8 -0
- hal0/bench/parsers.py +250 -0
- hal0/bench/planner.py +409 -0
- hal0/bench/publish.py +197 -0
- hal0/bench/regress.py +117 -0
- hal0/bench/runner.py +587 -0
- hal0/bench/schema.py +271 -0
- hal0/bench/store.py +275 -0
- hal0/bench/suites.py +181 -0
- hal0/board/__init__.py +271 -0
- hal0/bundles/__init__.py +33 -0
- hal0/bundles/eligibility.py +101 -0
- hal0/bundles/schema.py +226 -0
- hal0/bundles/tiers.py +138 -0
- hal0/capabilities/__init__.py +22 -0
- hal0/capabilities/catalog.py +978 -0
- hal0/capabilities/config.py +411 -0
- hal0/capabilities/orchestrator.py +889 -0
- hal0/capabilities/profile_fit.py +52 -0
- hal0/cli/__init__.py +1 -0
- hal0/cli/_shared.py +136 -0
- hal0/cli/agent_commands.py +1502 -0
- hal0/cli/agent_shim.py +753 -0
- hal0/cli/app_commands.py +158 -0
- hal0/cli/bench_commands.py +33 -0
- hal0/cli/capabilities_commands.py +332 -0
- hal0/cli/comfyui_commands.py +85 -0
- hal0/cli/config_commands.py +257 -0
- hal0/cli/doctor_commands.py +1420 -0
- hal0/cli/doctor_verify.py +385 -0
- hal0/cli/main.py +307 -0
- hal0/cli/mcp_commands.py +373 -0
- hal0/cli/memory_bank_commands.py +412 -0
- hal0/cli/memory_commands.py +517 -0
- hal0/cli/memory_migrate_commands.py +580 -0
- hal0/cli/memory_mm_commands.py +109 -0
- hal0/cli/memory_ops_commands.py +177 -0
- hal0/cli/memory_recall_commands.py +84 -0
- hal0/cli/migrate_commands.py +761 -0
- hal0/cli/model_commands.py +496 -0
- hal0/cli/registry_commands.py +307 -0
- hal0/cli/setup_command.py +329 -0
- hal0/cli/setup_copy.py +92 -0
- hal0/cli/setup_install.py +251 -0
- hal0/cli/setup_plan.py +219 -0
- hal0/cli/setup_ui.py +1015 -0
- hal0/cli/slot_commands.py +778 -0
- hal0/cli/update_commands.py +734 -0
- hal0/cli/upstream_commands.py +556 -0
- hal0/comfyui/__init__.py +1 -0
- hal0/comfyui/capabilities.py +199 -0
- hal0/comfyui/fetch.py +265 -0
- hal0/comfyui/orchestrate.py +221 -0
- hal0/comfyui/provision.py +159 -0
- hal0/comfyui/selection.py +33 -0
- hal0/config/__init__.py +35 -0
- hal0/config/env.py +109 -0
- hal0/config/loader.py +842 -0
- hal0/config/locking.py +111 -0
- hal0/config/migrations/__init__.py +181 -0
- hal0/config/migrations/v1.py +37 -0
- hal0/config/network.py +137 -0
- hal0/config/paths.py +333 -0
- hal0/config/schema.py +3085 -0
- hal0/dashboard/__init__.py +1 -0
- hal0/dashboard/layout_store.py +194 -0
- hal0/dispatcher/__init__.py +44 -0
- hal0/dispatcher/_capability_resolve.py +243 -0
- hal0/dispatcher/_npu_common.py +21 -0
- hal0/dispatcher/memory_dispatcher.py +108 -0
- hal0/dispatcher/npu_swap_status.py +149 -0
- hal0/dispatcher/npu_trio.py +251 -0
- hal0/dispatcher/router.py +1456 -0
- hal0/dispatcher/single_flight.py +112 -0
- hal0/errors.py +207 -0
- hal0/events/__init__.py +270 -0
- hal0/hardware/__init__.py +26 -0
- hal0/hardware/gpu_view.py +220 -0
- hal0/hardware/probe.py +1129 -0
- hal0/hardware/pve.py +471 -0
- hal0/hardware/recommend.py +265 -0
- hal0/hardware/stats.py +295 -0
- hal0/install/__init__.py +1 -0
- hal0/install/answers.py +477 -0
- hal0/install/extensions.py +179 -0
- hal0/install/network.py +245 -0
- hal0/install/orchestrate.py +606 -0
- hal0/install/perms.py +439 -0
- hal0/install/profile_derive.py +165 -0
- hal0/install/static_seeds.py +87 -0
- hal0/install/suggest.py +106 -0
- hal0/installer/__init__.py +11 -0
- hal0/journal/__init__.py +18 -0
- hal0/mcp/__init__.py +42 -0
- hal0/mcp/admin.py +1681 -0
- hal0/mcp/approval_queue.py +368 -0
- hal0/mcp/browser_server.py +400 -0
- hal0/mcp/installed.py +325 -0
- hal0/mcp/manifest.py +459 -0
- hal0/mcp/memory.py +676 -0
- hal0/mcp/probes.py +448 -0
- hal0/memory/__init__.py +105 -0
- hal0/memory/extraction_env.py +124 -0
- hal0/memory/hindsight_client.py +144 -0
- hal0/memory/hindsight_provider.py +609 -0
- hal0/memory/honcho_env.py +240 -0
- hal0/memory/honcho_migrate.py +582 -0
- hal0/memory/migrate.py +38 -0
- hal0/memory/namespace.py +159 -0
- hal0/memory/pgvector_provider.py +179 -0
- hal0/memory/provider.py +246 -0
- hal0/model_fit.py +114 -0
- hal0/model_meta/__init__.py +684 -0
- hal0/normalize/__init__.py +11 -0
- hal0/normalize/messages.py +61 -0
- hal0/normalize/resolver.py +164 -0
- hal0/normalize/thinking.py +75 -0
- hal0/omni_router/__init__.py +38 -0
- hal0/omni_router/dispatch.py +426 -0
- hal0/omni_router/filter.py +126 -0
- hal0/omni_router/route_to_chat.py +227 -0
- hal0/omni_router/router.py +282 -0
- hal0/omni_router/tool_definitions.json +206 -0
- hal0/omni_router/tools.py +129 -0
- hal0/openwebui/__init__.py +22 -0
- hal0/openwebui/env_writer.py +191 -0
- hal0/openwebui/image_pin.py +138 -0
- hal0/ports.py +216 -0
- hal0/profiles/__init__.py +311 -0
- hal0/profiles/portable.py +119 -0
- hal0/providers/__init__.py +76 -0
- hal0/providers/_gpu.py +214 -0
- hal0/providers/base.py +255 -0
- hal0/providers/comfyui.py +538 -0
- hal0/providers/comfyui_workflows.py +315 -0
- hal0/providers/container.py +1713 -0
- hal0/providers/flm.py +1115 -0
- hal0/providers/kokoro.py +231 -0
- hal0/providers/llama_server.py +182 -0
- hal0/providers/npu_columns.py +268 -0
- hal0/providers/qwen3tts.py +257 -0
- hal0/providers/workflows/__init__.py +24 -0
- hal0/providers/workflows/sd15_simple.json +81 -0
- hal0/providers/workflows/sdxl_turbo_simple.json +81 -0
- hal0/registry/__init__.py +38 -0
- hal0/registry/curated.py +1093 -0
- hal0/registry/detect.py +414 -0
- hal0/registry/discover.py +419 -0
- hal0/registry/gguf_header.py +345 -0
- hal0/registry/model.py +228 -0
- hal0/registry/model_store.py +321 -0
- hal0/registry/pull.py +1534 -0
- hal0/registry/seeds/haloai_models.json +682 -0
- hal0/registry/store.py +541 -0
- hal0/registry/update_check.py +149 -0
- hal0/release/__init__.py +1 -0
- hal0/release/channel.py +83 -0
- hal0/release/notes.py +98 -0
- hal0/services/__init__.py +26 -0
- hal0/services/mdns.py +150 -0
- hal0/services/registry.py +155 -0
- hal0/services/systemd.py +211 -0
- hal0/slot_config/__init__.py +640 -0
- hal0/slot_view/__init__.py +831 -0
- hal0/slots/__init__.py +28 -0
- hal0/slots/arbiter.py +709 -0
- hal0/slots/argv.py +304 -0
- hal0/slots/capacity.py +482 -0
- hal0/slots/manager.py +4145 -0
- hal0/slots/metrics.py +83 -0
- hal0/slots/state.py +368 -0
- hal0/slots/ttft_samples.py +130 -0
- hal0/stacks/__init__.py +153 -0
- hal0/stacks/apply.py +456 -0
- hal0/stacks/portable.py +391 -0
- hal0/stacks/state.py +110 -0
- hal0/templates/__init__.py +39 -0
- hal0/templates/chat/chatml.jinja +7 -0
- hal0/templates/chat/llama3.jinja +9 -0
- hal0/templates/chat/qwen3.6-27b-mtp.jinja +287 -0
- hal0/updater/__init__.py +57 -0
- hal0/updater/updater.py +2252 -0
- hal0/upstreams/__init__.py +35 -0
- hal0/upstreams/filters.py +75 -0
- hal0/upstreams/integrations.py +237 -0
- hal0/upstreams/registry.py +854 -0
- hal0ai-0.9.8.dist-info/METADATA +543 -0
- hal0ai-0.9.8.dist-info/RECORD +311 -0
- hal0ai-0.9.8.dist-info/WHEEL +4 -0
- hal0ai-0.9.8.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|
hal0/agents/__init__.py
ADDED
|
@@ -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
|
+
]
|