memtask 0.0.1__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.
- memtask/__init__.py +5 -0
- memtask/__main__.py +5 -0
- memtask/api.py +328 -0
- memtask/app.py +73 -0
- memtask/cli.py +303 -0
- memtask/manager.py +744 -0
- memtask/storage.py +150 -0
- memtask-0.0.1.dist-info/METADATA +104 -0
- memtask-0.0.1.dist-info/RECORD +12 -0
- memtask-0.0.1.dist-info/WHEEL +5 -0
- memtask-0.0.1.dist-info/entry_points.txt +2 -0
- memtask-0.0.1.dist-info/top_level.txt +1 -0
memtask/manager.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import uuid
|
|
5
|
+
from time import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .storage import (
|
|
9
|
+
decode_string_list,
|
|
10
|
+
decode_tags,
|
|
11
|
+
encode_string_list,
|
|
12
|
+
encode_tags,
|
|
13
|
+
get_connection,
|
|
14
|
+
init_db,
|
|
15
|
+
normalize_confidence,
|
|
16
|
+
normalize_non_empty_string,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _row_to_task(
|
|
21
|
+
row: sqlite3.Row,
|
|
22
|
+
pending_id: int | None,
|
|
23
|
+
parent_task_refs: list[str] | None,
|
|
24
|
+
child_task_refs: list[str] | None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"task_ref": row["task_ref"],
|
|
28
|
+
"description": row["description"],
|
|
29
|
+
"project": row["project"],
|
|
30
|
+
"tags": decode_tags(row["tags_json"]),
|
|
31
|
+
"memory_refs": decode_string_list(row["memory_refs_json"]),
|
|
32
|
+
"status": row["status"],
|
|
33
|
+
"is_current": bool(row["is_current"]),
|
|
34
|
+
"created_at": row["created_at"],
|
|
35
|
+
"updated_at": row["updated_at"],
|
|
36
|
+
"completed_at": row["completed_at"],
|
|
37
|
+
"pending_id": pending_id,
|
|
38
|
+
"parent_task_refs": parent_task_refs or [],
|
|
39
|
+
"child_task_refs": child_task_refs or [],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _row_to_memory(row: sqlite3.Row) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"memory_id": row["memory_id"],
|
|
46
|
+
"content": row["content"],
|
|
47
|
+
"memory_scope": row["memory_scope"],
|
|
48
|
+
"kind": row["kind"],
|
|
49
|
+
"confidence": row["confidence"],
|
|
50
|
+
"parent_memory_id": row["parent_memory_id"],
|
|
51
|
+
"tags": decode_tags(row["tags_json"]),
|
|
52
|
+
"created_at": row["created_at"],
|
|
53
|
+
"updated_at": row["updated_at"],
|
|
54
|
+
"last_accessed_at": row["last_accessed_at"],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fetch_pending_rows(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
|
59
|
+
cursor = conn.execute(
|
|
60
|
+
"""
|
|
61
|
+
SELECT task_ref, description, project, tags_json, memory_refs_json, status,
|
|
62
|
+
is_current, created_at, updated_at, completed_at
|
|
63
|
+
FROM tasks
|
|
64
|
+
WHERE status = 'pending'
|
|
65
|
+
ORDER BY created_at ASC, task_ref ASC
|
|
66
|
+
"""
|
|
67
|
+
)
|
|
68
|
+
return cursor.fetchall()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _task_row_by_ref(conn: sqlite3.Connection, task_ref: str) -> sqlite3.Row | None:
|
|
72
|
+
cursor = conn.execute(
|
|
73
|
+
"""
|
|
74
|
+
SELECT task_ref, description, project, tags_json, memory_refs_json, status,
|
|
75
|
+
is_current, created_at, updated_at, completed_at
|
|
76
|
+
FROM tasks
|
|
77
|
+
WHERE task_ref = ?
|
|
78
|
+
""",
|
|
79
|
+
(task_ref,),
|
|
80
|
+
)
|
|
81
|
+
return cursor.fetchone()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _task_row_by_pending_id(conn: sqlite3.Connection, pending_id: int) -> sqlite3.Row | None:
|
|
85
|
+
if pending_id < 1:
|
|
86
|
+
return None
|
|
87
|
+
rows = _fetch_pending_rows(conn)
|
|
88
|
+
if pending_id > len(rows):
|
|
89
|
+
return None
|
|
90
|
+
return rows[pending_id - 1]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _memory_row_by_id(conn: sqlite3.Connection, memory_id: str) -> sqlite3.Row | None:
|
|
94
|
+
cursor = conn.execute(
|
|
95
|
+
"""
|
|
96
|
+
SELECT memory_id, content, memory_scope, kind, confidence, parent_memory_id, tags_json,
|
|
97
|
+
created_at, updated_at, last_accessed_at
|
|
98
|
+
FROM memories
|
|
99
|
+
WHERE memory_id = ?
|
|
100
|
+
""",
|
|
101
|
+
(memory_id,),
|
|
102
|
+
)
|
|
103
|
+
return cursor.fetchone()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resolve_task_identifier(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row:
|
|
107
|
+
# Prefer stable task_ref/uuid, then fallback to pending_id for compatibility.
|
|
108
|
+
by_ref = _task_row_by_ref(conn, task_id)
|
|
109
|
+
if by_ref is not None:
|
|
110
|
+
return by_ref
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
numeric_id = int(task_id)
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
raise ValueError(f"Task '{task_id}' not found")
|
|
116
|
+
|
|
117
|
+
by_pending = _task_row_by_pending_id(conn, numeric_id)
|
|
118
|
+
if by_pending is not None:
|
|
119
|
+
return by_pending
|
|
120
|
+
|
|
121
|
+
raise ValueError(f"Task '{task_id}' not found")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_pending_task(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row:
|
|
125
|
+
row = _resolve_task_identifier(conn, task_id)
|
|
126
|
+
if row["status"] != "pending":
|
|
127
|
+
raise ValueError(f"Task '{task_id}' is not pending")
|
|
128
|
+
return row
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_memory_identifier(conn: sqlite3.Connection, memory_id: str) -> sqlite3.Row:
|
|
132
|
+
memory_id = normalize_non_empty_string(memory_id, "memory_id")
|
|
133
|
+
row = _memory_row_by_id(conn, memory_id)
|
|
134
|
+
if row is None:
|
|
135
|
+
raise ValueError(f"Memory '{memory_id}' not found")
|
|
136
|
+
return row
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_row_index(rows: list[sqlite3.Row], task_ref: str) -> int:
|
|
140
|
+
for index, row in enumerate(rows):
|
|
141
|
+
if row["task_ref"] == task_ref:
|
|
142
|
+
return index
|
|
143
|
+
return -1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_task_refs(conn: sqlite3.Connection, refs: list[str] | None) -> list[str]:
|
|
147
|
+
if not refs:
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
task_refs = []
|
|
151
|
+
seen = set()
|
|
152
|
+
for ref in refs:
|
|
153
|
+
if not str(ref).strip():
|
|
154
|
+
continue
|
|
155
|
+
row = _resolve_task_identifier(conn, str(ref).strip())
|
|
156
|
+
task_ref = row["task_ref"]
|
|
157
|
+
if task_ref in seen:
|
|
158
|
+
continue
|
|
159
|
+
seen.add(task_ref)
|
|
160
|
+
task_refs.append(task_ref)
|
|
161
|
+
return task_refs
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_memory_refs(conn: sqlite3.Connection, refs: list[str] | None) -> list[str]:
|
|
165
|
+
if not refs:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
memory_refs = []
|
|
169
|
+
seen = set()
|
|
170
|
+
for memory_ref in refs:
|
|
171
|
+
if not str(memory_ref).strip():
|
|
172
|
+
continue
|
|
173
|
+
row = _resolve_memory_identifier(conn, str(memory_ref).strip())
|
|
174
|
+
memory_ref_value = row["memory_id"]
|
|
175
|
+
if memory_ref_value in seen:
|
|
176
|
+
continue
|
|
177
|
+
seen.add(memory_ref_value)
|
|
178
|
+
memory_refs.append(memory_ref_value)
|
|
179
|
+
return memory_refs
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _dependency_map(
|
|
183
|
+
conn: sqlite3.Connection,
|
|
184
|
+
task_refs: list[str],
|
|
185
|
+
source_column: str,
|
|
186
|
+
target_column: str,
|
|
187
|
+
) -> dict[str, list[str]]:
|
|
188
|
+
if not task_refs:
|
|
189
|
+
return {}
|
|
190
|
+
|
|
191
|
+
qmarks = ",".join("?" * len(task_refs))
|
|
192
|
+
query = f"""
|
|
193
|
+
SELECT {source_column}, {target_column}
|
|
194
|
+
FROM task_dependencies
|
|
195
|
+
WHERE {source_column} IN ({qmarks})
|
|
196
|
+
ORDER BY {target_column} ASC
|
|
197
|
+
"""
|
|
198
|
+
rows = conn.execute(query, task_refs).fetchall()
|
|
199
|
+
|
|
200
|
+
mapping: dict[str, list[str]] = {task_ref: [] for task_ref in task_refs}
|
|
201
|
+
for row in rows:
|
|
202
|
+
mapping[row[source_column]].append(row[target_column])
|
|
203
|
+
return mapping
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _attach_dependencies(
|
|
207
|
+
conn: sqlite3.Connection,
|
|
208
|
+
rows: list[sqlite3.Row],
|
|
209
|
+
include_pending_ids: bool = False,
|
|
210
|
+
) -> list[dict[str, Any]]:
|
|
211
|
+
task_refs = [row["task_ref"] for row in rows]
|
|
212
|
+
if not task_refs:
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
parent_map = _dependency_map(
|
|
216
|
+
conn=conn,
|
|
217
|
+
task_refs=task_refs,
|
|
218
|
+
source_column="task_ref",
|
|
219
|
+
target_column="depends_on_task_ref",
|
|
220
|
+
)
|
|
221
|
+
child_map = _dependency_map(
|
|
222
|
+
conn=conn,
|
|
223
|
+
task_refs=task_refs,
|
|
224
|
+
source_column="depends_on_task_ref",
|
|
225
|
+
target_column="task_ref",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
payload = []
|
|
229
|
+
for index, row in enumerate(rows):
|
|
230
|
+
task_ref = row["task_ref"]
|
|
231
|
+
pending_id = index + 1 if include_pending_ids else None
|
|
232
|
+
payload.append(
|
|
233
|
+
_row_to_task(
|
|
234
|
+
row=row,
|
|
235
|
+
pending_id=pending_id,
|
|
236
|
+
parent_task_refs=parent_map.get(task_ref, []),
|
|
237
|
+
child_task_refs=child_map.get(task_ref, []),
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
return payload
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _attach_task_dependencies(
|
|
244
|
+
conn: sqlite3.Connection,
|
|
245
|
+
row: sqlite3.Row,
|
|
246
|
+
pending_id: int | None = None,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
pending_refs = [row["task_ref"]]
|
|
249
|
+
parent_map = _dependency_map(
|
|
250
|
+
conn=conn,
|
|
251
|
+
task_refs=pending_refs,
|
|
252
|
+
source_column="task_ref",
|
|
253
|
+
target_column="depends_on_task_ref",
|
|
254
|
+
)
|
|
255
|
+
child_map = _dependency_map(
|
|
256
|
+
conn=conn,
|
|
257
|
+
task_refs=pending_refs,
|
|
258
|
+
source_column="depends_on_task_ref",
|
|
259
|
+
target_column="task_ref",
|
|
260
|
+
)
|
|
261
|
+
task_ref = row["task_ref"]
|
|
262
|
+
return _row_to_task(
|
|
263
|
+
row=row,
|
|
264
|
+
pending_id=pending_id,
|
|
265
|
+
parent_task_refs=parent_map.get(task_ref, []),
|
|
266
|
+
child_task_refs=child_map.get(task_ref, []),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _check_dependency_blockers(conn: sqlite3.Connection, task_ref: str) -> list[str]:
|
|
271
|
+
rows = conn.execute(
|
|
272
|
+
"""
|
|
273
|
+
SELECT td.task_ref
|
|
274
|
+
FROM task_dependencies td
|
|
275
|
+
JOIN tasks t ON t.task_ref = td.task_ref
|
|
276
|
+
WHERE td.depends_on_task_ref = ? AND t.status != 'completed'
|
|
277
|
+
ORDER BY td.task_ref
|
|
278
|
+
""",
|
|
279
|
+
(task_ref,),
|
|
280
|
+
).fetchall()
|
|
281
|
+
return [row["task_ref"] for row in rows]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _add_dependency_links(
|
|
285
|
+
conn: sqlite3.Connection,
|
|
286
|
+
task_ref: str,
|
|
287
|
+
parent_task_refs: list[str],
|
|
288
|
+
) -> None:
|
|
289
|
+
if not parent_task_refs:
|
|
290
|
+
return
|
|
291
|
+
cursor = conn.cursor()
|
|
292
|
+
for parent_ref in parent_task_refs:
|
|
293
|
+
cursor.execute(
|
|
294
|
+
"""
|
|
295
|
+
INSERT OR IGNORE INTO task_dependencies (task_ref, depends_on_task_ref)
|
|
296
|
+
VALUES (?, ?)
|
|
297
|
+
""",
|
|
298
|
+
(task_ref, parent_ref),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _remove_dependency_links(conn: sqlite3.Connection, task_ref: str) -> None:
|
|
303
|
+
conn.execute(
|
|
304
|
+
"DELETE FROM task_dependencies WHERE task_ref = ? OR depends_on_task_ref = ?",
|
|
305
|
+
(task_ref, task_ref),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _remove_memory_refs_from_tasks(conn: sqlite3.Connection, memory_id: str) -> list[str]:
|
|
310
|
+
affected_refs = []
|
|
311
|
+
rows = conn.execute("SELECT task_ref, memory_refs_json FROM tasks").fetchall()
|
|
312
|
+
for row in rows:
|
|
313
|
+
memory_refs = decode_string_list(row["memory_refs_json"])
|
|
314
|
+
if memory_id not in memory_refs:
|
|
315
|
+
continue
|
|
316
|
+
updated_refs = [ref for ref in memory_refs if ref != memory_id]
|
|
317
|
+
conn.execute(
|
|
318
|
+
"""
|
|
319
|
+
UPDATE tasks
|
|
320
|
+
SET memory_refs_json = ?
|
|
321
|
+
WHERE task_ref = ?
|
|
322
|
+
""",
|
|
323
|
+
(encode_string_list(updated_refs), row["task_ref"]),
|
|
324
|
+
)
|
|
325
|
+
affected_refs.append(row["task_ref"])
|
|
326
|
+
return affected_refs
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _touch_memory_access(conn: sqlite3.Connection, memory_id: str) -> None:
|
|
330
|
+
now = time()
|
|
331
|
+
conn.execute(
|
|
332
|
+
"""
|
|
333
|
+
UPDATE memories
|
|
334
|
+
SET last_accessed_at = ?
|
|
335
|
+
WHERE memory_id = ?
|
|
336
|
+
""",
|
|
337
|
+
(now, memory_id),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def list_tasks() -> list[dict[str, Any]]:
|
|
342
|
+
"""Return all pending tasks with stable task_ref and ephemeral pending_id values."""
|
|
343
|
+
with get_connection() as conn:
|
|
344
|
+
rows = _fetch_pending_rows(conn)
|
|
345
|
+
return _attach_dependencies(conn, rows, include_pending_ids=True)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def current_tasks() -> list[dict[str, Any]]:
|
|
349
|
+
"""Return currently active pending tasks."""
|
|
350
|
+
with get_connection() as conn:
|
|
351
|
+
rows = conn.execute(
|
|
352
|
+
"""
|
|
353
|
+
SELECT task_ref, description, project, tags_json, memory_refs_json, status,
|
|
354
|
+
is_current, created_at, updated_at, completed_at
|
|
355
|
+
FROM tasks
|
|
356
|
+
WHERE status = 'pending' AND is_current = 1
|
|
357
|
+
ORDER BY created_at ASC, task_ref ASC
|
|
358
|
+
"""
|
|
359
|
+
).fetchall()
|
|
360
|
+
return _attach_dependencies(conn, rows, include_pending_ids=True)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def get_task(task_id: str) -> dict[str, Any]:
|
|
364
|
+
"""Return a task by task_ref/UUID or pending numeric id."""
|
|
365
|
+
with get_connection() as conn:
|
|
366
|
+
row = _resolve_task_identifier(conn, task_id)
|
|
367
|
+
pending_id = None
|
|
368
|
+
if row["status"] == "pending":
|
|
369
|
+
pending_rows = _fetch_pending_rows(conn)
|
|
370
|
+
pending_id = _find_row_index(pending_rows, row["task_ref"]) + 1
|
|
371
|
+
if pending_id == 0:
|
|
372
|
+
pending_id = None
|
|
373
|
+
return _attach_task_dependencies(conn, row, pending_id=pending_id)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def add_task(
|
|
377
|
+
description: str,
|
|
378
|
+
project: str | None = None,
|
|
379
|
+
tags: list[str] | None = None,
|
|
380
|
+
parent_task_refs: list[str] | None = None,
|
|
381
|
+
memory_refs: list[str] | None = None,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
"""Add one pending task and return the created task record."""
|
|
384
|
+
description = normalize_non_empty_string(description, "description")
|
|
385
|
+
task_ref = str(uuid.uuid4())
|
|
386
|
+
now = time()
|
|
387
|
+
with get_connection() as conn:
|
|
388
|
+
resolved_parents = _resolve_task_refs(conn, parent_task_refs)
|
|
389
|
+
resolved_memories = _resolve_memory_refs(conn, memory_refs)
|
|
390
|
+
conn.execute(
|
|
391
|
+
"""
|
|
392
|
+
INSERT INTO tasks (task_ref, description, project, tags_json, memory_refs_json,
|
|
393
|
+
status, is_current, created_at, updated_at, completed_at)
|
|
394
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, NULL)
|
|
395
|
+
""",
|
|
396
|
+
(
|
|
397
|
+
task_ref,
|
|
398
|
+
description,
|
|
399
|
+
project,
|
|
400
|
+
encode_tags(tags),
|
|
401
|
+
encode_string_list(resolved_memories),
|
|
402
|
+
now,
|
|
403
|
+
now,
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
_add_dependency_links(conn, task_ref, resolved_parents)
|
|
407
|
+
return get_task(task_ref)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def add_batch_tasks(
|
|
411
|
+
descriptions: list[str],
|
|
412
|
+
project: str | None = None,
|
|
413
|
+
tags: list[str] | None = None,
|
|
414
|
+
parent_task_refs: list[str] | None = None,
|
|
415
|
+
memory_refs: list[str] | None = None,
|
|
416
|
+
) -> list[dict[str, Any]]:
|
|
417
|
+
"""Add multiple pending tasks and return created records."""
|
|
418
|
+
if not descriptions:
|
|
419
|
+
return []
|
|
420
|
+
created_refs: list[str] = []
|
|
421
|
+
now = time()
|
|
422
|
+
with get_connection() as conn:
|
|
423
|
+
resolved_parents = _resolve_task_refs(conn, parent_task_refs)
|
|
424
|
+
resolved_memories = _resolve_memory_refs(conn, memory_refs)
|
|
425
|
+
cursor = conn.cursor()
|
|
426
|
+
for index, description in enumerate(descriptions):
|
|
427
|
+
if not str(description).strip():
|
|
428
|
+
raise ValueError(f"Description at index {index} is required")
|
|
429
|
+
task_ref = str(uuid.uuid4())
|
|
430
|
+
created_refs.append(task_ref)
|
|
431
|
+
cursor.execute(
|
|
432
|
+
"""
|
|
433
|
+
INSERT INTO tasks (task_ref, description, project, tags_json, memory_refs_json,
|
|
434
|
+
status, is_current, created_at, updated_at, completed_at)
|
|
435
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, NULL)
|
|
436
|
+
""",
|
|
437
|
+
(
|
|
438
|
+
task_ref,
|
|
439
|
+
str(description).strip(),
|
|
440
|
+
project,
|
|
441
|
+
encode_tags(tags),
|
|
442
|
+
encode_string_list(resolved_memories),
|
|
443
|
+
now,
|
|
444
|
+
now,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
_add_dependency_links(cursor.connection, task_ref, resolved_parents)
|
|
448
|
+
conn.commit()
|
|
449
|
+
|
|
450
|
+
tasks = []
|
|
451
|
+
for task_ref in created_refs:
|
|
452
|
+
tasks.append(get_task(task_ref))
|
|
453
|
+
return tasks
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def complete_task(task_id: str) -> dict[str, Any]:
|
|
457
|
+
"""Mark a pending task completed."""
|
|
458
|
+
now = time()
|
|
459
|
+
with get_connection() as conn:
|
|
460
|
+
row = _resolve_pending_task(conn, task_id)
|
|
461
|
+
|
|
462
|
+
blockers = _check_dependency_blockers(conn, row["task_ref"])
|
|
463
|
+
if blockers:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"Cannot complete task '{task_id}'. It has incomplete child tasks: {blockers}"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
conn.execute(
|
|
469
|
+
"""
|
|
470
|
+
UPDATE tasks
|
|
471
|
+
SET status = 'completed', is_current = 0, updated_at = ?, completed_at = ?
|
|
472
|
+
WHERE task_ref = ?
|
|
473
|
+
""",
|
|
474
|
+
(now, now, row["task_ref"]),
|
|
475
|
+
)
|
|
476
|
+
conn.commit()
|
|
477
|
+
completed = _task_row_by_ref(conn, row["task_ref"])
|
|
478
|
+
if completed is None:
|
|
479
|
+
raise RuntimeError("Failed to fetch completed task")
|
|
480
|
+
return _attach_task_dependencies(conn, completed, pending_id=None)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def remove_task(task_id: str) -> dict[str, Any]:
|
|
484
|
+
"""Delete a task by task_ref/UUID or pending numeric id."""
|
|
485
|
+
with get_connection() as conn:
|
|
486
|
+
row = _resolve_task_identifier(conn, task_id)
|
|
487
|
+
dep_map = _attach_task_dependencies(conn, row, pending_id=None)
|
|
488
|
+
_remove_dependency_links(conn, row["task_ref"])
|
|
489
|
+
conn.execute("DELETE FROM tasks WHERE task_ref = ?", (row["task_ref"],))
|
|
490
|
+
conn.commit()
|
|
491
|
+
return dep_map
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def remove_all_tasks() -> dict[str, Any]:
|
|
495
|
+
"""Delete all pending tasks currently returned by list_tasks."""
|
|
496
|
+
tasks = list_tasks()
|
|
497
|
+
removed = []
|
|
498
|
+
failed = []
|
|
499
|
+
for task in tasks:
|
|
500
|
+
task_id = task.get("task_ref") or task.get("pending_id")
|
|
501
|
+
if not task_id:
|
|
502
|
+
failed.append({"task": task, "error": "Could not resolve a task identifier."})
|
|
503
|
+
continue
|
|
504
|
+
try:
|
|
505
|
+
removed.append(remove_task(str(task_id)))
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
failed.append({"task_id": task_id, "error": str(exc)})
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
"attempted_count": len(tasks),
|
|
511
|
+
"removed_count": len(removed),
|
|
512
|
+
"removed": removed,
|
|
513
|
+
"failed": failed,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def set_current_task(task_id: str) -> dict[str, Any]:
|
|
518
|
+
"""Mark a pending task as current and unset any other current pending task."""
|
|
519
|
+
now = time()
|
|
520
|
+
with get_connection() as conn:
|
|
521
|
+
row = _resolve_pending_task(conn, task_id)
|
|
522
|
+
conn.execute("UPDATE tasks SET is_current = 0 WHERE status = 'pending'")
|
|
523
|
+
conn.execute(
|
|
524
|
+
"UPDATE tasks SET is_current = 1, updated_at = ? WHERE task_ref = ?",
|
|
525
|
+
(now, row["task_ref"]),
|
|
526
|
+
)
|
|
527
|
+
conn.commit()
|
|
528
|
+
updated = _task_row_by_ref(conn, row["task_ref"])
|
|
529
|
+
if updated is None:
|
|
530
|
+
raise RuntimeError("Failed to fetch updated task")
|
|
531
|
+
return get_task(updated["task_ref"])
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def remember(
|
|
535
|
+
content: str,
|
|
536
|
+
memory_scope: str | None = None,
|
|
537
|
+
kind: str | None = None,
|
|
538
|
+
confidence: int = 100,
|
|
539
|
+
parent_memory_id: str | None = None,
|
|
540
|
+
tags: list[str] | None = None,
|
|
541
|
+
) -> dict[str, Any]:
|
|
542
|
+
"""Add a memory artifact for recall and couple to higher-level memory structures."""
|
|
543
|
+
content = normalize_non_empty_string(content, "content")
|
|
544
|
+
scope = normalize_non_empty_string(memory_scope or "global", "memory_scope")
|
|
545
|
+
normalized_kind = normalize_non_empty_string(kind or "fact", "kind")
|
|
546
|
+
confidence = normalize_confidence(confidence)
|
|
547
|
+
now = time()
|
|
548
|
+
|
|
549
|
+
with get_connection() as conn:
|
|
550
|
+
if parent_memory_id is not None:
|
|
551
|
+
parent_memory_id = _resolve_memory_identifier(
|
|
552
|
+
conn=conn,
|
|
553
|
+
memory_id=parent_memory_id.strip(),
|
|
554
|
+
)["memory_id"]
|
|
555
|
+
|
|
556
|
+
memory_id = str(uuid.uuid4())
|
|
557
|
+
conn.execute(
|
|
558
|
+
"""
|
|
559
|
+
INSERT INTO memories (
|
|
560
|
+
memory_id, content, memory_scope, kind, confidence, parent_memory_id,
|
|
561
|
+
tags_json, created_at, updated_at, last_accessed_at
|
|
562
|
+
)
|
|
563
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
564
|
+
""",
|
|
565
|
+
(
|
|
566
|
+
memory_id,
|
|
567
|
+
content,
|
|
568
|
+
scope,
|
|
569
|
+
normalized_kind,
|
|
570
|
+
confidence,
|
|
571
|
+
parent_memory_id,
|
|
572
|
+
encode_tags(tags),
|
|
573
|
+
now,
|
|
574
|
+
now,
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
row = _memory_row_by_id(conn, memory_id)
|
|
578
|
+
if row is None:
|
|
579
|
+
raise RuntimeError("Failed to fetch created memory")
|
|
580
|
+
return _row_to_memory(row)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def recall(
|
|
584
|
+
query: str | None = None,
|
|
585
|
+
memory_scope: str | None = None,
|
|
586
|
+
kind: str | None = None,
|
|
587
|
+
min_confidence: int | None = None,
|
|
588
|
+
parent_memory_id: str | None = None,
|
|
589
|
+
limit: int | None = None,
|
|
590
|
+
) -> list[dict[str, Any]]:
|
|
591
|
+
"""Recall memories with optional filtering."""
|
|
592
|
+
clauses = []
|
|
593
|
+
params: list[Any] = []
|
|
594
|
+
|
|
595
|
+
if query is not None and str(query).strip():
|
|
596
|
+
clauses.append("LOWER(content) LIKE ?")
|
|
597
|
+
params.append(f"%{str(query).strip().lower()}%")
|
|
598
|
+
|
|
599
|
+
if memory_scope is not None:
|
|
600
|
+
clauses.append("memory_scope = ?")
|
|
601
|
+
params.append(normalize_non_empty_string(memory_scope, "memory_scope"))
|
|
602
|
+
|
|
603
|
+
if kind is not None:
|
|
604
|
+
clauses.append("kind = ?")
|
|
605
|
+
params.append(normalize_non_empty_string(kind, "kind"))
|
|
606
|
+
|
|
607
|
+
if min_confidence is not None:
|
|
608
|
+
clauses.append("confidence >= ?")
|
|
609
|
+
params.append(normalize_confidence(min_confidence))
|
|
610
|
+
|
|
611
|
+
if parent_memory_id is not None:
|
|
612
|
+
parent_memory_id = normalize_non_empty_string(parent_memory_id, "parent_memory_id")
|
|
613
|
+
with get_connection() as conn:
|
|
614
|
+
_resolve_memory_identifier(conn, parent_memory_id)
|
|
615
|
+
clauses.append("parent_memory_id = ?")
|
|
616
|
+
params.append(parent_memory_id)
|
|
617
|
+
|
|
618
|
+
if limit is not None:
|
|
619
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
|
|
620
|
+
raise ValueError("limit must be a positive integer")
|
|
621
|
+
|
|
622
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
623
|
+
sql = (
|
|
624
|
+
"SELECT memory_id, content, memory_scope, kind, confidence, parent_memory_id, "
|
|
625
|
+
"tags_json, created_at, updated_at, last_accessed_at FROM memories "
|
|
626
|
+
f"{where} ORDER BY confidence DESC, updated_at DESC"
|
|
627
|
+
)
|
|
628
|
+
if limit is not None:
|
|
629
|
+
sql += " LIMIT ?"
|
|
630
|
+
params.append(limit)
|
|
631
|
+
|
|
632
|
+
with get_connection() as conn:
|
|
633
|
+
rows = conn.execute(sql, params).fetchall()
|
|
634
|
+
payload = [_row_to_memory(row) for row in rows]
|
|
635
|
+
memory_ids = [row["memory_id"] for row in rows]
|
|
636
|
+
if memory_ids:
|
|
637
|
+
now = time()
|
|
638
|
+
placeholders = ",".join("?" * len(memory_ids))
|
|
639
|
+
conn.execute(
|
|
640
|
+
f"UPDATE memories SET last_accessed_at = ? WHERE memory_id IN ({placeholders})",
|
|
641
|
+
[now] + memory_ids,
|
|
642
|
+
)
|
|
643
|
+
conn.commit()
|
|
644
|
+
for memory in payload:
|
|
645
|
+
memory["last_accessed_at"] = now
|
|
646
|
+
return payload
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def get_memory(memory_id: str) -> dict[str, Any]:
|
|
650
|
+
"""Return a memory by stable id and refresh last_accessed."""
|
|
651
|
+
with get_connection() as conn:
|
|
652
|
+
row = _resolve_memory_identifier(conn, memory_id)
|
|
653
|
+
_touch_memory_access(conn, row["memory_id"])
|
|
654
|
+
conn.commit()
|
|
655
|
+
updated = _memory_row_by_id(conn, row["memory_id"])
|
|
656
|
+
if updated is None:
|
|
657
|
+
raise RuntimeError("Failed to fetch memory")
|
|
658
|
+
return _row_to_memory(updated)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def update_memory(
|
|
662
|
+
memory_id: str,
|
|
663
|
+
content: str | None = None,
|
|
664
|
+
memory_scope: str | None = None,
|
|
665
|
+
kind: str | None = None,
|
|
666
|
+
confidence: int | None = None,
|
|
667
|
+
parent_memory_id: str | None = None,
|
|
668
|
+
tags: list[str] | None = None,
|
|
669
|
+
clear_parent: bool = False,
|
|
670
|
+
) -> dict[str, Any]:
|
|
671
|
+
"""Update selected fields of a memory."""
|
|
672
|
+
if clear_parent and parent_memory_id is not None:
|
|
673
|
+
raise ValueError("Cannot set parent_memory_id and clear_parent at the same time")
|
|
674
|
+
|
|
675
|
+
updates: list[str] = []
|
|
676
|
+
params: list[Any] = []
|
|
677
|
+
|
|
678
|
+
if content is not None:
|
|
679
|
+
updates.append("content = ?")
|
|
680
|
+
params.append(normalize_non_empty_string(content, "content"))
|
|
681
|
+
|
|
682
|
+
if memory_scope is not None:
|
|
683
|
+
updates.append("memory_scope = ?")
|
|
684
|
+
params.append(normalize_non_empty_string(memory_scope, "memory_scope"))
|
|
685
|
+
|
|
686
|
+
if kind is not None:
|
|
687
|
+
updates.append("kind = ?")
|
|
688
|
+
params.append(normalize_non_empty_string(kind, "kind"))
|
|
689
|
+
|
|
690
|
+
if confidence is not None:
|
|
691
|
+
updates.append("confidence = ?")
|
|
692
|
+
params.append(normalize_confidence(confidence))
|
|
693
|
+
|
|
694
|
+
if clear_parent:
|
|
695
|
+
updates.append("parent_memory_id = NULL")
|
|
696
|
+
elif parent_memory_id is not None:
|
|
697
|
+
updates.append("parent_memory_id = ?")
|
|
698
|
+
params.append(normalize_non_empty_string(parent_memory_id, "parent_memory_id"))
|
|
699
|
+
|
|
700
|
+
if tags is not None:
|
|
701
|
+
updates.append("tags_json = ?")
|
|
702
|
+
params.append(encode_tags(tags))
|
|
703
|
+
|
|
704
|
+
if not updates:
|
|
705
|
+
raise ValueError("No fields supplied for update")
|
|
706
|
+
|
|
707
|
+
updates.append("updated_at = ?")
|
|
708
|
+
params.append(time())
|
|
709
|
+
|
|
710
|
+
with get_connection() as conn:
|
|
711
|
+
if clear_parent is False and parent_memory_id is not None:
|
|
712
|
+
parent_row = _resolve_memory_identifier(conn, parent_memory_id)
|
|
713
|
+
if parent_row["memory_id"] == memory_id:
|
|
714
|
+
raise ValueError("A memory cannot be its own parent")
|
|
715
|
+
|
|
716
|
+
updates_sql = ", ".join(updates)
|
|
717
|
+
row = _resolve_memory_identifier(conn, memory_id)
|
|
718
|
+
memory_id_value = row["memory_id"]
|
|
719
|
+
conn.execute(
|
|
720
|
+
f"UPDATE memories SET {updates_sql} WHERE memory_id = ?",
|
|
721
|
+
(*params, memory_id_value),
|
|
722
|
+
)
|
|
723
|
+
conn.commit()
|
|
724
|
+
updated = _memory_row_by_id(conn, memory_id_value)
|
|
725
|
+
if updated is None:
|
|
726
|
+
raise RuntimeError("Failed to fetch updated memory")
|
|
727
|
+
return _row_to_memory(updated)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def delete_memory(memory_id: str) -> dict[str, Any]:
|
|
731
|
+
"""Delete a memory by stable id."""
|
|
732
|
+
with get_connection() as conn:
|
|
733
|
+
row = _resolve_memory_identifier(conn, memory_id)
|
|
734
|
+
_remove_memory_refs_from_tasks(conn, row["memory_id"])
|
|
735
|
+
conn.execute(
|
|
736
|
+
"UPDATE memories SET parent_memory_id = NULL WHERE parent_memory_id = ?",
|
|
737
|
+
(row["memory_id"],),
|
|
738
|
+
)
|
|
739
|
+
conn.execute("DELETE FROM memories WHERE memory_id = ?", (row["memory_id"],))
|
|
740
|
+
conn.commit()
|
|
741
|
+
return _row_to_memory(row)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
init_db()
|