pascal-agent 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pascal/state.py ADDED
@@ -0,0 +1,790 @@
1
+ """Pascal state -- SQLite store for the working-document loop.
2
+
3
+ 8 tables + 1 migration:
4
+ tasks, notifications, rules, run_lock, history, context, todos, memories
5
+ + checkpoints (created via migration)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import sqlite3
13
+ import uuid
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ _SCHEMA = """
20
+ PRAGMA journal_mode = WAL;
21
+
22
+ CREATE TABLE IF NOT EXISTS tasks (
23
+ id TEXT PRIMARY KEY,
24
+ goal TEXT NOT NULL,
25
+ status TEXT NOT NULL DEFAULT 'pending'
26
+ CHECK (status IN ('pending', 'active', 'paused', 'blocked', 'done', 'failed')),
27
+ progress TEXT DEFAULT '',
28
+ result TEXT,
29
+ priority TEXT DEFAULT 'normal' CHECK (priority IN ('urgent', 'normal', 'low')),
30
+ source TEXT DEFAULT 'human',
31
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
32
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS notifications (
36
+ id TEXT PRIMARY KEY,
37
+ source TEXT NOT NULL,
38
+ message TEXT NOT NULL,
39
+ priority TEXT DEFAULT 'normal' CHECK (priority IN ('urgent', 'normal', 'low')),
40
+ metadata TEXT DEFAULT '{}',
41
+ status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'handled', 'dismissed')),
42
+ received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
43
+ handled_at TEXT
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_notif_status ON notifications(status, received_at);
47
+
48
+ CREATE TABLE IF NOT EXISTS rules (
49
+ id TEXT PRIMARY KEY,
50
+ rule TEXT NOT NULL,
51
+ tier TEXT NOT NULL DEFAULT 'learned'
52
+ CHECK (tier IN ('policy', 'operator', 'learned', 'ephemeral')),
53
+ mutable INTEGER NOT NULL DEFAULT 1 CHECK (mutable IN (0, 1)),
54
+ added_by TEXT NOT NULL DEFAULT 'human' CHECK (added_by IN ('human', 'agent')),
55
+ expires_at TEXT,
56
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
57
+ );
58
+
59
+ -- Concurrency: single-run lock
60
+ CREATE TABLE IF NOT EXISTS run_lock (
61
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
62
+ locked_by TEXT NOT NULL,
63
+ locked_at TEXT NOT NULL,
64
+ expires_at TEXT NOT NULL
65
+ );
66
+
67
+
68
+ CREATE TABLE IF NOT EXISTS history (
69
+ id TEXT PRIMARY KEY,
70
+ summary TEXT NOT NULL,
71
+ task_id TEXT REFERENCES tasks(id),
72
+ details TEXT DEFAULT '{}',
73
+ idem_key TEXT,
74
+ action_status TEXT DEFAULT 'ok' CHECK (action_status IN ('ok', 'error', 'unknown')),
75
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_history_time ON history(created_at);
79
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_history_idem ON history(idem_key) WHERE idem_key IS NOT NULL;
80
+
81
+ CREATE TRIGGER IF NOT EXISTS history_no_update
82
+ BEFORE UPDATE ON history BEGIN
83
+ SELECT RAISE(ABORT, 'history is append-only: updates not allowed');
84
+ END;
85
+
86
+ CREATE TRIGGER IF NOT EXISTS history_no_delete
87
+ BEFORE DELETE ON history BEGIN
88
+ SELECT RAISE(ABORT, 'history is append-only: deletes not allowed');
89
+ END;
90
+
91
+ CREATE TABLE IF NOT EXISTS context (
92
+ key TEXT PRIMARY KEY,
93
+ value TEXT NOT NULL,
94
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS todos (
98
+ id TEXT PRIMARY KEY,
99
+ task_id TEXT NOT NULL REFERENCES tasks(id),
100
+ title TEXT NOT NULL,
101
+ status TEXT NOT NULL DEFAULT 'pending'
102
+ CHECK (status IN ('pending', 'done', 'skipped')),
103
+ ordinal INTEGER NOT NULL DEFAULT 0,
104
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
105
+ );
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_todos_task ON todos(task_id, ordinal);
108
+
109
+ CREATE TABLE IF NOT EXISTS memories (
110
+ id TEXT PRIMARY KEY,
111
+ kind TEXT NOT NULL CHECK (kind IN ('fact', 'lesson', 'preference', 'procedure')),
112
+ content TEXT NOT NULL,
113
+ source_task_id TEXT REFERENCES tasks(id),
114
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
115
+ );
116
+
117
+ CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind, created_at);
118
+
119
+ CREATE TABLE IF NOT EXISTS conversations (
120
+ id TEXT PRIMARY KEY,
121
+ channel TEXT NOT NULL,
122
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
123
+ content TEXT NOT NULL,
124
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
125
+ );
126
+
127
+ CREATE INDEX IF NOT EXISTS idx_convo_channel ON conversations(channel, created_at DESC);
128
+
129
+ """
130
+
131
+
132
+ class PascalStore:
133
+ """Minimal persistence for the Pascal loop."""
134
+
135
+ def __init__(self, db_path: str) -> None:
136
+ path = Path(db_path).expanduser()
137
+ path.parent.mkdir(parents=True, exist_ok=True)
138
+ self._conn = sqlite3.connect(str(path))
139
+ self._conn.row_factory = sqlite3.Row
140
+ self._conn.execute("PRAGMA foreign_keys = ON")
141
+ self._conn.executescript(_SCHEMA)
142
+ self._conn.commit()
143
+ self._migrate_commitments()
144
+ self._migrate_context_ttl()
145
+ self._migrate_checkpoints()
146
+ self._migrate_memory_fts()
147
+ self._migrate_memory_decay()
148
+ self._migrate_task_hierarchy()
149
+ self._migrate_plan_json()
150
+
151
+ @property
152
+ def connection(self) -> sqlite3.Connection:
153
+ return self._conn
154
+
155
+ def close(self) -> None:
156
+ self._conn.close()
157
+
158
+ # ── Run lock (concurrency) ───────────────────────────────────
159
+
160
+ _LOCK_TTL_SECONDS = 600 # 10 minutes (was 5; must exceed _DELEGATE_MAX_TIMEOUT=600s)
161
+ _lock_owner: str = ""
162
+
163
+ def acquire_lock(self, run_id: str) -> bool:
164
+ """Try to acquire the single-run lock. Returns True if acquired."""
165
+ now = datetime.now(timezone.utc)
166
+ expires = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
167
+ new_expires = (now + timedelta(seconds=self._LOCK_TTL_SECONDS)).strftime(
168
+ "%Y-%m-%dT%H:%M:%S.%fZ"
169
+ )
170
+ # Clear expired locks
171
+ self._conn.execute(
172
+ "DELETE FROM run_lock WHERE expires_at < ?", (expires,),
173
+ )
174
+ try:
175
+ self._conn.execute(
176
+ "INSERT INTO run_lock (singleton, locked_by, locked_at, expires_at) VALUES (1, ?, ?, ?)",
177
+ (run_id, expires, new_expires),
178
+ )
179
+ self._conn.commit()
180
+ self._lock_owner = run_id
181
+ return True
182
+ except sqlite3.IntegrityError:
183
+ return False
184
+
185
+ def refresh_lock(self) -> None:
186
+ """Extend the lock TTL. Only the lock owner can refresh."""
187
+ if not self._lock_owner:
188
+ return
189
+ new_expires = (
190
+ datetime.now(timezone.utc) + timedelta(seconds=self._LOCK_TTL_SECONDS)
191
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
192
+ self._conn.execute(
193
+ "UPDATE run_lock SET expires_at = ? WHERE singleton = 1 AND locked_by = ?",
194
+ (new_expires, self._lock_owner),
195
+ )
196
+ self._conn.commit()
197
+
198
+ def release_lock(self, force: bool = False) -> None:
199
+ """Release the lock. Only the lock owner can release, unless force=True."""
200
+ if self._lock_owner:
201
+ self._conn.execute(
202
+ "DELETE FROM run_lock WHERE singleton = 1 AND locked_by = ?",
203
+ (self._lock_owner,),
204
+ )
205
+ elif force:
206
+ # Daemon startup: clear stale/expired locks from previous crash
207
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
208
+ self._conn.execute(
209
+ "DELETE FROM run_lock WHERE singleton = 1 AND expires_at < ?",
210
+ (now,),
211
+ )
212
+ self._conn.commit()
213
+ self._lock_owner = ""
214
+
215
+ # ── Tasks ────────────────────────────────────────────────────
216
+
217
+ # Valid state transitions -- any transition not here is rejected
218
+ _TASK_TRANSITIONS: dict[str, set[str]] = {
219
+ "pending": {"active", "blocked", "done", "failed"},
220
+ "active": {"paused", "blocked", "done", "failed"},
221
+ "paused": {"active", "done", "failed"},
222
+ "blocked": {"active", "done", "failed"},
223
+ "done": set(), # terminal
224
+ "failed": {"active", "pending"}, # can retry
225
+ }
226
+
227
+ def add_task(
228
+ self,
229
+ goal: str,
230
+ *,
231
+ priority: str = "normal",
232
+ source: str = "human",
233
+ promised_to: list[str] | None = None,
234
+ due_at: str | None = None,
235
+ proof_required: list[str] | None = None,
236
+ depends_on: list[str] | None = None,
237
+ parent_id: str | None = None,
238
+ ) -> str:
239
+ task_id = _make_id("task")
240
+ self._conn.execute(
241
+ "INSERT INTO tasks (id, goal, priority, source, promised_to, due_at, proof_required, depends_on, parent_id) "
242
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
243
+ (task_id, goal, priority, source,
244
+ json.dumps(promised_to) if promised_to else None,
245
+ due_at,
246
+ json.dumps(proof_required) if proof_required else None,
247
+ json.dumps(depends_on) if depends_on else None,
248
+ parent_id),
249
+ )
250
+ self._conn.commit()
251
+ return task_id
252
+
253
+ def get_active_task(self) -> dict[str, Any] | None:
254
+ row = self._conn.execute(
255
+ "SELECT * FROM tasks WHERE status = 'active' ORDER BY updated_at DESC LIMIT 1",
256
+ ).fetchone()
257
+ return dict(row) if row else None
258
+
259
+ def get_pending_tasks(self) -> list[dict[str, Any]]:
260
+ rows = self._conn.execute(
261
+ """SELECT * FROM tasks WHERE status = 'pending'
262
+ ORDER BY CASE priority WHEN 'urgent' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END,
263
+ created_at ASC""",
264
+ ).fetchall()
265
+ return [dict(r) for r in rows]
266
+
267
+ def activate_task(self, task_id: str) -> None:
268
+ task = self._conn.execute("SELECT status FROM tasks WHERE id = ?", (task_id,)).fetchone()
269
+ if task is None:
270
+ raise KeyError(f"Unknown task: {task_id}")
271
+ allowed = self._TASK_TRANSITIONS.get(task["status"], set())
272
+ if "active" not in allowed:
273
+ raise ValueError(f"Cannot activate task in '{task['status']}' state (allowed: {allowed})")
274
+ # Ensure single active task invariant
275
+ self._conn.execute(
276
+ "UPDATE tasks SET status = 'paused', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
277
+ "WHERE status = 'active' AND id != ?",
278
+ (task_id,),
279
+ )
280
+ self._conn.execute(
281
+ "UPDATE tasks SET status = 'active', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
282
+ (task_id,),
283
+ )
284
+ self._conn.commit()
285
+
286
+ def update_task(self, task_id: str, *, status: str | None = None, progress: str | None = None, result: str | None = None) -> None:
287
+ task = self._conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
288
+ if task is None:
289
+ raise KeyError(f"Unknown task: {task_id}")
290
+ # Enforce valid state transitions
291
+ if status is not None and status != task["status"]:
292
+ allowed = self._TASK_TRANSITIONS.get(task["status"], set())
293
+ if status not in allowed:
294
+ raise ValueError(
295
+ f"Invalid task transition: {task['status']} → {status} "
296
+ f"(allowed: {allowed or 'none -- terminal state'})"
297
+ )
298
+ self._conn.execute(
299
+ """UPDATE tasks SET status = ?, progress = ?, result = ?,
300
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?""",
301
+ (
302
+ status if status is not None else task["status"],
303
+ progress if progress is not None else task["progress"],
304
+ result if result is not None else task["result"],
305
+ task_id,
306
+ ),
307
+ )
308
+ self._conn.commit()
309
+
310
+ def pause_active_task(self, reason: str = "") -> str | None:
311
+ """Pause the current active task. Returns task_id if paused."""
312
+ active = self.get_active_task()
313
+ if active is None:
314
+ return None
315
+ self.update_task(active["id"], status="paused", progress=f"Paused: {reason}")
316
+ return active["id"]
317
+
318
+ # ── Plan Tree ─────────────────────────────────────────────────
319
+
320
+ def get_task_plan(self, task_id: str) -> dict[str, Any] | None:
321
+ """Get the plan tree for a task. Returns raw dict or None."""
322
+ row = self._conn.execute(
323
+ "SELECT plan_json FROM tasks WHERE id = ?", (task_id,),
324
+ ).fetchone()
325
+ if row is None or not row["plan_json"]:
326
+ return None
327
+ try:
328
+ return json.loads(row["plan_json"])
329
+ except (json.JSONDecodeError, TypeError):
330
+ return None
331
+
332
+ def set_task_plan(self, task_id: str, plan: dict[str, Any]) -> None:
333
+ """Store/update the plan tree for a task."""
334
+ self._conn.execute(
335
+ "UPDATE tasks SET plan_json = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
336
+ (json.dumps(plan, ensure_ascii=False, default=_json_safe), task_id),
337
+ )
338
+ self._conn.commit()
339
+
340
+ def clear_task_plan(self, task_id: str) -> None:
341
+ """Remove the plan tree from a task."""
342
+ self._conn.execute(
343
+ "UPDATE tasks SET plan_json = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
344
+ (task_id,),
345
+ )
346
+ self._conn.commit()
347
+
348
+ # ── Notifications ────────────────────────────────────────────
349
+
350
+ def push_notification(self, *, source: str, message: str, priority: str = "normal", metadata: dict[str, Any] | None = None) -> str:
351
+ notif_id = _make_id("notif")
352
+ self._conn.execute(
353
+ "INSERT INTO notifications (id, source, message, priority, metadata) VALUES (?, ?, ?, ?, ?)",
354
+ (notif_id, source, message, priority, json.dumps(metadata or {}, ensure_ascii=False, default=_json_safe)),
355
+ )
356
+ self._conn.commit()
357
+ return notif_id
358
+
359
+ def get_pending_notifications(self) -> list[dict[str, Any]]:
360
+ rows = self._conn.execute(
361
+ """SELECT * FROM notifications WHERE status = 'pending'
362
+ ORDER BY CASE priority WHEN 'urgent' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END,
363
+ received_at ASC""",
364
+ ).fetchall()
365
+ return [dict(r) for r in rows]
366
+
367
+ def handle_notification(self, notif_id: str) -> None:
368
+ self._conn.execute(
369
+ "UPDATE notifications SET status = 'handled', handled_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
370
+ (notif_id,),
371
+ )
372
+ self._conn.commit()
373
+
374
+ def dismiss_notification(self, notif_id: str) -> None:
375
+ self._conn.execute(
376
+ "UPDATE notifications SET status = 'dismissed', handled_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
377
+ (notif_id,),
378
+ )
379
+ self._conn.commit()
380
+
381
+ # ── Rules ────────────────────────────────────────────────────
382
+
383
+ _RULE_TIERS = ("policy", "operator", "learned", "ephemeral")
384
+
385
+ def add_rule(self, rule: str, *, tier: str = "learned", mutable: bool = True, added_by: str = "human", ttl_hours: int | None = None) -> str:
386
+ if tier not in self._RULE_TIERS:
387
+ tier = "learned"
388
+ # policy tier is always immutable, agent cannot add policy/operator
389
+ if tier == "policy":
390
+ mutable = False
391
+ added_by = "human"
392
+ if tier == "operator" and added_by == "agent":
393
+ tier = "learned" # agent can't escalate to operator tier
394
+ expires_at = None
395
+ if tier == "ephemeral" or ttl_hours is not None:
396
+ hours = ttl_hours if ttl_hours is not None else 24
397
+ expires_at = (datetime.now(timezone.utc) + timedelta(hours=hours)).strftime(
398
+ "%Y-%m-%dT%H:%M:%S.%fZ"
399
+ )
400
+ rule_id = _make_id("rule")
401
+ self._conn.execute(
402
+ "INSERT INTO rules (id, rule, tier, mutable, added_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
403
+ (rule_id, rule, tier, int(mutable), added_by, expires_at),
404
+ )
405
+ self._conn.commit()
406
+ return rule_id
407
+
408
+ def get_rules(self) -> list[dict[str, Any]]:
409
+ # Clean expired ephemeral rules
410
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
411
+ self._conn.execute(
412
+ "DELETE FROM rules WHERE expires_at IS NOT NULL AND expires_at < ?", (now,),
413
+ )
414
+ self._conn.commit()
415
+ # Return sorted by tier priority: policy > operator > learned > ephemeral
416
+ rows = self._conn.execute(
417
+ """SELECT * FROM rules ORDER BY
418
+ CASE tier WHEN 'policy' THEN 0 WHEN 'operator' THEN 1
419
+ WHEN 'learned' THEN 2 WHEN 'ephemeral' THEN 3 END,
420
+ created_at ASC""",
421
+ ).fetchall()
422
+ return [dict(r) for r in rows]
423
+
424
+ def remove_rule(self, rule_id: str) -> bool:
425
+ """Remove a mutable rule. Returns False if rule is immutable."""
426
+ rule = self._conn.execute("SELECT mutable FROM rules WHERE id = ?", (rule_id,)).fetchone()
427
+ if rule is None:
428
+ return False
429
+ if not rule["mutable"]:
430
+ return False
431
+ self._conn.execute("DELETE FROM rules WHERE id = ?", (rule_id,))
432
+ self._conn.commit()
433
+ return True
434
+
435
+ # ── History ──────────────────────────────────────────────────
436
+
437
+ def record(
438
+ self,
439
+ summary: str,
440
+ *,
441
+ task_id: str | None = None,
442
+ details: dict[str, Any] | None = None,
443
+ idem_key: str | None = None,
444
+ action_status: str = "ok",
445
+ ) -> None:
446
+ if action_status not in ("ok", "error", "unknown"):
447
+ action_status = "ok"
448
+ try:
449
+ self._conn.execute(
450
+ "INSERT OR IGNORE INTO history (id, summary, task_id, details, idem_key, action_status) "
451
+ "VALUES (?, ?, ?, ?, ?, ?)",
452
+ (_make_id("hist"), summary, task_id, json.dumps(details or {}, ensure_ascii=False, default=_json_safe),
453
+ idem_key, action_status),
454
+ )
455
+ self._conn.commit()
456
+ except sqlite3.IntegrityError:
457
+ pass # duplicate idem_key
458
+
459
+ def get_recent_history(self, limit: int = 10) -> list[dict[str, Any]]:
460
+ rows = self._conn.execute(
461
+ "SELECT * FROM history ORDER BY created_at DESC LIMIT ?", (limit,),
462
+ ).fetchall()
463
+ return [dict(r) for r in reversed(rows)]
464
+
465
+ def get_context(self, key: str) -> Any | None:
466
+ row = self._conn.execute("SELECT value FROM context WHERE key = ?", (key,)).fetchone()
467
+ if row is None:
468
+ return None
469
+ try:
470
+ return json.loads(row["value"])
471
+ except (json.JSONDecodeError, TypeError):
472
+ return row["value"]
473
+
474
+ # ── TODOs ─────────────────────────────────────────────────────
475
+
476
+ def add_todo(self, *, task_id: str, title: str) -> str:
477
+ todo_id = _make_id("todo")
478
+ max_ord = self._conn.execute(
479
+ "SELECT COALESCE(MAX(ordinal), 0) AS m FROM todos WHERE task_id = ?",
480
+ (task_id,),
481
+ ).fetchone()["m"]
482
+ self._conn.execute(
483
+ "INSERT INTO todos (id, task_id, title, ordinal) VALUES (?, ?, ?, ?)",
484
+ (todo_id, task_id, title, max_ord + 1),
485
+ )
486
+ self._conn.commit()
487
+ return todo_id
488
+
489
+ def complete_todo(self, todo_id: str) -> None:
490
+ self._conn.execute(
491
+ "UPDATE todos SET status = 'done' WHERE id = ?", (todo_id,),
492
+ )
493
+ self._conn.commit()
494
+
495
+ def get_todos(self, task_id: str) -> list[dict[str, Any]]:
496
+ rows = self._conn.execute(
497
+ "SELECT * FROM todos WHERE task_id = ? ORDER BY ordinal ASC",
498
+ (task_id,),
499
+ ).fetchall()
500
+ return [dict(r) for r in rows]
501
+
502
+ # ── Memories ─────────────────────────────────────────────────
503
+
504
+ def add_memory(self, *, kind: str, content: str, source_task_id: str | None = None) -> str:
505
+ # Dedup: skip if identical memory already exists
506
+ existing = self._conn.execute(
507
+ "SELECT id FROM memories WHERE kind = ? AND content = ? LIMIT 1",
508
+ (kind, content.strip()),
509
+ ).fetchone()
510
+ if existing:
511
+ return existing["id"]
512
+
513
+ mem_id = _make_id("mem")
514
+ self._conn.execute(
515
+ "INSERT INTO memories (id, kind, content, source_task_id) VALUES (?, ?, ?, ?)",
516
+ (mem_id, kind, content, source_task_id),
517
+ )
518
+ # Update FTS index
519
+ try:
520
+ rowid = self._conn.execute("SELECT rowid FROM memories WHERE id = ?", (mem_id,)).fetchone()
521
+ if rowid:
522
+ self._conn.execute(
523
+ "INSERT INTO memories_fts(rowid, content, kind) VALUES (?, ?, ?)",
524
+ (rowid[0], content, kind),
525
+ )
526
+ except Exception:
527
+ pass # FTS update failure is non-fatal
528
+ self._conn.commit()
529
+ return mem_id
530
+
531
+ def delete_memory(self, mem_id: str) -> bool:
532
+ """Delete a memory and its FTS5 index entry. Returns True if deleted."""
533
+ row = self._conn.execute(
534
+ "SELECT rowid, content, kind FROM memories WHERE id = ?", (mem_id,),
535
+ ).fetchone()
536
+ if not row:
537
+ return False
538
+ # Remove from FTS5 content-synced table (requires explicit delete command)
539
+ try:
540
+ self._conn.execute(
541
+ "INSERT INTO memories_fts(memories_fts, rowid, content, kind) VALUES('delete', ?, ?, ?)",
542
+ (row["rowid"], row["content"], row["kind"]),
543
+ )
544
+ except Exception:
545
+ pass # FTS cleanup failure is non-fatal
546
+ self._conn.execute("DELETE FROM memories WHERE id = ?", (mem_id,))
547
+ self._conn.commit()
548
+ return True
549
+
550
+ def get_memories(self, *, kind: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
551
+ if kind:
552
+ rows = self._conn.execute(
553
+ "SELECT * FROM memories WHERE kind = ? ORDER BY created_at DESC LIMIT ?",
554
+ (kind, limit),
555
+ ).fetchall()
556
+ else:
557
+ rows = self._conn.execute(
558
+ "SELECT * FROM memories ORDER BY created_at DESC LIMIT ?",
559
+ (limit,),
560
+ ).fetchall()
561
+ return [dict(r) for r in rows]
562
+
563
+ def search_memories(self, query: str, *, limit: int = 8) -> list[dict[str, Any]]:
564
+ """Search memories by relevance using FTS5. Falls back to recent if no match."""
565
+ if not query or not query.strip():
566
+ return self.get_memories(limit=limit)
567
+ try:
568
+ # Sanitize: keep only alphanumeric + spaces, strip FTS5 special chars
569
+ import re as _re
570
+ clean = _re.sub(r'[^\w\s]', ' ', query)
571
+ tokens = [t for t in clean.split() if t.strip() and len(t) > 1]
572
+ if not tokens:
573
+ return self.get_memories(limit=limit)
574
+ fts_query = " OR ".join(f'"{t}"*' for t in tokens[:10]) # quoted + prefix
575
+ rows = self._conn.execute(
576
+ """SELECT m.* FROM memories m
577
+ JOIN memories_fts f ON m.rowid = f.rowid
578
+ WHERE memories_fts MATCH ?
579
+ ORDER BY rank
580
+ LIMIT ?""",
581
+ (fts_query, limit),
582
+ ).fetchall()
583
+ if rows:
584
+ return [dict(r) for r in rows]
585
+ except Exception:
586
+ pass
587
+ # Fallback: recent memories
588
+ return self.get_memories(limit=limit)
589
+
590
+ # ── Conversations ─────────────────────────────────────────────
591
+
592
+ def push_conversation_turn(self, channel: str, role: str, content: str) -> str:
593
+ turn_id = _make_id("conv")
594
+ self._conn.execute(
595
+ "INSERT INTO conversations (id, channel, role, content) VALUES (?, ?, ?, ?)",
596
+ (turn_id, channel, role, content),
597
+ )
598
+ self._conn.commit()
599
+ return turn_id
600
+
601
+ def get_recent_conversation(self, channel: str, limit: int = 10) -> list[dict[str, Any]]:
602
+ rows = self._conn.execute(
603
+ "SELECT * FROM conversations WHERE channel = ? ORDER BY created_at DESC LIMIT ?",
604
+ (channel, limit),
605
+ ).fetchall()
606
+ return [dict(r) for r in reversed(rows)] # oldest first for natural reading
607
+
608
+ # ── Context (with TTL) ───────────────────────────────────────
609
+
610
+ def set_context(self, key: str, value: Any, ttl_hours: int | None = None) -> None:
611
+ expires_at = None
612
+ if ttl_hours is not None and ttl_hours > 0:
613
+ expires_at = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).strftime(
614
+ "%Y-%m-%dT%H:%M:%S.%fZ"
615
+ )
616
+ val = json.dumps(value, ensure_ascii=False, default=_json_safe) if not isinstance(value, str) else value
617
+ self._conn.execute(
618
+ """INSERT INTO context (key, value, expires_at, updated_at)
619
+ VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
620
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value, expires_at=excluded.expires_at,
621
+ updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
622
+ (key, val, expires_at),
623
+ )
624
+ self._conn.commit()
625
+ self._enforce_context_limit()
626
+
627
+ def get_all_context(self) -> dict[str, Any]:
628
+ self.cleanup_expired_context()
629
+ rows = self._conn.execute("SELECT key, value FROM context ORDER BY key").fetchall()
630
+ result = {}
631
+ for row in rows:
632
+ try:
633
+ result[row["key"]] = json.loads(row["value"])
634
+ except (json.JSONDecodeError, TypeError):
635
+ result[row["key"]] = row["value"]
636
+ return result
637
+
638
+ def cleanup_expired_context(self) -> int:
639
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
640
+ cursor = self._conn.execute(
641
+ "DELETE FROM context WHERE expires_at IS NOT NULL AND expires_at < ?", (now,),
642
+ )
643
+ self._conn.commit()
644
+ return cursor.rowcount
645
+
646
+ def _enforce_context_limit(self, max_entries: int = 100) -> None:
647
+ count = self._conn.execute("SELECT COUNT(*) FROM context").fetchone()[0]
648
+ if count <= max_entries:
649
+ return
650
+ to_delete = count - max_entries
651
+ self._conn.execute(
652
+ "DELETE FROM context WHERE key IN "
653
+ "(SELECT key FROM context ORDER BY updated_at ASC LIMIT ?)",
654
+ (to_delete,),
655
+ )
656
+ self._conn.commit()
657
+
658
+ # ── Checkpoints ──────────────────────────────────────────────
659
+
660
+ def save_checkpoint(self, task_id: str, iteration: int, loop_state: dict[str, Any]) -> str:
661
+ cp_id = _make_id("cp")
662
+ pending_notifs = [r["id"] for r in self._conn.execute(
663
+ "SELECT id FROM notifications WHERE status = 'pending'"
664
+ ).fetchall()]
665
+ recent_hist = [r["id"] for r in self._conn.execute(
666
+ "SELECT id FROM history ORDER BY created_at DESC LIMIT 5"
667
+ ).fetchall()]
668
+ ctx_keys = [r["key"] for r in self._conn.execute("SELECT key FROM context").fetchall()]
669
+ task = self.get_active_task() or {}
670
+ snapshot = {
671
+ "task_id": task_id,
672
+ "task_status": task.get("status"),
673
+ "task_progress": task.get("progress"),
674
+ "iteration": iteration,
675
+ "has_unknown_step": loop_state.get("has_unknown_step", False),
676
+ "thought_buffer": loop_state.get("thought_buffer", []),
677
+ "error_feedback": loop_state.get("error_feedback", ""),
678
+ "active_context_keys": ctx_keys,
679
+ "pending_notification_ids": pending_notifs,
680
+ "recent_history_ids": recent_hist,
681
+ }
682
+ self._conn.execute(
683
+ "INSERT INTO checkpoints (id, task_id, iteration, snapshot_json) VALUES (?, ?, ?, ?)",
684
+ (cp_id, task_id, iteration, json.dumps(snapshot, ensure_ascii=False, default=_json_safe)),
685
+ )
686
+ self._conn.commit()
687
+ self._prune_checkpoints(task_id)
688
+ return cp_id
689
+
690
+ def get_latest_checkpoint(self, task_id: str) -> dict[str, Any] | None:
691
+ row = self._conn.execute(
692
+ "SELECT * FROM checkpoints WHERE task_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1",
693
+ (task_id,),
694
+ ).fetchone()
695
+ if not row:
696
+ return None
697
+ return {
698
+ "id": row["id"], "task_id": row["task_id"],
699
+ "iteration": row["iteration"],
700
+ "snapshot": json.loads(row["snapshot_json"]),
701
+ "created_at": row["created_at"],
702
+ }
703
+
704
+ def _prune_checkpoints(self, task_id: str, keep: int = 10) -> None:
705
+ self._conn.execute(
706
+ "DELETE FROM checkpoints WHERE task_id = ? AND id NOT IN "
707
+ "(SELECT id FROM checkpoints WHERE task_id = ? ORDER BY created_at DESC LIMIT ?)",
708
+ (task_id, task_id, keep),
709
+ )
710
+ self._conn.commit()
711
+
712
+ # ── Migrations ───────────────────────────────────────────────
713
+
714
+ # Whitelist of valid identifiers for dynamic SQL in migrations
715
+ _VALID_TABLES = frozenset({"tasks", "context", "memories", "checkpoints", "notifications", "rules", "history", "conversations", "todos"})
716
+ _VALID_COL_TYPES = frozenset({"TEXT", "INTEGER", "REAL", "BLOB", "INTEGER DEFAULT 0"})
717
+ _IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
718
+
719
+ def _ensure_column(self, table: str, column: str, col_type: str = "TEXT") -> None:
720
+ # Validate identifiers to prevent SQL injection (defense-in-depth)
721
+ if table not in self._VALID_TABLES:
722
+ raise ValueError(f"Invalid table name for migration: {table}")
723
+ if not self._IDENT_RE.match(column):
724
+ raise ValueError(f"Invalid column name for migration: {column}")
725
+ if col_type not in self._VALID_COL_TYPES:
726
+ raise ValueError(f"Invalid column type for migration: {col_type}")
727
+ try:
728
+ self._conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
729
+ self._conn.commit()
730
+ except sqlite3.OperationalError:
731
+ pass
732
+
733
+ def _migrate_commitments(self) -> None:
734
+ for col in ("promised_to", "due_at", "proof_required", "depends_on"):
735
+ self._ensure_column("tasks", col)
736
+
737
+ def _migrate_context_ttl(self) -> None:
738
+ self._ensure_column("context", "expires_at")
739
+
740
+ def _migrate_checkpoints(self) -> None:
741
+ self._conn.executescript("""
742
+ CREATE TABLE IF NOT EXISTS checkpoints (
743
+ id TEXT PRIMARY KEY,
744
+ task_id TEXT NOT NULL REFERENCES tasks(id),
745
+ iteration INTEGER NOT NULL,
746
+ snapshot_json TEXT NOT NULL,
747
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
748
+ );
749
+ CREATE INDEX IF NOT EXISTS idx_checkpoints_task
750
+ ON checkpoints(task_id, created_at DESC);
751
+ """)
752
+ self._conn.commit()
753
+
754
+ def _migrate_memory_fts(self) -> None:
755
+ """Create FTS5 virtual table for memory search."""
756
+ self._conn.executescript("""
757
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
758
+ USING fts5(content, kind, content=memories, content_rowid=rowid);
759
+ """)
760
+ # Populate FTS from existing data (skip rows already indexed)
761
+ self._conn.execute("""
762
+ INSERT INTO memories_fts(rowid, content, kind)
763
+ SELECT m.rowid, m.content, m.kind FROM memories m
764
+ WHERE m.rowid NOT IN (SELECT rowid FROM memories_fts)
765
+ """)
766
+ self._conn.commit()
767
+
768
+ def _migrate_memory_decay(self) -> None:
769
+ """Add access tracking for memory decay."""
770
+ self._ensure_column("memories", "access_count", "INTEGER DEFAULT 0")
771
+ self._ensure_column("memories", "last_accessed", "TEXT")
772
+
773
+ def _migrate_task_hierarchy(self) -> None:
774
+ """Add parent_id for hierarchical task decomposition."""
775
+ self._ensure_column("tasks", "parent_id", "TEXT")
776
+
777
+ def _migrate_plan_json(self) -> None:
778
+ """Add plan_json for plan tree storage."""
779
+ self._ensure_column("tasks", "plan_json", "TEXT")
780
+
781
+
782
+ def _json_safe(obj):
783
+ """Handle non-serializable objects in json.dumps (ContentBlock, etc.)."""
784
+ if hasattr(obj, '__dataclass_fields__'):
785
+ return {k: getattr(obj, k) for k in obj.__dataclass_fields__ if k != 'data'}
786
+ return f"<{type(obj).__name__}>"
787
+
788
+
789
+ def _make_id(prefix: str) -> str:
790
+ return f"{prefix}_{uuid.uuid4().hex[:10]}"