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/__init__.py +3 -0
- pascal/__main__.py +880 -0
- pascal/actions.py +1066 -0
- pascal/capability.py +218 -0
- pascal/channels/__init__.py +0 -0
- pascal/channels/telegram.py +108 -0
- pascal/clipboard.py +38 -0
- pascal/config.py +134 -0
- pascal/daemon.py +211 -0
- pascal/desk.py +633 -0
- pascal/effect.py +155 -0
- pascal/eval/__init__.py +1 -0
- pascal/eval/smoke.py +213 -0
- pascal/llm/__init__.py +1 -0
- pascal/llm/anthropic.py +225 -0
- pascal/llm/codex.py +331 -0
- pascal/llm/openai.py +224 -0
- pascal/loop.py +1037 -0
- pascal/mcp.py +206 -0
- pascal/prompt.py +141 -0
- pascal/receipts.py +147 -0
- pascal/sandbox.py +287 -0
- pascal/scheduler.py +243 -0
- pascal/schemas.py +183 -0
- pascal/state.py +790 -0
- pascal/tools.py +672 -0
- pascal/trust.py +150 -0
- pascal/types.py +337 -0
- pascal/uia.py +316 -0
- pascal_agent-0.3.0.dist-info/METADATA +262 -0
- pascal_agent-0.3.0.dist-info/RECORD +33 -0
- pascal_agent-0.3.0.dist-info/WHEEL +4 -0
- pascal_agent-0.3.0.dist-info/entry_points.txt +2 -0
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]}"
|