Jonathan Haas commited on
Commit ·
fb22b7f
1
Parent(s): 2ed7c3f
Harden HA conversation policy/taxonomy and persist timers
Browse files- .env.example +2 -0
- README.md +2 -1
- TODO.md +6 -6
- docs/operations/error-taxonomy.md +12 -12
- docs/operations/home-control-policy.md +4 -1
- docs/operations/integration-policy.md +1 -1
- src/jarvis/config.py +29 -0
- src/jarvis/memory.py +127 -0
- src/jarvis/tools/services.py +78 -4
- tests/test_config.py +10 -0
- tests/test_memory.py +45 -0
- tests/test_tools_services.py +126 -4
.env.example
CHANGED
|
@@ -16,6 +16,8 @@ HOME_PERMISSION_PROFILE=control
|
|
| 16 |
HOME_REQUIRE_CONFIRM_EXECUTE=false
|
| 17 |
# Optional: enable Home Assistant conversation intent tool
|
| 18 |
HOME_CONVERSATION_ENABLED=false
|
|
|
|
|
|
|
| 19 |
|
| 20 |
# Todoist integration (optional)
|
| 21 |
TODOIST_API_TOKEN=
|
|
|
|
| 16 |
HOME_REQUIRE_CONFIRM_EXECUTE=false
|
| 17 |
# Optional: enable Home Assistant conversation intent tool
|
| 18 |
HOME_CONVERSATION_ENABLED=false
|
| 19 |
+
# Home Assistant conversation permission profile: readonly or control
|
| 20 |
+
HOME_CONVERSATION_PERMISSION_PROFILE=readonly
|
| 21 |
|
| 22 |
# Todoist integration (optional)
|
| 23 |
TODOIST_API_TOKEN=
|
README.md
CHANGED
|
@@ -83,6 +83,7 @@ cp .env.example .env
|
|
| 83 |
# Optional: HOME_PERMISSION_PROFILE=readonly (state only) or control (default)
|
| 84 |
# Optional: HOME_REQUIRE_CONFIRM_EXECUTE=true (require confirm=true on all executes)
|
| 85 |
# Optional: HOME_CONVERSATION_ENABLED=true (enable HA conversation intent tool)
|
|
|
|
| 86 |
# Optional: TODOIST_API_TOKEN / TODOIST_PROJECT_ID / TODOIST_PERMISSION_PROFILE
|
| 87 |
# Optional: TODOIST_TIMEOUT_SEC=10.0 / PUSHOVER_TIMEOUT_SEC=10.0
|
| 88 |
# Optional: PUSHOVER_API_TOKEN / PUSHOVER_USER_KEY / NOTIFICATION_PERMISSION_PROFILE
|
|
@@ -96,7 +97,7 @@ Smart home safety defaults:
|
|
| 96 |
- Integration runbook: [`docs/operations/integration-policy.md`](docs/operations/integration-policy.md).
|
| 97 |
- Home Assistant conversation tool requires both:
|
| 98 |
- `HOME_CONVERSATION_ENABLED=true`
|
| 99 |
-
- `
|
| 100 |
- and tool argument `confirm=true`
|
| 101 |
- Release checklist: [`docs/operations/release-checklist.md`](docs/operations/release-checklist.md).
|
| 102 |
- Security maintenance: [`docs/operations/security-maintenance.md`](docs/operations/security-maintenance.md).
|
|
|
|
| 83 |
# Optional: HOME_PERMISSION_PROFILE=readonly (state only) or control (default)
|
| 84 |
# Optional: HOME_REQUIRE_CONFIRM_EXECUTE=true (require confirm=true on all executes)
|
| 85 |
# Optional: HOME_CONVERSATION_ENABLED=true (enable HA conversation intent tool)
|
| 86 |
+
# Optional: HOME_CONVERSATION_PERMISSION_PROFILE=readonly|control (default readonly)
|
| 87 |
# Optional: TODOIST_API_TOKEN / TODOIST_PROJECT_ID / TODOIST_PERMISSION_PROFILE
|
| 88 |
# Optional: TODOIST_TIMEOUT_SEC=10.0 / PUSHOVER_TIMEOUT_SEC=10.0
|
| 89 |
# Optional: PUSHOVER_API_TOKEN / PUSHOVER_USER_KEY / NOTIFICATION_PERMISSION_PROFILE
|
|
|
|
| 97 |
- Integration runbook: [`docs/operations/integration-policy.md`](docs/operations/integration-policy.md).
|
| 98 |
- Home Assistant conversation tool requires both:
|
| 99 |
- `HOME_CONVERSATION_ENABLED=true`
|
| 100 |
+
- `HOME_CONVERSATION_PERMISSION_PROFILE=control`
|
| 101 |
- and tool argument `confirm=true`
|
| 102 |
- Release checklist: [`docs/operations/release-checklist.md`](docs/operations/release-checklist.md).
|
| 103 |
- Security maintenance: [`docs/operations/security-maintenance.md`](docs/operations/security-maintenance.md).
|
TODO.md
CHANGED
|
@@ -40,8 +40,8 @@ This backlog replaces the completed hardening backlog and focuses on feature gap
|
|
| 40 |
- [ ] `HA05` Add Home Assistant timer integration tool path.
|
| 41 |
- [ ] `HA06` Add entity capability discovery helper for safer action planning.
|
| 42 |
- [ ] `HA07` Add optional area-aware commands (room/area targeting helper).
|
| 43 |
-
- [
|
| 44 |
-
- [
|
| 45 |
- [ ] `HA10` Add HA intent runbook section with safe rollout checklist.
|
| 46 |
|
| 47 |
---
|
|
@@ -56,7 +56,7 @@ This backlog replaces the completed hardening backlog and focuses on feature gap
|
|
| 56 |
- [ ] `PR06` Add optional reminder notifications via Pushover.
|
| 57 |
- [ ] `PR07` Add calendar read integration (Google/ICS or HA calendar bridge).
|
| 58 |
- [ ] `PR08` Add “next event” conversational helper tool.
|
| 59 |
-
- [
|
| 60 |
- [ ] `PR10` Add productivity tool regression tests and edge-case parsing tests.
|
| 61 |
|
| 62 |
---
|
|
@@ -156,7 +156,7 @@ This backlog replaces the completed hardening backlog and focuses on feature gap
|
|
| 156 |
|
| 157 |
- [x] `E01` Implement `HA01` + `HA02` + `HA03` (Home Assistant conversation API tool + schema + mapping + tests).
|
| 158 |
- [x] `E02` Implement `PR01` + `PR02` + `PR03` (timer create/list/cancel tools + tests + status exposure).
|
| 159 |
-
- [
|
| 160 |
-
- [
|
| 161 |
- [ ] `E05` Implement `UX06` (structured status endpoint behavior contract).
|
| 162 |
-
- [
|
|
|
|
| 40 |
- [ ] `HA05` Add Home Assistant timer integration tool path.
|
| 41 |
- [ ] `HA06` Add entity capability discovery helper for safer action planning.
|
| 42 |
- [ ] `HA07` Add optional area-aware commands (room/area targeting helper).
|
| 43 |
+
- [x] `HA08` Add HA conversation error taxonomy mapping and tests.
|
| 44 |
+
- [x] `HA09` Add policy profile controls for HA intent tool (`readonly|control`).
|
| 45 |
- [ ] `HA10` Add HA intent runbook section with safe rollout checklist.
|
| 46 |
|
| 47 |
---
|
|
|
|
| 56 |
- [ ] `PR06` Add optional reminder notifications via Pushover.
|
| 57 |
- [ ] `PR07` Add calendar read integration (Google/ICS or HA calendar bridge).
|
| 58 |
- [ ] `PR08` Add “next event” conversational helper tool.
|
| 59 |
+
- [x] `PR09` Add persistence layer for timers/reminders across restarts.
|
| 60 |
- [ ] `PR10` Add productivity tool regression tests and edge-case parsing tests.
|
| 61 |
|
| 62 |
---
|
|
|
|
| 156 |
|
| 157 |
- [x] `E01` Implement `HA01` + `HA02` + `HA03` (Home Assistant conversation API tool + schema + mapping + tests).
|
| 158 |
- [x] `E02` Implement `PR01` + `PR02` + `PR03` (timer create/list/cancel tools + tests + status exposure).
|
| 159 |
+
- [x] `E03` Implement `HA08` + `HA09` (policy and taxonomy mapping for HA intent tool).
|
| 160 |
+
- [x] `E04` Implement `PR09` (persist timer/reminder data in `MemoryStore`).
|
| 161 |
- [ ] `E05` Implement `UX06` (structured status endpoint behavior contract).
|
| 162 |
+
- [x] `E06` Update README/runbooks for new tools and safe operating guidance.
|
docs/operations/error-taxonomy.md
CHANGED
|
@@ -7,27 +7,27 @@ Canonical tool error taxonomy shared by service tools and telemetry.
|
|
| 7 |
| Error code | Owning tool families |
|
| 8 |
|---|---|
|
| 9 |
| `api_error` | `pushover_notify` |
|
| 10 |
-
| `auth` | `smart_home`, `smart_home_state`, `todoist_*`, `pushover_notify` |
|
| 11 |
-
| `cancelled` | `smart_home*`, `todoist_*`, `pushover_notify` |
|
| 12 |
-
| `http_error` | `smart_home*`, `todoist_*`, `pushover_notify` |
|
| 13 |
-
| `invalid_data` | `smart_home`, `task_plan_*`, `memory_*` |
|
| 14 |
-
| `invalid_json` | `smart_home*`, `todoist_*`, `pushover_notify` |
|
| 15 |
| `invalid_plan` | `task_plan_*` |
|
| 16 |
| `invalid_status` | `task_plan_update` |
|
| 17 |
| `invalid_steps` | `task_plan_create` |
|
| 18 |
-
| `missing_config` | `smart_home*`, `todoist_*`, `pushover_notify` |
|
| 19 |
| `missing_entity` | `smart_home_state` |
|
| 20 |
-
| `missing_fields` | `smart_home`, `todoist_add_task`, `pushover_notify` |
|
| 21 |
| `missing_plan` | `task_plan_*` |
|
| 22 |
| `missing_query` | `memory_search` |
|
| 23 |
| `missing_store` | `memory_*`, `task_plan_*` |
|
| 24 |
| `missing_text` | `memory_add` |
|
| 25 |
-
| `network_client_error` | `smart_home*`, `todoist_*`, `pushover_notify` |
|
| 26 |
-
| `not_found` | `smart_home*`, `task_plan_*` |
|
| 27 |
-
| `policy` | cross-cutting tool policy enforcement |
|
| 28 |
-
| `storage_error` | `memory_*`, `task_plan_*` |
|
| 29 |
| `summary_unavailable` | `tool_summary*`, `system_status` |
|
| 30 |
-
| `timeout` | `smart_home*`, `todoist_*`, `pushover_notify` |
|
| 31 |
| `unexpected` | cross-cutting runtime guard |
|
| 32 |
| `unknown_error` | fallback normalization bucket |
|
| 33 |
|
|
|
|
| 7 |
| Error code | Owning tool families |
|
| 8 |
|---|---|
|
| 9 |
| `api_error` | `pushover_notify` |
|
| 10 |
+
| `auth` | `smart_home`, `smart_home_state`, `home_assistant_conversation`, `todoist_*`, `pushover_notify` |
|
| 11 |
+
| `cancelled` | `smart_home*`, `home_assistant_conversation`, `todoist_*`, `pushover_notify` |
|
| 12 |
+
| `http_error` | `smart_home*`, `home_assistant_conversation`, `todoist_*`, `pushover_notify` |
|
| 13 |
+
| `invalid_data` | `smart_home`, `home_assistant_conversation`, `timer_*`, `task_plan_*`, `memory_*` |
|
| 14 |
+
| `invalid_json` | `smart_home*`, `home_assistant_conversation`, `todoist_*`, `pushover_notify` |
|
| 15 |
| `invalid_plan` | `task_plan_*` |
|
| 16 |
| `invalid_status` | `task_plan_update` |
|
| 17 |
| `invalid_steps` | `task_plan_create` |
|
| 18 |
+
| `missing_config` | `smart_home*`, `home_assistant_conversation`, `todoist_*`, `pushover_notify` |
|
| 19 |
| `missing_entity` | `smart_home_state` |
|
| 20 |
+
| `missing_fields` | `smart_home`, `home_assistant_conversation`, `timer_cancel`, `todoist_add_task`, `pushover_notify` |
|
| 21 |
| `missing_plan` | `task_plan_*` |
|
| 22 |
| `missing_query` | `memory_search` |
|
| 23 |
| `missing_store` | `memory_*`, `task_plan_*` |
|
| 24 |
| `missing_text` | `memory_add` |
|
| 25 |
+
| `network_client_error` | `smart_home*`, `home_assistant_conversation`, `todoist_*`, `pushover_notify` |
|
| 26 |
+
| `not_found` | `smart_home*`, `home_assistant_conversation`, `timer_cancel`, `task_plan_*` |
|
| 27 |
+
| `policy` | cross-cutting tool policy enforcement (`smart_home*`, `home_assistant_conversation`, allow/deny lists) |
|
| 28 |
+
| `storage_error` | `memory_*`, `task_plan_*`, `timer_*` |
|
| 29 |
| `summary_unavailable` | `tool_summary*`, `system_status` |
|
| 30 |
+
| `timeout` | `smart_home*`, `home_assistant_conversation`, `todoist_*`, `pushover_notify` |
|
| 31 |
| `unexpected` | cross-cutting runtime guard |
|
| 32 |
| `unknown_error` | fallback normalization bucket |
|
| 33 |
|
docs/operations/home-control-policy.md
CHANGED
|
@@ -29,7 +29,7 @@ For `smart_home` requests:
|
|
| 29 |
|
| 30 |
For `home_assistant_conversation` requests:
|
| 31 |
- Requires `HOME_CONVERSATION_ENABLED=true`.
|
| 32 |
-
- Requires `
|
| 33 |
- Requires tool argument `confirm=true` on every request.
|
| 34 |
|
| 35 |
## 4) Transport/Service Validation
|
|
@@ -42,6 +42,8 @@ For `home_assistant_conversation` requests:
|
|
| 42 |
- `system_status` includes:
|
| 43 |
- tool allow/deny counts
|
| 44 |
- active `home_permission_profile`
|
|
|
|
|
|
|
| 45 |
|
| 46 |
### Redaction examples
|
| 47 |
Input payload:
|
|
@@ -77,3 +79,4 @@ Audit payload:
|
|
| 77 |
3. Ensure sensitive executes pass `confirm=true`.
|
| 78 |
4. Verify `HASS_URL` and `HASS_TOKEN` are both set.
|
| 79 |
5. If an execute is blocked, check whether an action cooldown is currently active.
|
|
|
|
|
|
| 29 |
|
| 30 |
For `home_assistant_conversation` requests:
|
| 31 |
- Requires `HOME_CONVERSATION_ENABLED=true`.
|
| 32 |
+
- Requires `HOME_CONVERSATION_PERMISSION_PROFILE=control`.
|
| 33 |
- Requires tool argument `confirm=true` on every request.
|
| 34 |
|
| 35 |
## 4) Transport/Service Validation
|
|
|
|
| 42 |
- `system_status` includes:
|
| 43 |
- tool allow/deny counts
|
| 44 |
- active `home_permission_profile`
|
| 45 |
+
- active `home_conversation_enabled`
|
| 46 |
+
- active `home_conversation_permission_profile`
|
| 47 |
|
| 48 |
### Redaction examples
|
| 49 |
Input payload:
|
|
|
|
| 79 |
3. Ensure sensitive executes pass `confirm=true`.
|
| 80 |
4. Verify `HASS_URL` and `HASS_TOKEN` are both set.
|
| 81 |
5. If an execute is blocked, check whether an action cooldown is currently active.
|
| 82 |
+
6. For `home_assistant_conversation`, verify `HOME_CONVERSATION_ENABLED=true`, `HOME_CONVERSATION_PERMISSION_PROFILE=control`, and `confirm=true`.
|
docs/operations/integration-policy.md
CHANGED
|
@@ -109,4 +109,4 @@ Persisted audit details:
|
|
| 109 |
- `timer_create` accepts numeric seconds or compact durations (`90s`, `5m`, `1h 15m`).
|
| 110 |
- `timer_list` reports current active timers and remaining time.
|
| 111 |
- `timer_cancel` removes a timer by `timer_id` or exact `label`.
|
| 112 |
-
- Timers are
|
|
|
|
| 109 |
- `timer_create` accepts numeric seconds or compact durations (`90s`, `5m`, `1h 15m`).
|
| 110 |
- `timer_list` reports current active timers and remaining time.
|
| 111 |
- `timer_cancel` removes a timer by `timer_id` or exact `label`.
|
| 112 |
+
- Timers are persisted in the memory store when memory is enabled and are restored on restart.
|
src/jarvis/config.py
CHANGED
|
@@ -125,6 +125,9 @@ class Config:
|
|
| 125 |
home_permission_profile: str = field(default_factory=lambda: os.environ.get("HOME_PERMISSION_PROFILE", "control"))
|
| 126 |
home_require_confirm_execute: bool = field(default_factory=lambda: _env_bool("HOME_REQUIRE_CONFIRM_EXECUTE") or False)
|
| 127 |
home_conversation_enabled: bool = field(default_factory=lambda: _env_bool("HOME_CONVERSATION_ENABLED") or False)
|
|
|
|
|
|
|
|
|
|
| 128 |
todoist_api_token: str = field(default_factory=lambda: os.environ.get("TODOIST_API_TOKEN", ""))
|
| 129 |
todoist_project_id: str = field(default_factory=lambda: os.environ.get("TODOIST_PROJECT_ID", ""))
|
| 130 |
todoist_permission_profile: str = field(default_factory=lambda: os.environ.get("TODOIST_PERMISSION_PROFILE", "control"))
|
|
@@ -177,6 +180,9 @@ class Config:
|
|
| 177 |
self.backchannel_style = self._normalize_backchannel_style(self.backchannel_style)
|
| 178 |
self.persona_style = self._normalize_persona_style(self.persona_style)
|
| 179 |
self.home_permission_profile = self._normalize_home_permission_profile(self.home_permission_profile)
|
|
|
|
|
|
|
|
|
|
| 180 |
self.todoist_permission_profile = self._normalize_todoist_permission_profile(self.todoist_permission_profile)
|
| 181 |
self.notification_permission_profile = self._normalize_notification_permission_profile(self.notification_permission_profile)
|
| 182 |
if _env_is_set("BACKCHANNEL_STYLE") and self.backchannel_style == "balanced":
|
|
@@ -191,6 +197,13 @@ class Config:
|
|
| 191 |
raw = os.environ.get("HOME_PERMISSION_PROFILE", "")
|
| 192 |
if raw.strip().lower() not in {"readonly", "control"}:
|
| 193 |
self.startup_warnings.append("HOME_PERMISSION_PROFILE invalid; using control.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
if _env_is_set("TODOIST_PERMISSION_PROFILE") and self.todoist_permission_profile == "control":
|
| 195 |
raw = os.environ.get("TODOIST_PERMISSION_PROFILE", "")
|
| 196 |
if raw.strip().lower() not in {"readonly", "control"}:
|
|
@@ -221,6 +234,13 @@ class Config:
|
|
| 221 |
return normalized
|
| 222 |
return "control"
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
@staticmethod
|
| 225 |
def _normalize_todoist_permission_profile(profile: str) -> str:
|
| 226 |
normalized = (profile or "control").strip().lower()
|
|
@@ -248,6 +268,15 @@ class Config:
|
|
| 248 |
and not has_hass_token
|
| 249 |
):
|
| 250 |
warnings.append("HOME_PERMISSION_PROFILE=control set while HASS_URL/HASS_TOKEN are empty.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
has_todoist_token = bool((self.todoist_api_token or "").strip())
|
| 252 |
has_todoist_project = bool((self.todoist_project_id or "").strip())
|
| 253 |
if has_todoist_project and not has_todoist_token:
|
|
|
|
| 125 |
home_permission_profile: str = field(default_factory=lambda: os.environ.get("HOME_PERMISSION_PROFILE", "control"))
|
| 126 |
home_require_confirm_execute: bool = field(default_factory=lambda: _env_bool("HOME_REQUIRE_CONFIRM_EXECUTE") or False)
|
| 127 |
home_conversation_enabled: bool = field(default_factory=lambda: _env_bool("HOME_CONVERSATION_ENABLED") or False)
|
| 128 |
+
home_conversation_permission_profile: str = field(
|
| 129 |
+
default_factory=lambda: os.environ.get("HOME_CONVERSATION_PERMISSION_PROFILE", "readonly")
|
| 130 |
+
)
|
| 131 |
todoist_api_token: str = field(default_factory=lambda: os.environ.get("TODOIST_API_TOKEN", ""))
|
| 132 |
todoist_project_id: str = field(default_factory=lambda: os.environ.get("TODOIST_PROJECT_ID", ""))
|
| 133 |
todoist_permission_profile: str = field(default_factory=lambda: os.environ.get("TODOIST_PERMISSION_PROFILE", "control"))
|
|
|
|
| 180 |
self.backchannel_style = self._normalize_backchannel_style(self.backchannel_style)
|
| 181 |
self.persona_style = self._normalize_persona_style(self.persona_style)
|
| 182 |
self.home_permission_profile = self._normalize_home_permission_profile(self.home_permission_profile)
|
| 183 |
+
self.home_conversation_permission_profile = self._normalize_home_conversation_permission_profile(
|
| 184 |
+
self.home_conversation_permission_profile
|
| 185 |
+
)
|
| 186 |
self.todoist_permission_profile = self._normalize_todoist_permission_profile(self.todoist_permission_profile)
|
| 187 |
self.notification_permission_profile = self._normalize_notification_permission_profile(self.notification_permission_profile)
|
| 188 |
if _env_is_set("BACKCHANNEL_STYLE") and self.backchannel_style == "balanced":
|
|
|
|
| 197 |
raw = os.environ.get("HOME_PERMISSION_PROFILE", "")
|
| 198 |
if raw.strip().lower() not in {"readonly", "control"}:
|
| 199 |
self.startup_warnings.append("HOME_PERMISSION_PROFILE invalid; using control.")
|
| 200 |
+
if (
|
| 201 |
+
_env_is_set("HOME_CONVERSATION_PERMISSION_PROFILE")
|
| 202 |
+
and self.home_conversation_permission_profile == "readonly"
|
| 203 |
+
):
|
| 204 |
+
raw = os.environ.get("HOME_CONVERSATION_PERMISSION_PROFILE", "")
|
| 205 |
+
if raw.strip().lower() not in {"readonly", "control"}:
|
| 206 |
+
self.startup_warnings.append("HOME_CONVERSATION_PERMISSION_PROFILE invalid; using readonly.")
|
| 207 |
if _env_is_set("TODOIST_PERMISSION_PROFILE") and self.todoist_permission_profile == "control":
|
| 208 |
raw = os.environ.get("TODOIST_PERMISSION_PROFILE", "")
|
| 209 |
if raw.strip().lower() not in {"readonly", "control"}:
|
|
|
|
| 234 |
return normalized
|
| 235 |
return "control"
|
| 236 |
|
| 237 |
+
@staticmethod
|
| 238 |
+
def _normalize_home_conversation_permission_profile(profile: str) -> str:
|
| 239 |
+
normalized = (profile or "readonly").strip().lower()
|
| 240 |
+
if normalized in {"readonly", "control"}:
|
| 241 |
+
return normalized
|
| 242 |
+
return "readonly"
|
| 243 |
+
|
| 244 |
@staticmethod
|
| 245 |
def _normalize_todoist_permission_profile(profile: str) -> str:
|
| 246 |
normalized = (profile or "control").strip().lower()
|
|
|
|
| 268 |
and not has_hass_token
|
| 269 |
):
|
| 270 |
warnings.append("HOME_PERMISSION_PROFILE=control set while HASS_URL/HASS_TOKEN are empty.")
|
| 271 |
+
if (
|
| 272 |
+
_env_is_set("HOME_CONVERSATION_PERMISSION_PROFILE")
|
| 273 |
+
and self.home_conversation_permission_profile == "control"
|
| 274 |
+
and not has_hass_url
|
| 275 |
+
and not has_hass_token
|
| 276 |
+
):
|
| 277 |
+
warnings.append(
|
| 278 |
+
"HOME_CONVERSATION_PERMISSION_PROFILE=control set while HASS_URL/HASS_TOKEN are empty."
|
| 279 |
+
)
|
| 280 |
has_todoist_token = bool((self.todoist_api_token or "").strip())
|
| 281 |
has_todoist_project = bool((self.todoist_project_id or "").strip())
|
| 282 |
if has_todoist_project and not has_todoist_token:
|
src/jarvis/memory.py
CHANGED
|
@@ -49,6 +49,17 @@ class MemorySummary:
|
|
| 49 |
updated_at: float
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
class MemoryStore:
|
| 53 |
def __init__(self, path: str) -> None:
|
| 54 |
if path not in {":memory:", ""}:
|
|
@@ -134,6 +145,20 @@ class MemoryStore:
|
|
| 134 |
"""
|
| 135 |
)
|
| 136 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_memory_summaries_updated ON memory_summaries(updated_at DESC);")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
try:
|
| 138 |
cur.execute(
|
| 139 |
"""
|
|
@@ -454,6 +479,95 @@ class MemoryStore:
|
|
| 454 |
updated_at=float(row["updated_at"]),
|
| 455 |
)
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
def close(self) -> None:
|
| 458 |
if self._closed:
|
| 459 |
return
|
|
@@ -470,6 +584,7 @@ class MemoryStore:
|
|
| 470 |
"fts": self._fts_enabled,
|
| 471 |
"vector": self._memory_enabled,
|
| 472 |
"sources": source_counts,
|
|
|
|
| 473 |
"last_warm": self._last_warm,
|
| 474 |
"last_sync": self._last_sync,
|
| 475 |
"last_optimize": self._last_optimize,
|
|
@@ -511,6 +626,18 @@ class MemoryStore:
|
|
| 511 |
source=str(row["source"]),
|
| 512 |
)
|
| 513 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
@staticmethod
|
| 515 |
def _normalize_limit(value: Any, *, default: int = 5) -> int:
|
| 516 |
parsed = default
|
|
|
|
| 49 |
updated_at: float
|
| 50 |
|
| 51 |
|
| 52 |
+
@dataclass
|
| 53 |
+
class TimerEntry:
|
| 54 |
+
id: int
|
| 55 |
+
created_at: float
|
| 56 |
+
due_at: float
|
| 57 |
+
duration_sec: float
|
| 58 |
+
label: str
|
| 59 |
+
status: str
|
| 60 |
+
cancelled_at: float | None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
class MemoryStore:
|
| 64 |
def __init__(self, path: str) -> None:
|
| 65 |
if path not in {":memory:", ""}:
|
|
|
|
| 145 |
"""
|
| 146 |
)
|
| 147 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_memory_summaries_updated ON memory_summaries(updated_at DESC);")
|
| 148 |
+
cur.execute(
|
| 149 |
+
"""
|
| 150 |
+
CREATE TABLE IF NOT EXISTS timers (
|
| 151 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 152 |
+
created_at REAL NOT NULL,
|
| 153 |
+
due_at REAL NOT NULL,
|
| 154 |
+
duration_sec REAL NOT NULL,
|
| 155 |
+
label TEXT NOT NULL DEFAULT '',
|
| 156 |
+
status TEXT NOT NULL DEFAULT 'active',
|
| 157 |
+
cancelled_at REAL
|
| 158 |
+
);
|
| 159 |
+
"""
|
| 160 |
+
)
|
| 161 |
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_timers_status_due ON timers(status, due_at ASC);")
|
| 162 |
try:
|
| 163 |
cur.execute(
|
| 164 |
"""
|
|
|
|
| 479 |
updated_at=float(row["updated_at"]),
|
| 480 |
)
|
| 481 |
|
| 482 |
+
def add_timer(
|
| 483 |
+
self,
|
| 484 |
+
*,
|
| 485 |
+
due_at: float,
|
| 486 |
+
duration_sec: float,
|
| 487 |
+
label: str = "",
|
| 488 |
+
created_at: float | None = None,
|
| 489 |
+
) -> int:
|
| 490 |
+
if not math.isfinite(due_at):
|
| 491 |
+
raise ValueError("due_at must be finite")
|
| 492 |
+
if not math.isfinite(duration_sec) or duration_sec <= 0.0:
|
| 493 |
+
raise ValueError("duration_sec must be > 0")
|
| 494 |
+
created = time.time() if created_at is None else float(created_at)
|
| 495 |
+
clean_label = label.strip()
|
| 496 |
+
cur = self._conn.cursor()
|
| 497 |
+
cur.execute(
|
| 498 |
+
"""
|
| 499 |
+
INSERT INTO timers(created_at, due_at, duration_sec, label, status, cancelled_at)
|
| 500 |
+
VALUES (?, ?, ?, ?, 'active', NULL)
|
| 501 |
+
""",
|
| 502 |
+
(created, float(due_at), float(duration_sec), clean_label),
|
| 503 |
+
)
|
| 504 |
+
self._conn.commit()
|
| 505 |
+
return int(cur.lastrowid)
|
| 506 |
+
|
| 507 |
+
def list_timers(
|
| 508 |
+
self,
|
| 509 |
+
*,
|
| 510 |
+
status: str = "active",
|
| 511 |
+
include_expired: bool = False,
|
| 512 |
+
now: float | None = None,
|
| 513 |
+
limit: int = 200,
|
| 514 |
+
) -> list[TimerEntry]:
|
| 515 |
+
clean_status = status.strip().lower()
|
| 516 |
+
if clean_status not in {"active", "expired", "cancelled"}:
|
| 517 |
+
raise ValueError("status must be active, expired, or cancelled")
|
| 518 |
+
limit = self._normalize_limit(limit, default=200)
|
| 519 |
+
cur = self._conn.cursor()
|
| 520 |
+
params: list[Any] = [clean_status]
|
| 521 |
+
where = "status = ?"
|
| 522 |
+
if clean_status == "active" and not include_expired:
|
| 523 |
+
now_ts = time.time() if now is None else float(now)
|
| 524 |
+
where += " AND due_at > ?"
|
| 525 |
+
params.append(now_ts)
|
| 526 |
+
rows = cur.execute(
|
| 527 |
+
f"SELECT * FROM timers WHERE {where} ORDER BY due_at ASC LIMIT ?",
|
| 528 |
+
(*params, limit),
|
| 529 |
+
).fetchall()
|
| 530 |
+
return [self._row_to_timer(row) for row in rows]
|
| 531 |
+
|
| 532 |
+
def expire_timers(self, *, now: float | None = None) -> int:
|
| 533 |
+
now_ts = time.time() if now is None else float(now)
|
| 534 |
+
with self._conn:
|
| 535 |
+
cur = self._conn.cursor()
|
| 536 |
+
cur.execute(
|
| 537 |
+
"""
|
| 538 |
+
UPDATE timers
|
| 539 |
+
SET status = 'expired'
|
| 540 |
+
WHERE status = 'active' AND due_at <= ?
|
| 541 |
+
""",
|
| 542 |
+
(now_ts,),
|
| 543 |
+
)
|
| 544 |
+
return int(cur.rowcount)
|
| 545 |
+
|
| 546 |
+
def cancel_timer(self, timer_id: int, *, cancelled_at: float | None = None) -> bool:
|
| 547 |
+
cancelled = time.time() if cancelled_at is None else float(cancelled_at)
|
| 548 |
+
with self._conn:
|
| 549 |
+
cur = self._conn.cursor()
|
| 550 |
+
cur.execute(
|
| 551 |
+
"""
|
| 552 |
+
UPDATE timers
|
| 553 |
+
SET status = 'cancelled', cancelled_at = ?
|
| 554 |
+
WHERE id = ? AND status = 'active'
|
| 555 |
+
""",
|
| 556 |
+
(cancelled, int(timer_id)),
|
| 557 |
+
)
|
| 558 |
+
return cur.rowcount > 0
|
| 559 |
+
|
| 560 |
+
def timer_counts(self) -> dict[str, int]:
|
| 561 |
+
rows = self._conn.cursor().execute(
|
| 562 |
+
"SELECT status, COUNT(*) AS c FROM timers GROUP BY status",
|
| 563 |
+
).fetchall()
|
| 564 |
+
counts = {"active": 0, "expired": 0, "cancelled": 0}
|
| 565 |
+
for row in rows:
|
| 566 |
+
key = str(row["status"])
|
| 567 |
+
if key in counts:
|
| 568 |
+
counts[key] = int(row["c"])
|
| 569 |
+
return counts
|
| 570 |
+
|
| 571 |
def close(self) -> None:
|
| 572 |
if self._closed:
|
| 573 |
return
|
|
|
|
| 584 |
"fts": self._fts_enabled,
|
| 585 |
"vector": self._memory_enabled,
|
| 586 |
"sources": source_counts,
|
| 587 |
+
"timers": self.timer_counts(),
|
| 588 |
"last_warm": self._last_warm,
|
| 589 |
"last_sync": self._last_sync,
|
| 590 |
"last_optimize": self._last_optimize,
|
|
|
|
| 626 |
source=str(row["source"]),
|
| 627 |
)
|
| 628 |
|
| 629 |
+
def _row_to_timer(self, row: sqlite3.Row) -> TimerEntry:
|
| 630 |
+
cancelled_at = row["cancelled_at"]
|
| 631 |
+
return TimerEntry(
|
| 632 |
+
id=int(row["id"]),
|
| 633 |
+
created_at=float(row["created_at"]),
|
| 634 |
+
due_at=float(row["due_at"]),
|
| 635 |
+
duration_sec=float(row["duration_sec"]),
|
| 636 |
+
label=str(row["label"] or ""),
|
| 637 |
+
status=str(row["status"]),
|
| 638 |
+
cancelled_at=float(cancelled_at) if cancelled_at is not None else None,
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
@staticmethod
|
| 642 |
def _normalize_limit(value: Any, *, default: int = 5) -> int:
|
| 643 |
parsed = default
|
src/jarvis/tools/services.py
CHANGED
|
@@ -67,6 +67,7 @@ _audit_log_backups: int = 3
|
|
| 67 |
_home_permission_profile: str = "control"
|
| 68 |
_home_require_confirm_execute: bool = False
|
| 69 |
_home_conversation_enabled: bool = False
|
|
|
|
| 70 |
_todoist_permission_profile: str = "control"
|
| 71 |
_notification_permission_profile: str = "allow"
|
| 72 |
_todoist_timeout_sec: float = 10.0
|
|
@@ -344,6 +345,7 @@ def _record_service_error(tool_name: str, start_time: float, code: str) -> None:
|
|
| 344 |
def bind(config: Config, memory_store: MemoryStore | None = None) -> None:
|
| 345 |
global _config, _memory, _audit_log_max_bytes, _audit_log_backups
|
| 346 |
global _home_permission_profile, _home_require_confirm_execute, _home_conversation_enabled
|
|
|
|
| 347 |
global _todoist_permission_profile, _notification_permission_profile
|
| 348 |
global _todoist_timeout_sec, _pushover_timeout_sec
|
| 349 |
global _timer_id_seq
|
|
@@ -356,6 +358,11 @@ def bind(config: Config, memory_store: MemoryStore | None = None) -> None:
|
|
| 356 |
_home_permission_profile = "control"
|
| 357 |
_home_require_confirm_execute = bool(getattr(config, "home_require_confirm_execute", False))
|
| 358 |
_home_conversation_enabled = bool(getattr(config, "home_conversation_enabled", False))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
_todoist_permission_profile = str(getattr(config, "todoist_permission_profile", "control")).strip().lower()
|
| 360 |
if _todoist_permission_profile not in {"readonly", "control"}:
|
| 361 |
_todoist_permission_profile = "control"
|
|
@@ -370,6 +377,7 @@ def bind(config: Config, memory_store: MemoryStore | None = None) -> None:
|
|
| 370 |
_ha_state_cache.clear()
|
| 371 |
_timers.clear()
|
| 372 |
_timer_id_seq = 1
|
|
|
|
| 373 |
global _tool_allowlist, _tool_denylist
|
| 374 |
_tool_allowlist = list(config.tool_allowlist)
|
| 375 |
_tool_denylist = list(config.tool_denylist)
|
|
@@ -738,6 +746,11 @@ def _audit_status() -> dict[str, Any]:
|
|
| 738 |
|
| 739 |
|
| 740 |
def _prune_timers(now_mono: float | None = None) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
if not _timers:
|
| 742 |
return
|
| 743 |
current = time.monotonic() if now_mono is None else now_mono
|
|
@@ -758,6 +771,37 @@ def _timer_status() -> dict[str, Any]:
|
|
| 758 |
}
|
| 759 |
|
| 760 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
def _ha_headers() -> dict[str, str]:
|
| 762 |
assert _config is not None
|
| 763 |
return {"Authorization": f"Bearer {_config.hass_token}"}
|
|
@@ -1113,10 +1157,17 @@ async def home_assistant_conversation(args: dict[str, Any]) -> dict[str, Any]:
|
|
| 1113 |
}
|
| 1114 |
]
|
| 1115 |
}
|
| 1116 |
-
if
|
| 1117 |
_record_service_error("home_assistant_conversation", start_time, "policy")
|
| 1118 |
-
_audit("home_assistant_conversation", {"result": "denied", "reason": "
|
| 1119 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1120 |
text = str(args.get("text", "")).strip()
|
| 1121 |
if not text:
|
| 1122 |
_record_service_error("home_assistant_conversation", start_time, "missing_fields")
|
|
@@ -1619,6 +1670,7 @@ async def system_status(args: dict[str, Any]) -> dict[str, Any]:
|
|
| 1619 |
"home_permission_profile": _home_permission_profile,
|
| 1620 |
"home_require_confirm_execute": bool(_home_require_confirm_execute),
|
| 1621 |
"home_conversation_enabled": bool(_home_conversation_enabled),
|
|
|
|
| 1622 |
"todoist_permission_profile": _todoist_permission_profile,
|
| 1623 |
"notification_permission_profile": _notification_permission_profile,
|
| 1624 |
},
|
|
@@ -2007,11 +2059,24 @@ async def timer_create(args: dict[str, Any]) -> dict[str, Any]:
|
|
| 2007 |
_record_service_error("timer_create", start_time, "invalid_data")
|
| 2008 |
return {"content": [{"type": "text", "text": f"Too many active timers ({TIMER_MAX_ACTIVE} max)."}]}
|
| 2009 |
label = str(args.get("label", "")).strip()
|
| 2010 |
-
timer_id = _allocate_timer_id()
|
| 2011 |
now_wall = time.time()
|
| 2012 |
now_mono = time.monotonic()
|
| 2013 |
due_wall = now_wall + duration
|
| 2014 |
due_mono = now_mono + duration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2015 |
_timers[timer_id] = {
|
| 2016 |
"id": timer_id,
|
| 2017 |
"label": label,
|
|
@@ -2106,6 +2171,15 @@ async def timer_cancel(args: dict[str, Any]) -> dict[str, Any]:
|
|
| 2106 |
if selected_id is None:
|
| 2107 |
_record_service_error("timer_cancel", start_time, "not_found")
|
| 2108 |
return {"content": [{"type": "text", "text": "Timer not found."}]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2109 |
removed = _timers.pop(selected_id, None)
|
| 2110 |
if removed is None:
|
| 2111 |
_record_service_error("timer_cancel", start_time, "not_found")
|
|
|
|
| 67 |
_home_permission_profile: str = "control"
|
| 68 |
_home_require_confirm_execute: bool = False
|
| 69 |
_home_conversation_enabled: bool = False
|
| 70 |
+
_home_conversation_permission_profile: str = "readonly"
|
| 71 |
_todoist_permission_profile: str = "control"
|
| 72 |
_notification_permission_profile: str = "allow"
|
| 73 |
_todoist_timeout_sec: float = 10.0
|
|
|
|
| 345 |
def bind(config: Config, memory_store: MemoryStore | None = None) -> None:
|
| 346 |
global _config, _memory, _audit_log_max_bytes, _audit_log_backups
|
| 347 |
global _home_permission_profile, _home_require_confirm_execute, _home_conversation_enabled
|
| 348 |
+
global _home_conversation_permission_profile
|
| 349 |
global _todoist_permission_profile, _notification_permission_profile
|
| 350 |
global _todoist_timeout_sec, _pushover_timeout_sec
|
| 351 |
global _timer_id_seq
|
|
|
|
| 358 |
_home_permission_profile = "control"
|
| 359 |
_home_require_confirm_execute = bool(getattr(config, "home_require_confirm_execute", False))
|
| 360 |
_home_conversation_enabled = bool(getattr(config, "home_conversation_enabled", False))
|
| 361 |
+
_home_conversation_permission_profile = str(
|
| 362 |
+
getattr(config, "home_conversation_permission_profile", "readonly")
|
| 363 |
+
).strip().lower()
|
| 364 |
+
if _home_conversation_permission_profile not in {"readonly", "control"}:
|
| 365 |
+
_home_conversation_permission_profile = "readonly"
|
| 366 |
_todoist_permission_profile = str(getattr(config, "todoist_permission_profile", "control")).strip().lower()
|
| 367 |
if _todoist_permission_profile not in {"readonly", "control"}:
|
| 368 |
_todoist_permission_profile = "control"
|
|
|
|
| 377 |
_ha_state_cache.clear()
|
| 378 |
_timers.clear()
|
| 379 |
_timer_id_seq = 1
|
| 380 |
+
_load_timers_from_store()
|
| 381 |
global _tool_allowlist, _tool_denylist
|
| 382 |
_tool_allowlist = list(config.tool_allowlist)
|
| 383 |
_tool_denylist = list(config.tool_denylist)
|
|
|
|
| 746 |
|
| 747 |
|
| 748 |
def _prune_timers(now_mono: float | None = None) -> None:
|
| 749 |
+
if _memory is not None:
|
| 750 |
+
try:
|
| 751 |
+
_memory.expire_timers(now=time.time())
|
| 752 |
+
except Exception:
|
| 753 |
+
log.warning("Failed to expire persisted timers", exc_info=True)
|
| 754 |
if not _timers:
|
| 755 |
return
|
| 756 |
current = time.monotonic() if now_mono is None else now_mono
|
|
|
|
| 771 |
}
|
| 772 |
|
| 773 |
|
| 774 |
+
def _load_timers_from_store() -> None:
|
| 775 |
+
global _timer_id_seq
|
| 776 |
+
if _memory is None:
|
| 777 |
+
return
|
| 778 |
+
now_wall = time.time()
|
| 779 |
+
now_mono = time.monotonic()
|
| 780 |
+
try:
|
| 781 |
+
_memory.expire_timers(now=now_wall)
|
| 782 |
+
rows = _memory.list_timers(status="active", include_expired=False, now=now_wall, limit=TIMER_MAX_ACTIVE)
|
| 783 |
+
except Exception:
|
| 784 |
+
log.warning("Failed to load persisted timers", exc_info=True)
|
| 785 |
+
return
|
| 786 |
+
max_id = 0
|
| 787 |
+
for row in rows:
|
| 788 |
+
remaining = float(row.due_at) - now_wall
|
| 789 |
+
if remaining <= 0.0:
|
| 790 |
+
continue
|
| 791 |
+
timer_id = int(row.id)
|
| 792 |
+
max_id = max(max_id, timer_id)
|
| 793 |
+
_timers[timer_id] = {
|
| 794 |
+
"id": timer_id,
|
| 795 |
+
"label": row.label,
|
| 796 |
+
"duration_sec": float(row.duration_sec),
|
| 797 |
+
"created_at": float(row.created_at),
|
| 798 |
+
"due_at": float(row.due_at),
|
| 799 |
+
"due_mono": now_mono + remaining,
|
| 800 |
+
}
|
| 801 |
+
if max_id >= _timer_id_seq:
|
| 802 |
+
_timer_id_seq = max_id + 1
|
| 803 |
+
|
| 804 |
+
|
| 805 |
def _ha_headers() -> dict[str, str]:
|
| 806 |
assert _config is not None
|
| 807 |
return {"Authorization": f"Bearer {_config.hass_token}"}
|
|
|
|
| 1157 |
}
|
| 1158 |
]
|
| 1159 |
}
|
| 1160 |
+
if _home_conversation_permission_profile != "control":
|
| 1161 |
_record_service_error("home_assistant_conversation", start_time, "policy")
|
| 1162 |
+
_audit("home_assistant_conversation", {"result": "denied", "reason": "conversation_readonly_profile"})
|
| 1163 |
+
return {
|
| 1164 |
+
"content": [
|
| 1165 |
+
{
|
| 1166 |
+
"type": "text",
|
| 1167 |
+
"text": "Home Assistant conversation requires HOME_CONVERSATION_PERMISSION_PROFILE=control.",
|
| 1168 |
+
}
|
| 1169 |
+
]
|
| 1170 |
+
}
|
| 1171 |
text = str(args.get("text", "")).strip()
|
| 1172 |
if not text:
|
| 1173 |
_record_service_error("home_assistant_conversation", start_time, "missing_fields")
|
|
|
|
| 1670 |
"home_permission_profile": _home_permission_profile,
|
| 1671 |
"home_require_confirm_execute": bool(_home_require_confirm_execute),
|
| 1672 |
"home_conversation_enabled": bool(_home_conversation_enabled),
|
| 1673 |
+
"home_conversation_permission_profile": _home_conversation_permission_profile,
|
| 1674 |
"todoist_permission_profile": _todoist_permission_profile,
|
| 1675 |
"notification_permission_profile": _notification_permission_profile,
|
| 1676 |
},
|
|
|
|
| 2059 |
_record_service_error("timer_create", start_time, "invalid_data")
|
| 2060 |
return {"content": [{"type": "text", "text": f"Too many active timers ({TIMER_MAX_ACTIVE} max)."}]}
|
| 2061 |
label = str(args.get("label", "")).strip()
|
|
|
|
| 2062 |
now_wall = time.time()
|
| 2063 |
now_mono = time.monotonic()
|
| 2064 |
due_wall = now_wall + duration
|
| 2065 |
due_mono = now_mono + duration
|
| 2066 |
+
timer_id: int
|
| 2067 |
+
if _memory is not None:
|
| 2068 |
+
try:
|
| 2069 |
+
timer_id = _memory.add_timer(
|
| 2070 |
+
due_at=due_wall,
|
| 2071 |
+
duration_sec=duration,
|
| 2072 |
+
label=label,
|
| 2073 |
+
created_at=now_wall,
|
| 2074 |
+
)
|
| 2075 |
+
except Exception:
|
| 2076 |
+
_record_service_error("timer_create", start_time, "storage_error")
|
| 2077 |
+
return {"content": [{"type": "text", "text": "Timer create failed: persistent storage unavailable."}]}
|
| 2078 |
+
else:
|
| 2079 |
+
timer_id = _allocate_timer_id()
|
| 2080 |
_timers[timer_id] = {
|
| 2081 |
"id": timer_id,
|
| 2082 |
"label": label,
|
|
|
|
| 2171 |
if selected_id is None:
|
| 2172 |
_record_service_error("timer_cancel", start_time, "not_found")
|
| 2173 |
return {"content": [{"type": "text", "text": "Timer not found."}]}
|
| 2174 |
+
if _memory is not None:
|
| 2175 |
+
try:
|
| 2176 |
+
cancelled = _memory.cancel_timer(selected_id)
|
| 2177 |
+
except Exception:
|
| 2178 |
+
_record_service_error("timer_cancel", start_time, "storage_error")
|
| 2179 |
+
return {"content": [{"type": "text", "text": "Timer cancel failed: persistent storage unavailable."}]}
|
| 2180 |
+
if not cancelled:
|
| 2181 |
+
_record_service_error("timer_cancel", start_time, "not_found")
|
| 2182 |
+
return {"content": [{"type": "text", "text": "Timer not found."}]}
|
| 2183 |
removed = _timers.pop(selected_id, None)
|
| 2184 |
if removed is None:
|
| 2185 |
_record_service_error("timer_cancel", start_time, "not_found")
|
tests/test_config.py
CHANGED
|
@@ -97,6 +97,12 @@ class TestConfig:
|
|
| 97 |
c = Config(home_permission_profile="execute-all")
|
| 98 |
assert c.home_permission_profile == "control"
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def test_todoist_permission_profile_normalizes(self, monkeypatch):
|
| 101 |
monkeypatch.setenv("ANTHROPIC_API_KEY", "test")
|
| 102 |
from jarvis.config import Config
|
|
@@ -130,6 +136,7 @@ class TestConfig:
|
|
| 130 |
monkeypatch.setenv("HOME_REQUIRE_CONFIRM_EXECUTE", "sometimes")
|
| 131 |
monkeypatch.setenv("HOME_CONVERSATION_ENABLED", "perhaps")
|
| 132 |
monkeypatch.setenv("HOME_PERMISSION_PROFILE", "execute-all")
|
|
|
|
| 133 |
monkeypatch.setenv("TODOIST_PERMISSION_PROFILE", "write-all")
|
| 134 |
monkeypatch.setenv("NOTIFICATION_PERMISSION_PROFILE", "enabled")
|
| 135 |
from jarvis.config import Config
|
|
@@ -147,6 +154,7 @@ class TestConfig:
|
|
| 147 |
assert "HOME_REQUIRE_CONFIRM_EXECUTE invalid boolean" in text
|
| 148 |
assert "HOME_CONVERSATION_ENABLED invalid boolean" in text
|
| 149 |
assert "HOME_PERMISSION_PROFILE invalid" in text
|
|
|
|
| 150 |
assert "TODOIST_PERMISSION_PROFILE invalid" in text
|
| 151 |
assert "NOTIFICATION_PERMISSION_PROFILE invalid" in text
|
| 152 |
|
|
@@ -242,6 +250,7 @@ class TestConfig:
|
|
| 242 |
def test_startup_warning_for_permissive_profiles_without_credentials(self, monkeypatch):
|
| 243 |
monkeypatch.setenv("ANTHROPIC_API_KEY", "test")
|
| 244 |
monkeypatch.setenv("HOME_PERMISSION_PROFILE", "control")
|
|
|
|
| 245 |
monkeypatch.setenv("TODOIST_PERMISSION_PROFILE", "control")
|
| 246 |
monkeypatch.setenv("NOTIFICATION_PERMISSION_PROFILE", "allow")
|
| 247 |
monkeypatch.delenv("HASS_URL", raising=False)
|
|
@@ -254,5 +263,6 @@ class TestConfig:
|
|
| 254 |
c = Config()
|
| 255 |
text = "\n".join(c.startup_warnings).lower()
|
| 256 |
assert "home_permission_profile=control set while hass_url/hass_token are empty" in text
|
|
|
|
| 257 |
assert "todoist_permission_profile=control set while todoist_api_token is empty" in text
|
| 258 |
assert "notification_permission_profile=allow set while pushover credentials are empty" in text
|
|
|
|
| 97 |
c = Config(home_permission_profile="execute-all")
|
| 98 |
assert c.home_permission_profile == "control"
|
| 99 |
|
| 100 |
+
def test_home_conversation_permission_profile_normalizes(self, monkeypatch):
|
| 101 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "test")
|
| 102 |
+
from jarvis.config import Config
|
| 103 |
+
c = Config(home_conversation_permission_profile="execute-all")
|
| 104 |
+
assert c.home_conversation_permission_profile == "readonly"
|
| 105 |
+
|
| 106 |
def test_todoist_permission_profile_normalizes(self, monkeypatch):
|
| 107 |
monkeypatch.setenv("ANTHROPIC_API_KEY", "test")
|
| 108 |
from jarvis.config import Config
|
|
|
|
| 136 |
monkeypatch.setenv("HOME_REQUIRE_CONFIRM_EXECUTE", "sometimes")
|
| 137 |
monkeypatch.setenv("HOME_CONVERSATION_ENABLED", "perhaps")
|
| 138 |
monkeypatch.setenv("HOME_PERMISSION_PROFILE", "execute-all")
|
| 139 |
+
monkeypatch.setenv("HOME_CONVERSATION_PERMISSION_PROFILE", "execute-all")
|
| 140 |
monkeypatch.setenv("TODOIST_PERMISSION_PROFILE", "write-all")
|
| 141 |
monkeypatch.setenv("NOTIFICATION_PERMISSION_PROFILE", "enabled")
|
| 142 |
from jarvis.config import Config
|
|
|
|
| 154 |
assert "HOME_REQUIRE_CONFIRM_EXECUTE invalid boolean" in text
|
| 155 |
assert "HOME_CONVERSATION_ENABLED invalid boolean" in text
|
| 156 |
assert "HOME_PERMISSION_PROFILE invalid" in text
|
| 157 |
+
assert "HOME_CONVERSATION_PERMISSION_PROFILE invalid" in text
|
| 158 |
assert "TODOIST_PERMISSION_PROFILE invalid" in text
|
| 159 |
assert "NOTIFICATION_PERMISSION_PROFILE invalid" in text
|
| 160 |
|
|
|
|
| 250 |
def test_startup_warning_for_permissive_profiles_without_credentials(self, monkeypatch):
|
| 251 |
monkeypatch.setenv("ANTHROPIC_API_KEY", "test")
|
| 252 |
monkeypatch.setenv("HOME_PERMISSION_PROFILE", "control")
|
| 253 |
+
monkeypatch.setenv("HOME_CONVERSATION_PERMISSION_PROFILE", "control")
|
| 254 |
monkeypatch.setenv("TODOIST_PERMISSION_PROFILE", "control")
|
| 255 |
monkeypatch.setenv("NOTIFICATION_PERMISSION_PROFILE", "allow")
|
| 256 |
monkeypatch.delenv("HASS_URL", raising=False)
|
|
|
|
| 263 |
c = Config()
|
| 264 |
text = "\n".join(c.startup_warnings).lower()
|
| 265 |
assert "home_permission_profile=control set while hass_url/hass_token are empty" in text
|
| 266 |
+
assert "home_conversation_permission_profile=control set while hass_url/hass_token are empty" in text
|
| 267 |
assert "todoist_permission_profile=control set while todoist_api_token is empty" in text
|
| 268 |
assert "notification_permission_profile=allow set while pushover credentials are empty" in text
|
tests/test_memory.py
CHANGED
|
@@ -206,3 +206,48 @@ def test_update_task_step_rolls_back_on_plan_status_failure(tmp_path):
|
|
| 206 |
assert plan_status == "open"
|
| 207 |
finally:
|
| 208 |
store.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
assert plan_status == "open"
|
| 207 |
finally:
|
| 208 |
store.close()
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_timer_store_lifecycle(tmp_path):
|
| 212 |
+
store = MemoryStore(str(tmp_path / "memory.sqlite"))
|
| 213 |
+
try:
|
| 214 |
+
now = 1_700_000_000.0
|
| 215 |
+
timer_id = store.add_timer(
|
| 216 |
+
due_at=now + 120.0,
|
| 217 |
+
duration_sec=120.0,
|
| 218 |
+
label="tea",
|
| 219 |
+
created_at=now,
|
| 220 |
+
)
|
| 221 |
+
active = store.list_timers(status="active", include_expired=False, now=now)
|
| 222 |
+
assert [timer.id for timer in active] == [timer_id]
|
| 223 |
+
assert active[0].label == "tea"
|
| 224 |
+
|
| 225 |
+
assert store.cancel_timer(timer_id) is True
|
| 226 |
+
assert store.cancel_timer(timer_id) is False
|
| 227 |
+
cancelled = store.list_timers(status="cancelled")
|
| 228 |
+
assert [timer.id for timer in cancelled] == [timer_id]
|
| 229 |
+
finally:
|
| 230 |
+
store.close()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_expire_timers_moves_due_rows_to_expired(tmp_path):
|
| 234 |
+
store = MemoryStore(str(tmp_path / "memory.sqlite"))
|
| 235 |
+
try:
|
| 236 |
+
now = 1_700_000_000.0
|
| 237 |
+
_ = store.add_timer(
|
| 238 |
+
due_at=now - 5.0,
|
| 239 |
+
duration_sec=30.0,
|
| 240 |
+
label="old",
|
| 241 |
+
created_at=now - 35.0,
|
| 242 |
+
)
|
| 243 |
+
changed = store.expire_timers(now=now)
|
| 244 |
+
assert changed == 1
|
| 245 |
+
active = store.list_timers(status="active", include_expired=False, now=now)
|
| 246 |
+
assert active == []
|
| 247 |
+
expired = store.list_timers(status="expired")
|
| 248 |
+
assert len(expired) == 1
|
| 249 |
+
counts = store.timer_counts()
|
| 250 |
+
assert counts["expired"] == 1
|
| 251 |
+
assert counts["active"] == 0
|
| 252 |
+
finally:
|
| 253 |
+
store.close()
|
tests/test_tools_services.py
CHANGED
|
@@ -797,6 +797,7 @@ class TestServicesTools:
|
|
| 797 |
cfg = services._config
|
| 798 |
assert cfg is not None
|
| 799 |
cfg.home_conversation_enabled = True
|
|
|
|
| 800 |
services.bind(cfg)
|
| 801 |
|
| 802 |
result = await services.home_assistant_conversation({
|
|
@@ -811,14 +812,14 @@ class TestServicesTools:
|
|
| 811 |
cfg = services._config
|
| 812 |
assert cfg is not None
|
| 813 |
cfg.home_conversation_enabled = True
|
| 814 |
-
cfg.
|
| 815 |
services.bind(cfg)
|
| 816 |
|
| 817 |
result = await services.home_assistant_conversation({
|
| 818 |
"text": "turn on kitchen lights",
|
| 819 |
"confirm": True,
|
| 820 |
})
|
| 821 |
-
assert "
|
| 822 |
|
| 823 |
@pytest.mark.asyncio
|
| 824 |
async def test_home_assistant_conversation_success(self, aiohttp_response, aiohttp_session_mock):
|
|
@@ -827,7 +828,7 @@ class TestServicesTools:
|
|
| 827 |
cfg = services._config
|
| 828 |
assert cfg is not None
|
| 829 |
cfg.home_conversation_enabled = True
|
| 830 |
-
cfg.
|
| 831 |
services.bind(cfg)
|
| 832 |
|
| 833 |
response = aiohttp_response(
|
|
@@ -860,7 +861,7 @@ class TestServicesTools:
|
|
| 860 |
cfg = services._config
|
| 861 |
assert cfg is not None
|
| 862 |
cfg.home_conversation_enabled = True
|
| 863 |
-
cfg.
|
| 864 |
services.bind(cfg)
|
| 865 |
|
| 866 |
response = aiohttp_response(status=200, json_data="not-a-dict")
|
|
@@ -872,6 +873,82 @@ class TestServicesTools:
|
|
| 872 |
})
|
| 873 |
assert "invalid home assistant conversation response" in result["content"][0]["text"].lower()
|
| 874 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
@pytest.mark.asyncio
|
| 876 |
async def test_timer_create_list_cancel_lifecycle(self):
|
| 877 |
from jarvis.tools import services
|
|
@@ -921,6 +998,43 @@ class TestServicesTools:
|
|
| 921 |
hidden = await services.timer_list({})
|
| 922 |
assert "no active timers" in hidden["content"][0]["text"].lower()
|
| 923 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
@pytest.mark.asyncio
|
| 925 |
async def test_todoist_add_task_requires_config(self):
|
| 926 |
from jarvis.tools import services
|
|
@@ -2067,6 +2181,7 @@ class TestServicesTools:
|
|
| 2067 |
assert "tool_policy" in payload
|
| 2068 |
assert isinstance(payload["tool_policy"]["home_require_confirm_execute"], bool)
|
| 2069 |
assert isinstance(payload["tool_policy"]["home_conversation_enabled"], bool)
|
|
|
|
| 2070 |
assert "memory" in payload
|
| 2071 |
assert "audit" in payload
|
| 2072 |
assert payload["audit"]["redaction_enabled"] is True
|
|
@@ -2386,3 +2501,10 @@ class TestServicesTools:
|
|
| 2386 |
}
|
| 2387 |
for code in critical_codes:
|
| 2388 |
assert code in script_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
cfg = services._config
|
| 798 |
assert cfg is not None
|
| 799 |
cfg.home_conversation_enabled = True
|
| 800 |
+
cfg.home_conversation_permission_profile = "control"
|
| 801 |
services.bind(cfg)
|
| 802 |
|
| 803 |
result = await services.home_assistant_conversation({
|
|
|
|
| 812 |
cfg = services._config
|
| 813 |
assert cfg is not None
|
| 814 |
cfg.home_conversation_enabled = True
|
| 815 |
+
cfg.home_conversation_permission_profile = "readonly"
|
| 816 |
services.bind(cfg)
|
| 817 |
|
| 818 |
result = await services.home_assistant_conversation({
|
| 819 |
"text": "turn on kitchen lights",
|
| 820 |
"confirm": True,
|
| 821 |
})
|
| 822 |
+
assert "home_conversation_permission_profile=control" in result["content"][0]["text"].lower()
|
| 823 |
|
| 824 |
@pytest.mark.asyncio
|
| 825 |
async def test_home_assistant_conversation_success(self, aiohttp_response, aiohttp_session_mock):
|
|
|
|
| 828 |
cfg = services._config
|
| 829 |
assert cfg is not None
|
| 830 |
cfg.home_conversation_enabled = True
|
| 831 |
+
cfg.home_conversation_permission_profile = "control"
|
| 832 |
services.bind(cfg)
|
| 833 |
|
| 834 |
response = aiohttp_response(
|
|
|
|
| 861 |
cfg = services._config
|
| 862 |
assert cfg is not None
|
| 863 |
cfg.home_conversation_enabled = True
|
| 864 |
+
cfg.home_conversation_permission_profile = "control"
|
| 865 |
services.bind(cfg)
|
| 866 |
|
| 867 |
response = aiohttp_response(status=200, json_data="not-a-dict")
|
|
|
|
| 873 |
})
|
| 874 |
assert "invalid home assistant conversation response" in result["content"][0]["text"].lower()
|
| 875 |
|
| 876 |
+
@pytest.mark.asyncio
|
| 877 |
+
@pytest.mark.parametrize(
|
| 878 |
+
("status", "expected_text", "expected_detail"),
|
| 879 |
+
[
|
| 880 |
+
(401, "authentication failed", "auth"),
|
| 881 |
+
(404, "endpoint not found", "not_found"),
|
| 882 |
+
(500, "conversation error (500)", "http_error"),
|
| 883 |
+
],
|
| 884 |
+
)
|
| 885 |
+
async def test_home_assistant_conversation_http_mappings(
|
| 886 |
+
self,
|
| 887 |
+
status,
|
| 888 |
+
expected_text,
|
| 889 |
+
expected_detail,
|
| 890 |
+
aiohttp_response,
|
| 891 |
+
aiohttp_session_mock,
|
| 892 |
+
):
|
| 893 |
+
from jarvis.tools import services
|
| 894 |
+
from jarvis.tool_summary import list_summaries
|
| 895 |
+
|
| 896 |
+
cfg = services._config
|
| 897 |
+
assert cfg is not None
|
| 898 |
+
cfg.home_conversation_enabled = True
|
| 899 |
+
cfg.home_conversation_permission_profile = "control"
|
| 900 |
+
services.bind(cfg)
|
| 901 |
+
|
| 902 |
+
response = aiohttp_response(status=status, json_data={})
|
| 903 |
+
with patch("aiohttp.ClientSession") as mock_session_cls:
|
| 904 |
+
mock_session_cls.return_value = aiohttp_session_mock(post=response)
|
| 905 |
+
result = await services.home_assistant_conversation({
|
| 906 |
+
"text": "turn off the kitchen lights",
|
| 907 |
+
"confirm": True,
|
| 908 |
+
})
|
| 909 |
+
assert expected_text in result["content"][0]["text"].lower()
|
| 910 |
+
summaries = list_summaries(25)
|
| 911 |
+
assert any(
|
| 912 |
+
item.get("name") == "home_assistant_conversation" and item.get("detail") == expected_detail
|
| 913 |
+
for item in summaries
|
| 914 |
+
)
|
| 915 |
+
|
| 916 |
+
@pytest.mark.asyncio
|
| 917 |
+
@pytest.mark.parametrize(
|
| 918 |
+
("side_effect", "expected_text", "expected_detail"),
|
| 919 |
+
[
|
| 920 |
+
(asyncio.TimeoutError(), "timed out", "timeout"),
|
| 921 |
+
(asyncio.CancelledError(), "was cancelled", "cancelled"),
|
| 922 |
+
(aiohttp.ClientError("boom"), "failed to reach", "network_client_error"),
|
| 923 |
+
],
|
| 924 |
+
)
|
| 925 |
+
async def test_home_assistant_conversation_transport_error_mappings(
|
| 926 |
+
self,
|
| 927 |
+
side_effect,
|
| 928 |
+
expected_text,
|
| 929 |
+
expected_detail,
|
| 930 |
+
):
|
| 931 |
+
from jarvis.tools import services
|
| 932 |
+
from jarvis.tool_summary import list_summaries
|
| 933 |
+
|
| 934 |
+
cfg = services._config
|
| 935 |
+
assert cfg is not None
|
| 936 |
+
cfg.home_conversation_enabled = True
|
| 937 |
+
cfg.home_conversation_permission_profile = "control"
|
| 938 |
+
services.bind(cfg)
|
| 939 |
+
|
| 940 |
+
with patch("aiohttp.ClientSession", side_effect=side_effect):
|
| 941 |
+
result = await services.home_assistant_conversation({
|
| 942 |
+
"text": "turn off the kitchen lights",
|
| 943 |
+
"confirm": True,
|
| 944 |
+
})
|
| 945 |
+
assert expected_text in result["content"][0]["text"].lower()
|
| 946 |
+
summaries = list_summaries(25)
|
| 947 |
+
assert any(
|
| 948 |
+
item.get("name") == "home_assistant_conversation" and item.get("detail") == expected_detail
|
| 949 |
+
for item in summaries
|
| 950 |
+
)
|
| 951 |
+
|
| 952 |
@pytest.mark.asyncio
|
| 953 |
async def test_timer_create_list_cancel_lifecycle(self):
|
| 954 |
from jarvis.tools import services
|
|
|
|
| 998 |
hidden = await services.timer_list({})
|
| 999 |
assert "no active timers" in hidden["content"][0]["text"].lower()
|
| 1000 |
|
| 1001 |
+
@pytest.mark.asyncio
|
| 1002 |
+
async def test_timer_persists_across_bind(self, tmp_path):
|
| 1003 |
+
from jarvis.config import Config
|
| 1004 |
+
from jarvis.memory import MemoryStore
|
| 1005 |
+
from jarvis.tools import services
|
| 1006 |
+
|
| 1007 |
+
cfg = Config()
|
| 1008 |
+
store = MemoryStore(str(tmp_path / "memory.sqlite"))
|
| 1009 |
+
services.bind(cfg, store)
|
| 1010 |
+
|
| 1011 |
+
created = await services.timer_create({"duration": 600, "label": "persisted"})
|
| 1012 |
+
timer_id = int(created["content"][0]["text"].split()[1])
|
| 1013 |
+
|
| 1014 |
+
services.bind(cfg, store)
|
| 1015 |
+
listed = await services.timer_list({})
|
| 1016 |
+
text = listed["content"][0]["text"].lower()
|
| 1017 |
+
assert "persisted" in text
|
| 1018 |
+
assert f"- {timer_id}" in text
|
| 1019 |
+
|
| 1020 |
+
@pytest.mark.asyncio
|
| 1021 |
+
async def test_timer_cancel_persists_across_bind(self, tmp_path):
|
| 1022 |
+
from jarvis.config import Config
|
| 1023 |
+
from jarvis.memory import MemoryStore
|
| 1024 |
+
from jarvis.tools import services
|
| 1025 |
+
|
| 1026 |
+
cfg = Config()
|
| 1027 |
+
store = MemoryStore(str(tmp_path / "memory.sqlite"))
|
| 1028 |
+
services.bind(cfg, store)
|
| 1029 |
+
|
| 1030 |
+
created = await services.timer_create({"duration": 600, "label": "to-cancel"})
|
| 1031 |
+
timer_id = int(created["content"][0]["text"].split()[1])
|
| 1032 |
+
await services.timer_cancel({"timer_id": timer_id})
|
| 1033 |
+
|
| 1034 |
+
services.bind(cfg, store)
|
| 1035 |
+
listed = await services.timer_list({})
|
| 1036 |
+
assert "no active timers" in listed["content"][0]["text"].lower()
|
| 1037 |
+
|
| 1038 |
@pytest.mark.asyncio
|
| 1039 |
async def test_todoist_add_task_requires_config(self):
|
| 1040 |
from jarvis.tools import services
|
|
|
|
| 2181 |
assert "tool_policy" in payload
|
| 2182 |
assert isinstance(payload["tool_policy"]["home_require_confirm_execute"], bool)
|
| 2183 |
assert isinstance(payload["tool_policy"]["home_conversation_enabled"], bool)
|
| 2184 |
+
assert payload["tool_policy"]["home_conversation_permission_profile"] in {"readonly", "control"}
|
| 2185 |
assert "memory" in payload
|
| 2186 |
assert "audit" in payload
|
| 2187 |
assert payload["audit"]["redaction_enabled"] is True
|
|
|
|
| 2501 |
}
|
| 2502 |
for code in critical_codes:
|
| 2503 |
assert code in script_text
|
| 2504 |
+
|
| 2505 |
+
def test_error_taxonomy_doc_mentions_home_assistant_conversation_and_timers(self):
|
| 2506 |
+
project_root = Path(__file__).resolve().parents[1]
|
| 2507 |
+
taxonomy_doc = (project_root / "docs" / "operations" / "error-taxonomy.md").read_text()
|
| 2508 |
+
assert "home_assistant_conversation" in taxonomy_doc
|
| 2509 |
+
assert "timer_cancel" in taxonomy_doc
|
| 2510 |
+
assert "timer_*" in taxonomy_doc
|