Jonathan Haas commited on
Commit
fb22b7f
·
1 Parent(s): 2ed7c3f

Harden HA conversation policy/taxonomy and persist timers

Browse files
.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
- - `HOME_PERMISSION_PROFILE=control`
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
- - [ ] `HA08` Add HA conversation error taxonomy mapping and tests.
44
- - [ ] `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,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
- - [ ] `PR09` Add persistence layer for timers/reminders across restarts.
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
- - [ ] `E03` Implement `HA08` + `HA09` (policy and taxonomy mapping for HA intent tool).
160
- - [ ] `E04` Implement `PR09` (persist timer/reminder data in `MemoryStore`).
161
  - [ ] `E05` Implement `UX06` (structured status endpoint behavior contract).
162
- - [ ] `E06` Update README/runbooks for new tools and safe operating guidance.
 
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 `HOME_PERMISSION_PROFILE=control`.
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 currently runtime-memory only and reset on process restart.
 
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 _home_permission_profile != "control":
1117
  _record_service_error("home_assistant_conversation", start_time, "policy")
1118
- _audit("home_assistant_conversation", {"result": "denied", "reason": "readonly_profile"})
1119
- return {"content": [{"type": "text", "text": "Home Assistant conversation requires HOME_PERMISSION_PROFILE=control."}]}
 
 
 
 
 
 
 
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.home_permission_profile = "readonly"
815
  services.bind(cfg)
816
 
817
  result = await services.home_assistant_conversation({
818
  "text": "turn on kitchen lights",
819
  "confirm": True,
820
  })
821
- assert "home_permission_profile=control" in result["content"][0]["text"].lower()
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.home_permission_profile = "control"
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.home_permission_profile = "control"
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