nexo-brain 0.2.0 → 0.2.1

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.
package/src/db 2.py DELETED
@@ -1,2283 +0,0 @@
1
- """SQLite database for NEXO session coordination."""
2
-
3
- import sqlite3
4
- import time
5
- import os
6
- import secrets
7
- import string
8
- import datetime
9
- import pathlib
10
- from pathlib import Path
11
-
12
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
13
-
14
- DB_PATH = os.environ.get(
15
- "NEXO_TEST_DB",
16
- os.environ.get(
17
- "NEXO_DB",
18
- str(NEXO_HOME / "nexo.db"),
19
- ),
20
- )
21
-
22
- # TTLs in seconds
23
- SESSION_STALE_SECONDS = 3600 # 1 hour
24
- MESSAGE_TTL_SECONDS = 3600 # 1 hour
25
- QUESTION_TTL_SECONDS = 600 # 10 min
26
-
27
- # Shared connection per process — avoids file descriptor leak with multiple MCP sessions
28
- _shared_conn: sqlite3.Connection | None = None
29
-
30
-
31
- def get_db() -> sqlite3.Connection:
32
- """Get shared database connection with WAL mode. One connection per process.
33
-
34
- Uses WAL2-style pragmas for safe multi-process concurrent access:
35
- - WAL mode allows readers to not block writers
36
- - busy_timeout prevents instant SQLITE_BUSY failures
37
- - wal_autocheckpoint keeps WAL file from growing unbounded
38
- - Autocommit via isolation_level=None prevents implicit transactions
39
- that hold locks across multiple operations
40
- """
41
- global _shared_conn
42
- if _shared_conn is None:
43
- _shared_conn = sqlite3.connect(
44
- DB_PATH, timeout=30, check_same_thread=False,
45
- isolation_level=None, # autocommit — no implicit BEGIN holding locks
46
- )
47
- _shared_conn.execute("PRAGMA journal_mode=WAL")
48
- _shared_conn.execute("PRAGMA busy_timeout=30000")
49
- _shared_conn.execute("PRAGMA foreign_keys=ON")
50
- _shared_conn.execute("PRAGMA wal_autocheckpoint=100")
51
- _shared_conn.row_factory = sqlite3.Row
52
- return _shared_conn
53
-
54
-
55
- def close_db():
56
- """Close the shared database connection. Called on shutdown signals."""
57
- global _shared_conn
58
- if _shared_conn is not None:
59
- try:
60
- _shared_conn.close()
61
- except Exception:
62
- pass
63
- _shared_conn = None
64
-
65
-
66
- def init_db():
67
- """Create tables if they don't exist."""
68
- conn = get_db()
69
- conn.executescript("""
70
- CREATE TABLE IF NOT EXISTS sessions (
71
- sid TEXT PRIMARY KEY,
72
- task TEXT NOT NULL DEFAULT '',
73
- started_epoch REAL NOT NULL,
74
- last_update_epoch REAL NOT NULL,
75
- local_time TEXT NOT NULL DEFAULT ''
76
- );
77
-
78
- CREATE TABLE IF NOT EXISTS tracked_files (
79
- sid TEXT NOT NULL,
80
- path TEXT NOT NULL,
81
- tracked_at REAL NOT NULL,
82
- PRIMARY KEY (sid, path),
83
- FOREIGN KEY (sid) REFERENCES sessions(sid) ON DELETE CASCADE
84
- );
85
-
86
- CREATE TABLE IF NOT EXISTS messages (
87
- id TEXT PRIMARY KEY,
88
- from_sid TEXT NOT NULL,
89
- to_sid TEXT NOT NULL,
90
- text TEXT NOT NULL,
91
- created_epoch REAL NOT NULL
92
- );
93
-
94
- CREATE TABLE IF NOT EXISTS message_reads (
95
- message_id TEXT NOT NULL,
96
- sid TEXT NOT NULL,
97
- PRIMARY KEY (message_id, sid),
98
- FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
99
- );
100
-
101
- CREATE TABLE IF NOT EXISTS questions (
102
- qid TEXT PRIMARY KEY,
103
- from_sid TEXT NOT NULL,
104
- to_sid TEXT NOT NULL,
105
- question TEXT NOT NULL,
106
- answer TEXT,
107
- status TEXT NOT NULL DEFAULT 'pending',
108
- created_epoch REAL NOT NULL,
109
- answered_epoch REAL
110
- );
111
-
112
-
113
- CREATE TABLE IF NOT EXISTS reminders (
114
- id TEXT PRIMARY KEY,
115
- date TEXT,
116
- description TEXT NOT NULL,
117
- status TEXT NOT NULL DEFAULT 'PENDIENTE',
118
- category TEXT DEFAULT 'general',
119
- created_at REAL NOT NULL,
120
- updated_at REAL NOT NULL
121
- );
122
-
123
- CREATE TABLE IF NOT EXISTS followups (
124
- id TEXT PRIMARY KEY,
125
- date TEXT,
126
- description TEXT NOT NULL,
127
- verification TEXT DEFAULT '',
128
- status TEXT NOT NULL DEFAULT 'PENDIENTE',
129
- recurrence TEXT DEFAULT NULL,
130
- created_at REAL NOT NULL,
131
- updated_at REAL NOT NULL
132
- );
133
-
134
- CREATE TABLE IF NOT EXISTS learnings (
135
- id INTEGER PRIMARY KEY AUTOINCREMENT,
136
- category TEXT NOT NULL,
137
- title TEXT NOT NULL,
138
- content TEXT NOT NULL,
139
- created_at REAL NOT NULL,
140
- updated_at REAL NOT NULL
141
- );
142
-
143
- CREATE TABLE IF NOT EXISTS credentials (
144
- id INTEGER PRIMARY KEY AUTOINCREMENT,
145
- service TEXT NOT NULL,
146
- key TEXT NOT NULL,
147
- value TEXT NOT NULL,
148
- notes TEXT DEFAULT '',
149
- created_at REAL NOT NULL,
150
- updated_at REAL NOT NULL,
151
- UNIQUE(service, key)
152
- );
153
-
154
- CREATE TABLE IF NOT EXISTS task_history (
155
- id INTEGER PRIMARY KEY AUTOINCREMENT,
156
- task_num TEXT NOT NULL,
157
- task_name TEXT NOT NULL,
158
- executed_at REAL NOT NULL,
159
- notes TEXT DEFAULT ''
160
- );
161
-
162
- CREATE TABLE IF NOT EXISTS task_frequencies (
163
- task_num TEXT PRIMARY KEY,
164
- task_name TEXT NOT NULL,
165
- frequency_days INTEGER NOT NULL,
166
- description TEXT DEFAULT ''
167
- );
168
-
169
- CREATE TABLE IF NOT EXISTS plugins (
170
- filename TEXT PRIMARY KEY,
171
- tools_count INTEGER DEFAULT 0,
172
- tool_names TEXT DEFAULT '',
173
- loaded_at REAL,
174
- created_by TEXT DEFAULT 'manual'
175
- );
176
-
177
- CREATE TABLE IF NOT EXISTS entities (
178
- id INTEGER PRIMARY KEY AUTOINCREMENT,
179
- name TEXT NOT NULL,
180
- type TEXT NOT NULL DEFAULT 'general',
181
- value TEXT NOT NULL,
182
- notes TEXT DEFAULT '',
183
- created_at REAL NOT NULL,
184
- updated_at REAL NOT NULL
185
- );
186
-
187
- CREATE TABLE IF NOT EXISTS preferences (
188
- key TEXT PRIMARY KEY,
189
- value TEXT NOT NULL,
190
- category TEXT DEFAULT 'general',
191
- updated_at REAL NOT NULL
192
- );
193
-
194
- CREATE TABLE IF NOT EXISTS agents (
195
- id TEXT PRIMARY KEY,
196
- name TEXT NOT NULL,
197
- specialization TEXT NOT NULL,
198
- model TEXT DEFAULT 'sonnet',
199
- tools TEXT DEFAULT '',
200
- context_files TEXT DEFAULT '',
201
- rules TEXT DEFAULT '',
202
- created_at REAL NOT NULL,
203
- updated_at REAL NOT NULL
204
- );
205
-
206
- CREATE TABLE IF NOT EXISTS change_log (
207
- id INTEGER PRIMARY KEY AUTOINCREMENT,
208
- session_id TEXT NOT NULL,
209
- created_at TEXT DEFAULT (datetime('now')),
210
- files TEXT NOT NULL,
211
- what_changed TEXT NOT NULL,
212
- why TEXT NOT NULL,
213
- triggered_by TEXT DEFAULT '',
214
- affects TEXT DEFAULT '',
215
- risks TEXT DEFAULT '',
216
- verify TEXT DEFAULT '',
217
- commit_ref TEXT DEFAULT ''
218
- );
219
-
220
- CREATE TABLE IF NOT EXISTS decisions (
221
- id INTEGER PRIMARY KEY AUTOINCREMENT,
222
- session_id TEXT NOT NULL,
223
- created_at TEXT DEFAULT (datetime('now')),
224
- domain TEXT NOT NULL,
225
- decision TEXT NOT NULL,
226
- alternatives TEXT,
227
- based_on TEXT,
228
- confidence TEXT DEFAULT 'medium',
229
- context_ref TEXT,
230
- outcome TEXT,
231
- outcome_at TEXT
232
- );
233
-
234
- CREATE TABLE IF NOT EXISTS session_diary (
235
- id INTEGER PRIMARY KEY AUTOINCREMENT,
236
- session_id TEXT NOT NULL,
237
- created_at TEXT DEFAULT (datetime('now')),
238
- decisions TEXT NOT NULL,
239
- discarded TEXT,
240
- pending TEXT,
241
- context_next TEXT,
242
- mental_state TEXT,
243
- domain TEXT,
244
- user_signals TEXT,
245
- summary TEXT NOT NULL
246
- );
247
- CREATE TABLE IF NOT EXISTS evolution_metrics (
248
- id INTEGER PRIMARY KEY AUTOINCREMENT,
249
- dimension TEXT NOT NULL,
250
- score INTEGER NOT NULL CHECK(score >= 0 AND score <= 100),
251
- measured_at TEXT DEFAULT (datetime('now')),
252
- evidence TEXT NOT NULL,
253
- delta INTEGER DEFAULT 0
254
- );
255
-
256
- CREATE TABLE IF NOT EXISTS evolution_log (
257
- id INTEGER PRIMARY KEY AUTOINCREMENT,
258
- created_at TEXT DEFAULT (datetime('now')),
259
- cycle_number INTEGER NOT NULL,
260
- dimension TEXT NOT NULL,
261
- proposal TEXT NOT NULL,
262
- classification TEXT NOT NULL DEFAULT 'auto',
263
- status TEXT DEFAULT 'pending',
264
- files_changed TEXT,
265
- snapshot_ref TEXT,
266
- test_result TEXT,
267
- impact INTEGER DEFAULT 0,
268
- reasoning TEXT NOT NULL
269
- );
270
- """)
271
- # foreign_keys=ON is set in get_db() per-connection
272
-
273
- # ── Schema migrations (idempotent) ────────────────────────────
274
- _migrate_add_column(conn, "learnings", "reasoning", "TEXT")
275
- _migrate_add_column(conn, "learnings", "prevention", "TEXT DEFAULT ''")
276
- _migrate_add_column(conn, "learnings", "applies_to", "TEXT DEFAULT ''")
277
- _migrate_add_column(conn, "learnings", "status", "TEXT DEFAULT 'active'")
278
- _migrate_add_column(conn, "learnings", "review_due_at", "REAL")
279
- _migrate_add_column(conn, "learnings", "last_reviewed_at", "REAL")
280
- _migrate_add_column(conn, "followups", "reasoning", "TEXT")
281
- _migrate_add_column(conn, "task_history", "reasoning", "TEXT")
282
- _migrate_add_column(conn, "decisions", "status", "TEXT DEFAULT 'pending_review'")
283
- _migrate_add_column(conn, "decisions", "review_due_at", "TEXT")
284
- _migrate_add_column(conn, "decisions", "last_reviewed_at", "TEXT")
285
- _migrate_add_index(conn, "idx_decisions_domain", "decisions", "domain")
286
- _migrate_add_index(conn, "idx_decisions_created", "decisions", "created_at")
287
- _migrate_add_index(conn, "idx_decisions_review_due", "decisions", "review_due_at")
288
- _migrate_add_index(conn, "idx_session_diary_sid", "session_diary", "session_id")
289
- _migrate_add_column(conn, "session_diary", "mental_state", "TEXT")
290
- _migrate_add_column(conn, "session_diary", "domain", "TEXT")
291
- _migrate_add_column(conn, "session_diary", "user_signals", "TEXT")
292
- _migrate_add_index(conn, "idx_change_log_created", "change_log", "created_at")
293
- _migrate_add_index(conn, "idx_change_log_files", "change_log", "files")
294
- _migrate_add_index(conn, "idx_learnings_status", "learnings", "status")
295
- _migrate_add_index(conn, "idx_learnings_review_due", "learnings", "review_due_at")
296
-
297
- conn.execute("""
298
- CREATE TABLE IF NOT EXISTS error_repetitions (
299
- id INTEGER PRIMARY KEY AUTOINCREMENT,
300
- new_learning_id INTEGER NOT NULL,
301
- original_learning_id INTEGER NOT NULL,
302
- similarity REAL NOT NULL,
303
- area TEXT NOT NULL,
304
- created_at TEXT DEFAULT (datetime('now'))
305
- )
306
- """)
307
- conn.execute("""
308
- CREATE TABLE IF NOT EXISTS guard_checks (
309
- id INTEGER PRIMARY KEY AUTOINCREMENT,
310
- session_id TEXT,
311
- files TEXT,
312
- area TEXT,
313
- learnings_returned INTEGER DEFAULT 0,
314
- blocking_rules_returned INTEGER DEFAULT 0,
315
- created_at TEXT DEFAULT (datetime('now'))
316
- )
317
- """)
318
- _migrate_add_index(conn, "idx_error_repetitions_area", "error_repetitions", "area")
319
- _migrate_add_index(conn, "idx_guard_checks_session", "guard_checks", "session_id")
320
-
321
- # ── FTS5 unified search index ────────────────────────────────
322
- conn.execute("""
323
- CREATE VIRTUAL TABLE IF NOT EXISTS unified_search USING fts5(
324
- source,
325
- source_id,
326
- title,
327
- body,
328
- category,
329
- updated_at UNINDEXED,
330
- tokenize='unicode61 remove_diacritics 2'
331
- )
332
- """)
333
-
334
- # Dynamic directory registry for FTS indexing
335
- conn.execute("""
336
- CREATE TABLE IF NOT EXISTS fts_dirs (
337
- id INTEGER PRIMARY KEY AUTOINCREMENT,
338
- path TEXT NOT NULL UNIQUE,
339
- dir_type TEXT NOT NULL DEFAULT 'code',
340
- patterns TEXT NOT NULL DEFAULT '*.php,*.js,*.json,*.py,*.ts,*.tsx',
341
- added_at REAL NOT NULL,
342
- notes TEXT DEFAULT ''
343
- )
344
- """)
345
- conn.commit()
346
-
347
- if os.environ.get("NEXO_SKIP_FS_INDEX", "0") != "1":
348
- # FTS refresh in background thread — never block server startup
349
- import threading
350
-
351
- def _bg_fts():
352
- try:
353
- bg_conn = sqlite3.connect(DB_PATH, timeout=30)
354
- bg_conn.execute("PRAGMA journal_mode=WAL")
355
- bg_conn.execute("PRAGMA busy_timeout=30000")
356
- bg_conn.row_factory = sqlite3.Row
357
- row = bg_conn.execute("SELECT COUNT(*) FROM unified_search").fetchone()
358
- if row[0] == 0:
359
- rebuild_fts_index(bg_conn)
360
- else:
361
- _refresh_fts_files(bg_conn)
362
- bg_conn.close()
363
- except Exception:
364
- pass
365
-
366
- threading.Thread(target=_bg_fts, daemon=True).start()
367
-
368
-
369
- # ── FTS5 Unified Search ──────────────────────────────────────────
370
-
371
- # Directories to index for unified search (uses NEXO_HOME)
372
- _FTS_MD_DIRS = [
373
- str(NEXO_HOME / "docs"),
374
- str(NEXO_HOME / "projects"),
375
- str(NEXO_HOME / "memory"),
376
- str(NEXO_HOME / "operations"),
377
- str(NEXO_HOME / "learnings"),
378
- str(NEXO_HOME / "brain"),
379
- str(NEXO_HOME / "agents"),
380
- str(NEXO_HOME / "skills"),
381
- ]
382
- # Code repos: populated via nexo_index_add_dir tool or NEXO_HOME/repos
383
- _FTS_CODE_DIRS = []
384
- _FTS_CODE_SKIP = {
385
- "vendor", "node_modules", ".git", "cache", "tmp", "logs", "uploads",
386
- "assets/img", "assets/fonts", ".next", "dist", "build", ".prisma",
387
- "public/build", ".turbo", "__pycache__",
388
- "coverage", ".nyc_output", "storage/framework", "bootstrap/cache",
389
- }
390
- _FTS_MAX_FILE_SIZE = 50_000 # skip .md files >50KB
391
- _FTS_MAX_CODE_FILE_SIZE = 30_000 # skip code files >30KB
392
-
393
- # Synonym map for cross-language search (ES <-> EN)
394
- _SYNONYMS = {
395
- "carrito": ["cart", "checkout"],
396
- "cart": ["carrito", "checkout"],
397
- "abandoned": ["abandonado"],
398
- "abandonado": ["abandoned"],
399
- "busqueda": ["search", "buscar"],
400
- "search": ["busqueda", "buscar"],
401
- "envio": ["shipping", "envío"],
402
- "shipping": ["envio", "envío"],
403
- "pedido": ["order", "orden"],
404
- "order": ["pedido", "orden"],
405
- "cliente": ["customer", "client"],
406
- "customer": ["cliente", "client"],
407
- "producto": ["product"],
408
- "product": ["producto"],
409
- "precio": ["price"],
410
- "price": ["precio"],
411
- "descuento": ["discount"],
412
- "discount": ["descuento"],
413
- "pago": ["payment"],
414
- "payment": ["pago"],
415
- "factura": ["invoice"],
416
- "invoice": ["factura"],
417
- "tienda": ["store", "shop"],
418
- "store": ["tienda", "shop"],
419
- "configuracion": ["config", "settings", "configuration"],
420
- "config": ["configuracion", "settings"],
421
- "permisos": ["permissions"],
422
- "permissions": ["permisos"],
423
- "mensaje": ["message"],
424
- "message": ["mensaje"],
425
- "plantilla": ["template"],
426
- "template": ["plantilla"],
427
- "webhook": ["gancho"],
428
- "cron": ["tarea programada", "scheduled"],
429
- "extension": ["extensión", "plugin", "addon"],
430
- "plugin": ["extension", "extensión"],
431
- }
432
-
433
-
434
- def _get_all_code_dirs(conn=None):
435
- """Return combined list of hardcoded + dynamic code dirs as [(path, [patterns])]."""
436
- if conn is None:
437
- conn = get_db()
438
- dirs = list(_FTS_CODE_DIRS)
439
- try:
440
- for r in conn.execute("SELECT path, patterns FROM fts_dirs WHERE dir_type = 'code'").fetchall():
441
- patterns = [p.strip() for p in r["patterns"].split(",") if p.strip()]
442
- dirs.append((r["path"], patterns))
443
- except Exception:
444
- pass
445
- return dirs
446
-
447
-
448
- def _get_all_md_dirs(conn=None):
449
- """Return combined list of hardcoded + dynamic md dirs."""
450
- if conn is None:
451
- conn = get_db()
452
- dirs = list(_FTS_MD_DIRS)
453
- try:
454
- for r in conn.execute("SELECT path FROM fts_dirs WHERE dir_type = 'md'").fetchall():
455
- dirs.append(r["path"])
456
- except Exception:
457
- pass
458
- return dirs
459
-
460
-
461
- def fts_add_dir(path: str, dir_type: str = 'code',
462
- patterns: str = '*.php,*.js,*.json,*.py,*.ts,*.tsx',
463
- notes: str = '') -> dict:
464
- """Register a directory for FTS indexing."""
465
- conn = get_db()
466
- path = os.path.expanduser(path)
467
- if not os.path.isdir(path):
468
- return {"error": f"Directory not found: {path}"}
469
- try:
470
- conn.execute(
471
- "INSERT OR REPLACE INTO fts_dirs (path, dir_type, patterns, added_at, notes) VALUES (?,?,?,?,?)",
472
- (path, dir_type, patterns, now_epoch(), notes)
473
- )
474
- conn.commit()
475
- return {"path": path, "dir_type": dir_type, "patterns": patterns}
476
- except Exception as e:
477
- return {"error": str(e)}
478
-
479
-
480
- def fts_remove_dir(path: str) -> dict:
481
- """Remove a directory from FTS indexing and clean up its entries."""
482
- conn = get_db()
483
- path = os.path.expanduser(path)
484
- deleted = conn.execute("DELETE FROM fts_dirs WHERE path = ?", (path,)).rowcount
485
- if deleted == 0:
486
- return {"error": f"Directory not registered: {path}"}
487
- # Remove indexed files from that directory
488
- conn.execute("DELETE FROM unified_search WHERE source IN ('file', 'code') AND source_id LIKE ?",
489
- (path + "%",))
490
- conn.commit()
491
- return {"removed": path}
492
-
493
-
494
- def fts_list_dirs() -> list[dict]:
495
- """List all registered FTS directories (hardcoded + dynamic)."""
496
- conn = get_db()
497
- result = []
498
- for d in _FTS_MD_DIRS:
499
- result.append({"path": d, "type": "md", "patterns": "*.md", "source": "builtin"})
500
- for d, pats in _FTS_CODE_DIRS:
501
- result.append({"path": d, "type": "code", "patterns": ",".join(pats), "source": "builtin"})
502
- try:
503
- for r in conn.execute("SELECT path, dir_type, patterns, notes FROM fts_dirs ORDER BY path").fetchall():
504
- result.append({"path": r["path"], "type": r["dir_type"], "patterns": r["patterns"],
505
- "source": "dynamic", "notes": r["notes"] or ""})
506
- except Exception:
507
- pass
508
- return result
509
-
510
-
511
- def _fs_indexing_enabled() -> bool:
512
- """Allow tests and smoke checks to disable expensive filesystem indexing."""
513
- return os.environ.get("NEXO_SKIP_FS_INDEX", "0") != "1"
514
-
515
-
516
- def rebuild_fts_index(conn=None):
517
- """Rebuild FTS5 index from all sources: SQLite tables + .md files."""
518
- if conn is None:
519
- conn = get_db()
520
- conn.execute("DELETE FROM unified_search")
521
-
522
- def _ins(source, source_id, title, body, category, updated_at):
523
- conn.execute(
524
- "INSERT INTO unified_search(source, source_id, title, body, category, updated_at) VALUES (?,?,?,?,?,?)",
525
- (source, str(source_id), str(title)[:200], body or '', category or '', str(updated_at or ''))
526
- )
527
-
528
- # 1. Learnings
529
- for r in conn.execute("SELECT id, category, title, content, reasoning, updated_at FROM learnings").fetchall():
530
- _ins("learning", r["id"], r["title"], f"{r['content']} {r['reasoning'] or ''}", r["category"], r["updated_at"])
531
-
532
- # 2. Decisions
533
- for r in conn.execute("SELECT id, domain, decision, alternatives, based_on, outcome, created_at FROM decisions").fetchall():
534
- body = f"{r['decision']} {r['alternatives'] or ''} {r['based_on'] or ''} {r['outcome'] or ''}"
535
- _ins("decision", r["id"], r["decision"][:200], body, r["domain"] or '', r["created_at"])
536
-
537
- # 3. Change log
538
- for r in conn.execute("SELECT id, files, what_changed, why, triggered_by, affects, risks, created_at FROM change_log").fetchall():
539
- body = f"{r['what_changed']} {r['why']} {r['triggered_by'] or ''} {r['affects'] or ''} {r['risks'] or ''}"
540
- _ins("change", r["id"], r["files"], body, "change_log", r["created_at"])
541
-
542
- # 4. Session diary
543
- for r in conn.execute("SELECT id, summary, decisions, discarded, pending, context_next, mental_state, domain, created_at FROM session_diary").fetchall():
544
- body = f"{r['summary']} {r['decisions'] or ''} {r['pending'] or ''} {r['context_next'] or ''} {r['mental_state'] or ''}"
545
- _ins("diary", r["id"], (r["summary"] or '')[:200], body, r["domain"] or "general", r["created_at"])
546
-
547
- # 5. Followups
548
- for r in conn.execute("SELECT id, description, verification, reasoning, updated_at FROM followups").fetchall():
549
- body = f"{r['description']} {r['verification'] or ''} {r['reasoning'] or ''}"
550
- _ins("followup", r["id"], r["id"], body, "followup", r["updated_at"])
551
-
552
- # 6. Entities
553
- for r in conn.execute("SELECT id, name, type, value, notes, updated_at FROM entities").fetchall():
554
- _ins("entity", r["id"], r["name"], f"{r['name']} {r['value']} {r['notes'] or ''}", r["type"] or "general", r["updated_at"])
555
-
556
- if _fs_indexing_enabled():
557
- # 7. .md files from key directories (hardcoded + dynamic)
558
- for dir_path in _get_all_md_dirs(conn):
559
- p = pathlib.Path(dir_path)
560
- if not p.exists():
561
- continue
562
- for md_file in p.rglob("*.md"):
563
- try:
564
- if md_file.stat().st_size > _FTS_MAX_FILE_SIZE:
565
- continue
566
- content = md_file.read_text(encoding="utf-8", errors="ignore")
567
- category = md_file.parent.name or "docs"
568
- _ins("file", str(md_file), md_file.stem, content, category, md_file.stat().st_mtime)
569
- except Exception:
570
- continue
571
-
572
- # 8. Code files from project repos (hardcoded + dynamic)
573
- for dir_path, patterns in _get_all_code_dirs(conn):
574
- p = pathlib.Path(dir_path)
575
- if not p.exists():
576
- continue
577
- for pattern in patterns:
578
- for code_file in p.rglob(pattern):
579
- # Skip excluded directories
580
- if any(skip in code_file.parts for skip in _FTS_CODE_SKIP):
581
- continue
582
- try:
583
- if code_file.stat().st_size > _FTS_MAX_CODE_FILE_SIZE:
584
- continue
585
- content = code_file.read_text(encoding="utf-8", errors="ignore")
586
- # Use relative path from repo root as category
587
- rel_parts = code_file.relative_to(p).parts
588
- category = rel_parts[0] if rel_parts else "code"
589
- _ins("code", str(code_file), code_file.name, content, category, code_file.stat().st_mtime)
590
- except Exception:
591
- continue
592
-
593
- conn.commit()
594
-
595
-
596
- def _refresh_fts_files(conn=None):
597
- """Refresh file + code entries in FTS index — add new, update modified, remove deleted."""
598
- if conn is None:
599
- conn = get_db()
600
-
601
- if not _fs_indexing_enabled():
602
- conn.execute("DELETE FROM unified_search WHERE source IN ('file', 'code')")
603
- conn.commit()
604
- return
605
-
606
- # Get currently indexed files with their mtime (both 'file' and 'code' sources)
607
- indexed = {}
608
- for r in conn.execute("SELECT source, source_id, updated_at FROM unified_search WHERE source IN ('file', 'code')").fetchall():
609
- indexed[r[1]] = (r[0], r[2])
610
-
611
- current_files = set()
612
-
613
- # Scan .md files (hardcoded + dynamic)
614
- for dir_path in _get_all_md_dirs(conn):
615
- p = pathlib.Path(dir_path)
616
- if not p.exists():
617
- continue
618
- for md_file in p.rglob("*.md"):
619
- try:
620
- if md_file.stat().st_size > _FTS_MAX_FILE_SIZE:
621
- continue
622
- fpath = str(md_file)
623
- current_files.add(fpath)
624
- mtime = md_file.stat().st_mtime
625
- old = indexed.get(fpath)
626
- if old is None or str(mtime) != str(old[1]):
627
- content = md_file.read_text(encoding="utf-8", errors="ignore")
628
- category = md_file.parent.name or "docs"
629
- conn.execute("DELETE FROM unified_search WHERE source_id = ?", (fpath,))
630
- conn.execute(
631
- "INSERT INTO unified_search(source, source_id, title, body, category, updated_at) VALUES (?,?,?,?,?,?)",
632
- ("file", fpath, md_file.stem, content, category, str(mtime))
633
- )
634
- except Exception:
635
- continue
636
-
637
- # Scan code files (hardcoded + dynamic)
638
- for dir_path, patterns in _get_all_code_dirs(conn):
639
- p = pathlib.Path(dir_path)
640
- if not p.exists():
641
- continue
642
- for pattern in patterns:
643
- for code_file in p.rglob(pattern):
644
- if any(skip in code_file.parts for skip in _FTS_CODE_SKIP):
645
- continue
646
- try:
647
- if code_file.stat().st_size > _FTS_MAX_CODE_FILE_SIZE:
648
- continue
649
- fpath = str(code_file)
650
- current_files.add(fpath)
651
- mtime = code_file.stat().st_mtime
652
- old = indexed.get(fpath)
653
- if old is None or str(mtime) != str(old[1]):
654
- content = code_file.read_text(encoding="utf-8", errors="ignore")
655
- rel_parts = code_file.relative_to(p).parts
656
- category = rel_parts[0] if rel_parts else "code"
657
- conn.execute("DELETE FROM unified_search WHERE source_id = ?", (fpath,))
658
- conn.execute(
659
- "INSERT INTO unified_search(source, source_id, title, body, category, updated_at) VALUES (?,?,?,?,?,?)",
660
- ("code", fpath, code_file.name, content, category, str(mtime))
661
- )
662
- except Exception:
663
- continue
664
-
665
- # Remove deleted files
666
- for fpath, (source, _) in indexed.items():
667
- if fpath not in current_files:
668
- conn.execute("DELETE FROM unified_search WHERE source_id = ?", (fpath,))
669
-
670
- conn.commit()
671
-
672
-
673
- def _expand_synonyms(words: list[str]) -> list[str]:
674
- """Expand search words with synonyms for cross-language matching."""
675
- expanded = set(words)
676
- for w in words:
677
- w_lower = w.lower()
678
- if w_lower in _SYNONYMS:
679
- expanded.update(_SYNONYMS[w_lower])
680
- return list(expanded)
681
-
682
-
683
- def fts_search(query: str, source_filter: str = None, limit: int = 20) -> list[dict]:
684
- """Search unified FTS5 index. Returns ranked results.
685
-
686
- Args:
687
- query: Search text (supports FTS5 syntax: "exact phrase", word*)
688
- source_filter: Optional filter by source (learning, decision, change, diary, followup, entity, file, code)
689
- limit: Max results (default 20)
690
- """
691
- conn = get_db()
692
- words = query.strip().split()
693
- if not words:
694
- return []
695
-
696
- # Expand with synonyms for cross-language matching
697
- all_words = _expand_synonyms(words)
698
-
699
- # Build FTS5 query: each word as quoted term with OR for broad matching
700
- fts_terms = []
701
- for w in all_words:
702
- # Strip FTS5 special chars to avoid syntax errors
703
- safe = w.replace('"', '').replace("'", '').replace('*', '').replace('^', '').replace('-', ' ').strip()
704
- if not safe:
705
- continue
706
- # Split on dots (e.g., "capabilities.json" → "capabilities" + "json")
707
- parts = [p.strip() for p in safe.split('.') if p.strip()]
708
- for part in parts:
709
- fts_terms.append(f'"{part}"')
710
- # Add prefix search for camelCase/code identifiers (contains uppercase mid-word)
711
- if any(c.isupper() for c in part[1:]) or '_' in part:
712
- fts_terms.append(f'{part}*')
713
- if not fts_terms:
714
- return []
715
- fts_query = " OR ".join(fts_terms)
716
-
717
- where_extra = ""
718
- params = [fts_query]
719
- if source_filter:
720
- where_extra = "AND source = ?"
721
- params.append(source_filter)
722
- params.append(limit)
723
-
724
- try:
725
- rows = conn.execute(f"""
726
- SELECT source, source_id, title,
727
- snippet(unified_search, 3, '»', '«', '...', 40) AS snippet,
728
- category, updated_at, rank
729
- FROM unified_search
730
- WHERE unified_search MATCH ? {where_extra}
731
- ORDER BY rank
732
- LIMIT ?
733
- """, params).fetchall()
734
- return [dict(r) for r in rows]
735
- except Exception:
736
- return []
737
-
738
-
739
- def fts_upsert(source: str, source_id: str, title: str, body: str, category: str = '', commit: bool = True):
740
- """Add or update a single entry in the FTS index."""
741
- conn = get_db()
742
- conn.execute("DELETE FROM unified_search WHERE source = ? AND source_id = ?", (source, str(source_id)))
743
- conn.execute(
744
- "INSERT INTO unified_search(source, source_id, title, body, category, updated_at) VALUES (?,?,?,?,?,?)",
745
- (source, str(source_id), str(title)[:200], body or '', category or '', datetime.datetime.now().isoformat())
746
- )
747
- if commit:
748
- conn.commit()
749
-
750
-
751
- def _migrate_add_column(conn, table: str, column: str, col_type: str):
752
- """Add column if it doesn't exist (idempotent)."""
753
- try:
754
- conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
755
- conn.commit()
756
- except sqlite3.OperationalError as e:
757
- if "duplicate column" in str(e).lower():
758
- pass
759
- else:
760
- raise
761
-
762
-
763
- def _migrate_add_index(conn, index_name: str, table: str, column: str):
764
- """Create index if it doesn't exist (idempotent)."""
765
- conn.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table}({column})")
766
- conn.commit()
767
-
768
-
769
- def _gen_id(prefix: str, length: int = 8) -> str:
770
- """Generate a random ID like 'msg-a1b2c3' or 'q-x9y8z7w6'."""
771
- chars = string.ascii_lowercase + string.digits
772
- suffix = ''.join(secrets.choice(chars) for _ in range(length))
773
- return f"{prefix}-{suffix}"
774
-
775
-
776
- # ── Session operations ──────────────────────────────────────────────
777
-
778
- def now_epoch() -> float:
779
- return time.time()
780
-
781
-
782
- def local_time_str() -> str:
783
- from datetime import datetime
784
- return datetime.now().strftime("%H:%M")
785
-
786
-
787
- def register_session(sid: str, task: str) -> dict:
788
- """Register or re-register a session."""
789
- conn = get_db()
790
- now = now_epoch()
791
- conn.execute(
792
- "INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time) "
793
- "VALUES (?, ?, ?, ?, ?)",
794
- (sid, task, now, now, local_time_str())
795
- )
796
- conn.commit()
797
- return {"sid": sid, "task": task}
798
-
799
-
800
- def update_session(sid: str, task: str) -> dict:
801
- """Update session task and timestamp. Preserves started_epoch."""
802
- conn = get_db()
803
- now = now_epoch()
804
- row = conn.execute("SELECT started_epoch FROM sessions WHERE sid = ?", (sid,)).fetchone()
805
- if row:
806
- conn.execute(
807
- "UPDATE sessions SET task = ?, last_update_epoch = ?, local_time = ? WHERE sid = ?",
808
- (task, now, local_time_str(), sid)
809
- )
810
- else:
811
- conn.execute(
812
- "INSERT INTO sessions (sid, task, started_epoch, last_update_epoch, local_time) "
813
- "VALUES (?, ?, ?, ?, ?)",
814
- (sid, task, now, now, local_time_str())
815
- )
816
- conn.commit()
817
- return {"sid": sid, "task": task}
818
-
819
-
820
- def complete_session(sid: str):
821
- """Remove session and its tracked files."""
822
- conn = get_db()
823
- conn.execute("PRAGMA foreign_keys=ON")
824
- conn.execute("DELETE FROM tracked_files WHERE sid = ?", (sid,))
825
- conn.execute("DELETE FROM sessions WHERE sid = ?", (sid,))
826
- conn.commit()
827
-
828
-
829
- def get_active_sessions() -> list[dict]:
830
- """Get all sessions updated within STALE threshold."""
831
- conn = get_db()
832
- cutoff = now_epoch() - SESSION_STALE_SECONDS
833
- rows = conn.execute(
834
- "SELECT sid, task, started_epoch, last_update_epoch, local_time "
835
- "FROM sessions WHERE last_update_epoch > ?",
836
- (cutoff,)
837
- ).fetchall()
838
- return [dict(r) for r in rows]
839
-
840
-
841
- def clean_stale_sessions() -> int:
842
- """Remove stale sessions. Returns count removed."""
843
- conn = get_db()
844
- cutoff = now_epoch() - SESSION_STALE_SECONDS
845
- stale = conn.execute(
846
- "SELECT sid FROM sessions WHERE last_update_epoch <= ?", (cutoff,)
847
- ).fetchall()
848
- for row in stale:
849
- conn.execute("DELETE FROM tracked_files WHERE sid = ?", (row["sid"],))
850
- result = conn.execute(
851
- "DELETE FROM sessions WHERE last_update_epoch <= ?", (cutoff,)
852
- )
853
- count = result.rowcount
854
- conn.commit()
855
- return count
856
-
857
-
858
- def search_sessions(keyword: str) -> list[dict]:
859
- """Find sessions whose task contains keyword (case-insensitive)."""
860
- conn = get_db()
861
- cutoff = now_epoch() - SESSION_STALE_SECONDS
862
- rows = conn.execute(
863
- "SELECT sid, task, last_update_epoch, local_time FROM sessions "
864
- "WHERE last_update_epoch > ? AND LOWER(task) LIKE ?",
865
- (cutoff, f"%{keyword.lower()}%")
866
- ).fetchall()
867
- return [dict(r) for r in rows]
868
-
869
-
870
- # ── File tracking ───────────────────────────────────────────────────
871
-
872
- def track_files(sid: str, paths: list[str]) -> dict:
873
- """Track files for a session. Returns conflicts if any."""
874
- conn = get_db()
875
- now = now_epoch()
876
- session = conn.execute("SELECT sid FROM sessions WHERE sid = ?", (sid,)).fetchone()
877
- if not session:
878
- return {"error": f"Session {sid} not found. Register first."}
879
-
880
- for path in paths:
881
- conn.execute(
882
- "INSERT OR IGNORE INTO tracked_files (sid, path, tracked_at) VALUES (?, ?, ?)",
883
- (sid, path, now)
884
- )
885
- conn.commit()
886
- conflicts = _check_conflicts(conn, sid)
887
- return {"tracked": paths, "conflicts": conflicts}
888
-
889
-
890
- def untrack_files(sid: str, paths: list[str] | None = None):
891
- """Untrack files. If paths is None, untrack all."""
892
- conn = get_db()
893
- if paths:
894
- for path in paths:
895
- conn.execute(
896
- "DELETE FROM tracked_files WHERE sid = ? AND path = ?",
897
- (sid, path)
898
- )
899
- else:
900
- conn.execute("DELETE FROM tracked_files WHERE sid = ?", (sid,))
901
- conn.commit()
902
-
903
-
904
- def get_all_tracked_files() -> dict:
905
- """Get all tracked files grouped by session."""
906
- conn = get_db()
907
- cutoff = now_epoch() - SESSION_STALE_SECONDS
908
- rows = conn.execute(
909
- "SELECT tf.sid, tf.path, s.task FROM tracked_files tf "
910
- "JOIN sessions s ON tf.sid = s.sid "
911
- "WHERE s.last_update_epoch > ?",
912
- (cutoff,)
913
- ).fetchall()
914
- result = {}
915
- for r in rows:
916
- sid = r["sid"]
917
- if sid not in result:
918
- result[sid] = {"task": r["task"], "files": []}
919
- result[sid]["files"].append(r["path"])
920
- return result
921
-
922
-
923
- def _check_conflicts(conn: sqlite3.Connection, sid: str) -> list[dict]:
924
- """Check if any of sid's files are tracked by other active sessions."""
925
- cutoff = now_epoch() - SESSION_STALE_SECONDS
926
- my_files = conn.execute(
927
- "SELECT path FROM tracked_files WHERE sid = ?", (sid,)
928
- ).fetchall()
929
- my_paths = {r["path"] for r in my_files}
930
- if not my_paths:
931
- return []
932
-
933
- conflicts = []
934
- others = conn.execute(
935
- "SELECT tf.sid, tf.path, s.task FROM tracked_files tf "
936
- "JOIN sessions s ON tf.sid = s.sid "
937
- "WHERE tf.sid != ? AND s.last_update_epoch > ?",
938
- (sid, cutoff)
939
- ).fetchall()
940
- by_sid = {}
941
- for r in others:
942
- if r["path"] in my_paths:
943
- osid = r["sid"]
944
- if osid not in by_sid:
945
- by_sid[osid] = {"sid": osid, "task": r["task"], "files": []}
946
- by_sid[osid]["files"].append(r["path"])
947
- return list(by_sid.values())
948
-
949
-
950
- # ── Messages ────────────────────────────────────────────────────────
951
-
952
- def send_message(from_sid: str, to_sid: str, text: str) -> str:
953
- """Send a message. to_sid can be 'all' for broadcast."""
954
- conn = get_db()
955
- _clean_old_messages(conn)
956
- msg_id = _gen_id("msg", 6)
957
- conn.execute(
958
- "INSERT INTO messages (id, from_sid, to_sid, text, created_epoch) "
959
- "VALUES (?, ?, ?, ?, ?)",
960
- (msg_id, from_sid, to_sid, text, now_epoch())
961
- )
962
- conn.commit()
963
- return msg_id
964
-
965
-
966
- def get_inbox(sid: str) -> list[dict]:
967
- """Get unread messages for a session."""
968
- conn = get_db()
969
- _clean_old_messages(conn)
970
- rows = conn.execute(
971
- "SELECT m.id, m.from_sid, m.to_sid, m.text, m.created_epoch "
972
- "FROM messages m "
973
- "WHERE (m.to_sid = 'all' OR m.to_sid = ?) "
974
- "AND m.from_sid != ? "
975
- "AND m.id NOT IN (SELECT message_id FROM message_reads WHERE sid = ?)",
976
- (sid, sid, sid)
977
- ).fetchall()
978
- for r in rows:
979
- conn.execute(
980
- "INSERT OR IGNORE INTO message_reads (message_id, sid) VALUES (?, ?)",
981
- (r["id"], sid)
982
- )
983
- conn.commit()
984
- result = [dict(r) for r in rows]
985
- return result
986
-
987
-
988
- def _clean_old_messages(conn: sqlite3.Connection):
989
- """Remove expired messages and commit immediately."""
990
- cutoff = now_epoch() - MESSAGE_TTL_SECONDS
991
- conn.execute("DELETE FROM messages WHERE created_epoch < ?", (cutoff,))
992
- conn.commit()
993
-
994
-
995
- # ── Questions ───────────────────────────────────────────────────────
996
-
997
- def ask_question(from_sid: str, to_sid: str, question: str) -> str:
998
- """Create a pending question. Returns qid."""
999
- conn = get_db()
1000
- _expire_old_questions(conn)
1001
- qid = _gen_id("q", 8)
1002
- conn.execute(
1003
- "INSERT INTO questions (qid, from_sid, to_sid, question, status, created_epoch) "
1004
- "VALUES (?, ?, ?, ?, 'pending', ?)",
1005
- (qid, from_sid, to_sid, question, now_epoch())
1006
- )
1007
- conn.commit()
1008
- return qid
1009
-
1010
-
1011
- def answer_question(qid: str, answer: str) -> dict:
1012
- """Answer a pending question."""
1013
- conn = get_db()
1014
- row = conn.execute(
1015
- "SELECT * FROM questions WHERE qid = ?", (qid,)
1016
- ).fetchone()
1017
- if not row:
1018
- return {"error": f"Question {qid} not found"}
1019
- if row["status"] != "pending":
1020
- return {"error": f"Question {qid} is {row['status']}, not pending"}
1021
- conn.execute(
1022
- "UPDATE questions SET answer = ?, status = 'answered', answered_epoch = ? "
1023
- "WHERE qid = ?",
1024
- (answer, now_epoch(), qid)
1025
- )
1026
- conn.commit()
1027
- return {"qid": qid, "status": "answered"}
1028
-
1029
-
1030
- def get_pending_questions(sid: str) -> list[dict]:
1031
- """Get pending questions addressed to this session."""
1032
- conn = get_db()
1033
- _expire_old_questions(conn)
1034
- rows = conn.execute(
1035
- "SELECT qid, from_sid, question, created_epoch FROM questions "
1036
- "WHERE to_sid = ? AND status = 'pending'",
1037
- (sid,)
1038
- ).fetchall()
1039
- conn.commit()
1040
- return [dict(r) for r in rows]
1041
-
1042
-
1043
- def check_answer(qid: str) -> dict | None:
1044
- """Check if a question has been answered. Returns answer or None."""
1045
- conn = get_db()
1046
- row = conn.execute(
1047
- "SELECT qid, answer, status FROM questions WHERE qid = ?", (qid,)
1048
- ).fetchone()
1049
- if not row:
1050
- return None
1051
- return dict(row)
1052
-
1053
-
1054
- def _expire_old_questions(conn: sqlite3.Connection):
1055
- """Mark old pending questions as expired."""
1056
- cutoff = now_epoch() - QUESTION_TTL_SECONDS
1057
- conn.execute(
1058
- "UPDATE questions SET status = 'expired' "
1059
- "WHERE status = 'pending' AND created_epoch < ?",
1060
- (cutoff,)
1061
- )
1062
-
1063
-
1064
- # ── Reminders ──────────────────────────────────────────────────────
1065
-
1066
- def create_reminder(id: str, description: str, date: str = None,
1067
- status: str = 'PENDIENTE', category: str = 'general') -> dict:
1068
- """Create a new reminder."""
1069
- conn = get_db()
1070
- now = now_epoch()
1071
- try:
1072
- conn.execute(
1073
- "INSERT INTO reminders (id, date, description, status, category, created_at, updated_at) "
1074
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
1075
- (id, date, description, status, category, now, now)
1076
- )
1077
- conn.commit()
1078
- except sqlite3.IntegrityError:
1079
- return {"error": f"Reminder {id} already exists. Use update instead."}
1080
- row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
1081
- return dict(row)
1082
-
1083
-
1084
- def update_reminder(id: str, **kwargs) -> dict:
1085
- """Update any fields of a reminder: description, date, status, category."""
1086
- conn = get_db()
1087
- row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
1088
- if not row:
1089
- return {"error": f"Reminder {id} not found"}
1090
- allowed = {"description", "date", "status", "category"}
1091
- updates = {k: v for k, v in kwargs.items() if k in allowed}
1092
- if not updates:
1093
- return {"error": "No valid fields to update"}
1094
- updates["updated_at"] = now_epoch()
1095
- set_clause = ", ".join(f"{k} = ?" for k in updates)
1096
- values = list(updates.values()) + [id]
1097
- conn.execute(f"UPDATE reminders SET {set_clause} WHERE id = ?", values)
1098
- conn.commit()
1099
- row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
1100
- return dict(row)
1101
-
1102
-
1103
- def complete_reminder(id: str) -> dict:
1104
- """Mark a reminder as completed with today's date."""
1105
- today = datetime.date.today().isoformat()
1106
- return update_reminder(id, status=f"COMPLETADO {today}")
1107
-
1108
-
1109
- def delete_reminder(id: str) -> bool:
1110
- """Delete a reminder."""
1111
- conn = get_db()
1112
- result = conn.execute("DELETE FROM reminders WHERE id = ?", (id,))
1113
- conn.commit()
1114
- deleted = result.rowcount > 0
1115
- return deleted
1116
-
1117
-
1118
- def get_reminders(filter_type: str = 'all') -> list[dict]:
1119
- """Get reminders by filter: 'all' (active), 'due' (date <= today), 'completed'."""
1120
- conn = get_db()
1121
- today = datetime.date.today().isoformat()
1122
- if filter_type == 'completed':
1123
- rows = conn.execute(
1124
- "SELECT * FROM reminders WHERE status LIKE 'COMPLETADO%' ORDER BY updated_at DESC"
1125
- ).fetchall()
1126
- elif filter_type == 'due':
1127
- rows = conn.execute(
1128
- "SELECT * FROM reminders WHERE status NOT LIKE 'COMPLETADO%' "
1129
- "AND status != 'ELIMINADO' AND date IS NOT NULL AND date <= ? "
1130
- "ORDER BY date ASC",
1131
- (today,)
1132
- ).fetchall()
1133
- else: # 'all' — active only
1134
- rows = conn.execute(
1135
- "SELECT * FROM reminders WHERE status NOT LIKE 'COMPLETADO%' "
1136
- "AND status != 'ELIMINADO' ORDER BY date ASC NULLS LAST"
1137
- ).fetchall()
1138
- return [dict(r) for r in rows]
1139
-
1140
-
1141
- def get_reminder(id: str) -> dict | None:
1142
- """Get a single reminder by id."""
1143
- conn = get_db()
1144
- row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
1145
- return dict(row) if row else None
1146
-
1147
-
1148
- # ── Followups ──────────────────────────────────────────────────────
1149
-
1150
- def create_followup(id: str, description: str, date: str = None,
1151
- verification: str = '', status: str = 'PENDIENTE',
1152
- reasoning: str = '', recurrence: str = None) -> dict:
1153
- """Create a new followup with optional reasoning and recurrence.
1154
-
1155
- recurrence format: 'weekly:monday', 'monthly:1', 'monthly:10', 'quarterly', etc.
1156
- When a recurring followup is completed, a new one is auto-created with the next date.
1157
- """
1158
- conn = get_db()
1159
- now = now_epoch()
1160
- try:
1161
- conn.execute(
1162
- "INSERT INTO followups (id, date, description, verification, status, reasoning, recurrence, created_at, updated_at) "
1163
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1164
- (id, date, description, verification, status, reasoning, recurrence, now, now)
1165
- )
1166
- conn.commit()
1167
- fts_upsert("followup", id, id, f"{description} {verification} {reasoning}", "followup", commit=False)
1168
- except sqlite3.IntegrityError:
1169
- return {"error": f"Followup {id} already exists. Use update instead."}
1170
- row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
1171
- return dict(row)
1172
-
1173
-
1174
- def update_followup(id: str, **kwargs) -> dict:
1175
- """Update any fields of a followup: description, date, verification, status, reasoning."""
1176
- conn = get_db()
1177
- row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
1178
- if not row:
1179
- return {"error": f"Followup {id} not found"}
1180
- allowed = {"description", "date", "verification", "status", "reasoning", "recurrence"}
1181
- updates = {k: v for k, v in kwargs.items() if k in allowed}
1182
- if not updates:
1183
- return {"error": "No valid fields to update"}
1184
- updates["updated_at"] = now_epoch()
1185
- set_clause = ", ".join(f"{k} = ?" for k in updates)
1186
- values = list(updates.values()) + [id]
1187
- conn.execute(f"UPDATE followups SET {set_clause} WHERE id = ?", values)
1188
- conn.commit()
1189
- row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
1190
- r = dict(row)
1191
- fts_upsert("followup", id, id, f"{r.get('description','')} {r.get('verification','')} {r.get('reasoning','')}", "followup", commit=False)
1192
- return r
1193
-
1194
-
1195
- def _calc_next_recurrence_date(recurrence: str, current_date: str = None) -> str:
1196
- """Calculate the next date for a recurring followup."""
1197
- today = datetime.date.today()
1198
- base = datetime.date.fromisoformat(current_date) if current_date else today
1199
-
1200
- if recurrence.startswith('weekly:'):
1201
- day_name = recurrence.split(':')[1].lower()
1202
- day_map = {'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
1203
- 'friday': 4, 'saturday': 5, 'sunday': 6}
1204
- target_day = day_map.get(day_name, 0)
1205
- days_ahead = (target_day - today.weekday()) % 7
1206
- if days_ahead == 0:
1207
- days_ahead = 7
1208
- return (today + datetime.timedelta(days=days_ahead)).isoformat()
1209
-
1210
- elif recurrence.startswith('monthly:'):
1211
- target_day = int(recurrence.split(':')[1])
1212
- if today.month == 12:
1213
- next_date = datetime.date(today.year + 1, 1, min(target_day, 28))
1214
- else:
1215
- import calendar
1216
- max_day = calendar.monthrange(today.year, today.month + 1)[1]
1217
- next_date = datetime.date(today.year, today.month + 1, min(target_day, max_day))
1218
- return next_date.isoformat()
1219
-
1220
- elif recurrence == 'quarterly':
1221
- month = base.month + 3
1222
- year = base.year
1223
- if month > 12:
1224
- month -= 12
1225
- year += 1
1226
- import calendar
1227
- max_day = calendar.monthrange(year, month)[1]
1228
- return datetime.date(year, month, min(base.day, max_day)).isoformat()
1229
-
1230
- return None
1231
-
1232
-
1233
- def complete_followup(id: str, result: str = '') -> dict:
1234
- """Mark a followup as completed with today's date and optional result.
1235
- If the followup has a recurrence pattern, auto-creates the next occurrence."""
1236
- conn = get_db()
1237
- row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
1238
- if not row:
1239
- return {"error": f"Followup {id} not found"}
1240
-
1241
- today = datetime.date.today().isoformat()
1242
- kwargs = {"status": f"COMPLETADO {today}"}
1243
- if result:
1244
- existing = row["verification"] or ''
1245
- kwargs["verification"] = f"{existing}\n{result}".strip() if existing else result
1246
-
1247
- update_result = update_followup(id, **kwargs)
1248
-
1249
- # Auto-regenerate if recurring
1250
- recurrence = row["recurrence"]
1251
- if recurrence:
1252
- next_date = _calc_next_recurrence_date(recurrence, row["date"])
1253
- if next_date:
1254
- archived_id = f"{id}-{today}"
1255
- conn.execute("UPDATE followups SET id = ? WHERE id = ?", (archived_id, id))
1256
- conn.commit()
1257
- create_followup(
1258
- id=id,
1259
- description=row["description"],
1260
- date=next_date,
1261
- verification='',
1262
- reasoning=row["reasoning"] or '',
1263
- recurrence=recurrence,
1264
- )
1265
-
1266
- return update_result
1267
-
1268
-
1269
- def delete_followup(id: str) -> bool:
1270
- """Delete a followup."""
1271
- conn = get_db()
1272
- result = conn.execute("DELETE FROM followups WHERE id = ?", (id,))
1273
- conn.execute("DELETE FROM unified_search WHERE source = 'followup' AND source_id = ?", (str(id),))
1274
- conn.commit()
1275
- deleted = result.rowcount > 0
1276
- return deleted
1277
-
1278
-
1279
- def get_followups(filter_type: str = 'all') -> list[dict]:
1280
- """Get followups by filter: 'all' (active), 'due' (date <= today), 'completed'."""
1281
- conn = get_db()
1282
- today = datetime.date.today().isoformat()
1283
- if filter_type == 'completed':
1284
- rows = conn.execute(
1285
- "SELECT * FROM followups WHERE status LIKE 'COMPLETADO%' ORDER BY updated_at DESC"
1286
- ).fetchall()
1287
- elif filter_type == 'due':
1288
- rows = conn.execute(
1289
- "SELECT * FROM followups WHERE status NOT LIKE 'COMPLETADO%' "
1290
- "AND status != 'ELIMINADO' AND date IS NOT NULL AND date <= ? "
1291
- "ORDER BY date ASC",
1292
- (today,)
1293
- ).fetchall()
1294
- else: # 'all' — active only
1295
- rows = conn.execute(
1296
- "SELECT * FROM followups WHERE status NOT LIKE 'COMPLETADO%' "
1297
- "AND status != 'ELIMINADO' ORDER BY date ASC NULLS LAST"
1298
- ).fetchall()
1299
- return [dict(r) for r in rows]
1300
-
1301
-
1302
- def get_followup(id: str) -> dict | None:
1303
- """Get a single followup by id."""
1304
- conn = get_db()
1305
- row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
1306
- return dict(row) if row else None
1307
-
1308
-
1309
- # ── Learnings ──────────────────────────────────────────────────────
1310
-
1311
- def create_learning(
1312
- category: str,
1313
- title: str,
1314
- content: str,
1315
- reasoning: str = '',
1316
- prevention: str = '',
1317
- applies_to: str = '',
1318
- status: str = 'active',
1319
- review_due_at: float | None = None,
1320
- last_reviewed_at: float | None = None,
1321
- ) -> dict:
1322
- """Create a new learning entry with optional reasoning."""
1323
- conn = get_db()
1324
- now = now_epoch()
1325
- cursor = conn.execute(
1326
- "INSERT INTO learnings "
1327
- "(category, title, content, reasoning, prevention, applies_to, status, review_due_at, last_reviewed_at, created_at, updated_at) "
1328
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1329
- (
1330
- category, title, content, reasoning, prevention, applies_to,
1331
- status, review_due_at, last_reviewed_at, now, now,
1332
- )
1333
- )
1334
- conn.commit()
1335
- lid = cursor.lastrowid
1336
- fts_upsert("learning", str(lid), title, f"{content} {reasoning or ''}", category, commit=False)
1337
- row = conn.execute("SELECT * FROM learnings WHERE id = ?", (lid,)).fetchone()
1338
- return dict(row)
1339
-
1340
-
1341
- def update_learning(id: int, **kwargs) -> dict:
1342
- """Update any fields of a learning: category, title, content, reasoning."""
1343
- conn = get_db()
1344
- row = conn.execute("SELECT * FROM learnings WHERE id = ?", (id,)).fetchone()
1345
- if not row:
1346
- return {"error": f"Learning {id} not found"}
1347
- allowed = {
1348
- "category", "title", "content", "reasoning", "prevention",
1349
- "applies_to", "status", "review_due_at", "last_reviewed_at",
1350
- }
1351
- updates = {k: v for k, v in kwargs.items() if k in allowed}
1352
- if not updates:
1353
- return dict(row)
1354
- updates["updated_at"] = now_epoch()
1355
- set_clause = ", ".join(f"{k} = ?" for k in updates)
1356
- values = list(updates.values()) + [id]
1357
- conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", values)
1358
- conn.commit()
1359
- row = conn.execute("SELECT * FROM learnings WHERE id = ?", (id,)).fetchone()
1360
- r = dict(row)
1361
- fts_upsert("learning", str(id), r.get("title", ""), f"{r.get('content', '')} {r.get('reasoning', '')}", r.get("category", ""), commit=False)
1362
- return r
1363
-
1364
-
1365
- def delete_learning(id: int) -> bool:
1366
- """Delete a learning entry."""
1367
- conn = get_db()
1368
- result = conn.execute("DELETE FROM learnings WHERE id = ?", (id,))
1369
- conn.execute("DELETE FROM unified_search WHERE source = 'learning' AND source_id = ?", (str(id),))
1370
- conn.commit()
1371
- deleted = result.rowcount > 0
1372
- return deleted
1373
-
1374
-
1375
- def search_learnings(query: str, category: str = None) -> list[dict]:
1376
- """Search learnings using FTS5 for ranked results. Falls back to LIKE if FTS fails."""
1377
- # Try FTS5 first
1378
- fts_results = fts_search(query, source_filter="learning", limit=30)
1379
- if fts_results:
1380
- conn = get_db()
1381
- ids = [int(r['source_id']) for r in fts_results]
1382
- placeholders = ','.join('?' * len(ids))
1383
- rows = conn.execute(
1384
- f"SELECT * FROM learnings WHERE id IN ({placeholders}) ORDER BY updated_at DESC",
1385
- ids
1386
- ).fetchall()
1387
- filtered = [dict(r) for r in rows]
1388
- if category:
1389
- filtered = [r for r in filtered if r.get('category') == category]
1390
- return filtered
1391
-
1392
- # Fallback to LIKE
1393
- conn = get_db()
1394
- words = query.strip().split()
1395
- if not words:
1396
- return []
1397
- conditions = []
1398
- params = []
1399
- for word in words:
1400
- pattern = f"%{word}%"
1401
- conditions.append("(title LIKE ? OR content LIKE ? OR reasoning LIKE ? OR prevention LIKE ?)")
1402
- params.extend([pattern, pattern, pattern, pattern])
1403
- where = " AND ".join(conditions)
1404
- if category:
1405
- where = f"category = ? AND ({where})"
1406
- params.insert(0, category)
1407
- rows = conn.execute(
1408
- f"SELECT * FROM learnings WHERE {where} ORDER BY updated_at DESC",
1409
- params
1410
- ).fetchall()
1411
- return [dict(r) for r in rows]
1412
-
1413
-
1414
- def list_learnings(category: str = None) -> list[dict]:
1415
- """List all learnings, optionally filtered by category."""
1416
- conn = get_db()
1417
- if category:
1418
- rows = conn.execute(
1419
- "SELECT * FROM learnings WHERE category = ? ORDER BY updated_at DESC",
1420
- (category,)
1421
- ).fetchall()
1422
- else:
1423
- rows = conn.execute(
1424
- "SELECT * FROM learnings ORDER BY category ASC, updated_at DESC"
1425
- ).fetchall()
1426
- return [dict(r) for r in rows]
1427
-
1428
-
1429
- def extract_keywords(text: str) -> list[str]:
1430
- """Extract meaningful keywords from text for similarity matching."""
1431
- import re
1432
- stop = {'the', 'a', 'an', 'is', 'was', 'are', 'were', 'be', 'been', 'being',
1433
- 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
1434
- 'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
1435
- 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
1436
- 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
1437
- 'under', 'again', 'further', 'then', 'once', 'and', 'but', 'or', 'nor',
1438
- 'not', 'so', 'yet', 'both', 'either', 'neither', 'each', 'every', 'all',
1439
- 'any', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'only',
1440
- 'own', 'same', 'than', 'too', 'very', 'just', 'que', 'de', 'en', 'la',
1441
- 'el', 'los', 'las', 'un', 'una', 'por', 'con', 'para', 'del', 'al',
1442
- 'es', 'se', 'no', 'si', 'como', 'pero', 'su', 'ya', 'esto', 'esta'}
1443
- words = re.findall(r'[a-zA-Z0-9_]+', text.lower())
1444
- return [w for w in words if len(w) > 2 and w not in stop]
1445
-
1446
-
1447
- def find_similar_learnings(new_id: int, title: str, content: str, category: str) -> list[tuple[int, float]]:
1448
- """Find learnings similar to the given one based on keyword overlap.
1449
- Returns list of (learning_id, similarity_score) tuples for matches > 0.3."""
1450
- keywords_new = set(extract_keywords(f"{title} {content}"))
1451
- if not keywords_new:
1452
- return []
1453
- conn = get_db()
1454
- rows = conn.execute(
1455
- "SELECT id, title, content FROM learnings WHERE category = ? AND id != ?",
1456
- (category, new_id)
1457
- ).fetchall()
1458
- results = []
1459
- for row in rows:
1460
- keywords_existing = set(extract_keywords(f"{row['title']} {row['content']}"))
1461
- if not keywords_existing:
1462
- continue
1463
- overlap = keywords_new & keywords_existing
1464
- union = keywords_new | keywords_existing
1465
- similarity = len(overlap) / len(union) if union else 0
1466
- if similarity > 0.3:
1467
- results.append((row['id'], round(similarity, 2)))
1468
- results.sort(key=lambda x: x[1], reverse=True)
1469
- return results[:5]
1470
-
1471
-
1472
- # ── Credentials ────────────────────────────────────────────────────
1473
-
1474
- def create_credential(service: str, key: str, value: str, notes: str = '') -> dict:
1475
- """Create a new credential entry."""
1476
- conn = get_db()
1477
- now = now_epoch()
1478
- try:
1479
- conn.execute(
1480
- "INSERT INTO credentials (service, key, value, notes, created_at, updated_at) "
1481
- "VALUES (?, ?, ?, ?, ?, ?)",
1482
- (service, key, value, notes, now, now)
1483
- )
1484
- conn.commit()
1485
- except sqlite3.IntegrityError:
1486
- return {"error": f"Credential {service}/{key} already exists. Use update instead."}
1487
- row = conn.execute(
1488
- "SELECT * FROM credentials WHERE service = ? AND key = ?", (service, key)
1489
- ).fetchone()
1490
- return dict(row)
1491
-
1492
-
1493
- def update_credential(service: str, key: str, value: str = None, notes: str = None) -> dict:
1494
- """Update value and/or notes for a credential."""
1495
- conn = get_db()
1496
- row = conn.execute(
1497
- "SELECT * FROM credentials WHERE service = ? AND key = ?", (service, key)
1498
- ).fetchone()
1499
- if not row:
1500
- return {"error": f"Credential {service}/{key} not found"}
1501
- updates = {"updated_at": now_epoch()}
1502
- if value is not None:
1503
- updates["value"] = value
1504
- if notes is not None:
1505
- updates["notes"] = notes
1506
- set_clause = ", ".join(f"{k} = ?" for k in updates)
1507
- values = list(updates.values()) + [service, key]
1508
- conn.execute(
1509
- f"UPDATE credentials SET {set_clause} WHERE service = ? AND key = ?", values
1510
- )
1511
- conn.commit()
1512
- row = conn.execute(
1513
- "SELECT * FROM credentials WHERE service = ? AND key = ?", (service, key)
1514
- ).fetchone()
1515
- return dict(row)
1516
-
1517
-
1518
- def delete_credential(service: str, key: str = None) -> bool:
1519
- """Delete credential(s). If key=None, delete all for the service."""
1520
- conn = get_db()
1521
- if key:
1522
- result = conn.execute(
1523
- "DELETE FROM credentials WHERE service = ? AND key = ?", (service, key)
1524
- )
1525
- else:
1526
- result = conn.execute(
1527
- "DELETE FROM credentials WHERE service = ?", (service,)
1528
- )
1529
- conn.commit()
1530
- deleted = result.rowcount > 0
1531
- return deleted
1532
-
1533
-
1534
- def get_credential(service: str, key: str = None) -> list[dict]:
1535
- """Get credential(s). If key=None, return all for the service."""
1536
- conn = get_db()
1537
- if key:
1538
- rows = conn.execute(
1539
- "SELECT * FROM credentials WHERE service = ? AND key = ?", (service, key)
1540
- ).fetchall()
1541
- else:
1542
- rows = conn.execute(
1543
- "SELECT * FROM credentials WHERE service = ?", (service,)
1544
- ).fetchall()
1545
- return [dict(r) for r in rows]
1546
-
1547
-
1548
- def list_credentials(service: str = None) -> list[dict]:
1549
- """List service+key only (NO values) for security."""
1550
- conn = get_db()
1551
- if service:
1552
- rows = conn.execute(
1553
- "SELECT id, service, key, notes, created_at, updated_at "
1554
- "FROM credentials WHERE service = ? ORDER BY key ASC",
1555
- (service,)
1556
- ).fetchall()
1557
- else:
1558
- rows = conn.execute(
1559
- "SELECT id, service, key, notes, created_at, updated_at "
1560
- "FROM credentials ORDER BY service ASC, key ASC"
1561
- ).fetchall()
1562
- return [dict(r) for r in rows]
1563
-
1564
-
1565
- # ── Task History & Frequencies ─────────────────────────────────────
1566
-
1567
- def log_task(task_num: str, task_name: str, notes: str = '', reasoning: str = '') -> dict:
1568
- """Log a task execution with optional reasoning."""
1569
- conn = get_db()
1570
- now = now_epoch()
1571
- cursor = conn.execute(
1572
- "INSERT INTO task_history (task_num, task_name, executed_at, notes, reasoning) "
1573
- "VALUES (?, ?, ?, ?, ?)",
1574
- (task_num, task_name, now, notes, reasoning)
1575
- )
1576
- conn.commit()
1577
- row = conn.execute(
1578
- "SELECT * FROM task_history WHERE id = ?", (cursor.lastrowid,)
1579
- ).fetchone()
1580
- return dict(row)
1581
-
1582
-
1583
- def list_task_history(task_num: str = None, days: int = 30) -> list[dict]:
1584
- """List task execution history, optionally filtered by task_num."""
1585
- conn = get_db()
1586
- cutoff = now_epoch() - (days * 86400)
1587
- if task_num:
1588
- rows = conn.execute(
1589
- "SELECT * FROM task_history WHERE task_num = ? AND executed_at >= ? "
1590
- "ORDER BY executed_at DESC",
1591
- (task_num, cutoff)
1592
- ).fetchall()
1593
- else:
1594
- rows = conn.execute(
1595
- "SELECT * FROM task_history WHERE executed_at >= ? "
1596
- "ORDER BY executed_at DESC",
1597
- (cutoff,)
1598
- ).fetchall()
1599
- return [dict(r) for r in rows]
1600
-
1601
-
1602
- def set_task_frequency(task_num: str, task_name: str,
1603
- frequency_days: int, description: str = '') -> dict:
1604
- """Set or update the expected frequency for a task."""
1605
- conn = get_db()
1606
- conn.execute(
1607
- "INSERT OR REPLACE INTO task_frequencies (task_num, task_name, frequency_days, description) "
1608
- "VALUES (?, ?, ?, ?)",
1609
- (task_num, task_name, frequency_days, description)
1610
- )
1611
- conn.commit()
1612
- row = conn.execute(
1613
- "SELECT * FROM task_frequencies WHERE task_num = ?", (task_num,)
1614
- ).fetchone()
1615
- return dict(row)
1616
-
1617
-
1618
- def get_overdue_tasks() -> list[dict]:
1619
- """Get tasks where last execution exceeds the configured frequency."""
1620
- conn = get_db()
1621
- freqs = conn.execute("SELECT * FROM task_frequencies").fetchall()
1622
- now = now_epoch()
1623
- overdue = []
1624
- for f in freqs:
1625
- last = conn.execute(
1626
- "SELECT MAX(executed_at) as last_exec FROM task_history WHERE task_num = ?",
1627
- (f["task_num"],)
1628
- ).fetchone()
1629
- last_exec = last["last_exec"] if last and last["last_exec"] else None
1630
- threshold = f["frequency_days"] * 86400
1631
- if last_exec is None or (now - last_exec) > threshold:
1632
- days_ago = round((now - last_exec) / 86400, 1) if last_exec else None
1633
- overdue.append({
1634
- "task_num": f["task_num"],
1635
- "task_name": f["task_name"],
1636
- "frequency_days": f["frequency_days"],
1637
- "last_executed": last_exec,
1638
- "days_since_last": days_ago,
1639
- "description": f["description"]
1640
- })
1641
- return overdue
1642
-
1643
-
1644
- def get_task_frequencies() -> list[dict]:
1645
- """Get all configured task frequencies."""
1646
- conn = get_db()
1647
- rows = conn.execute(
1648
- "SELECT * FROM task_frequencies ORDER BY task_num ASC"
1649
- ).fetchall()
1650
- return [dict(r) for r in rows]
1651
-
1652
-
1653
- # ── Entities ──────────────────────────────────────────────────────
1654
-
1655
- def create_entity(name: str, type: str, value: str, notes: str = "") -> int:
1656
- """Create a new entity. Returns the entity ID."""
1657
- conn = get_db()
1658
- now = time.time()
1659
- cursor = conn.execute(
1660
- "INSERT INTO entities (name, type, value, notes, created_at, updated_at) "
1661
- "VALUES (?, ?, ?, ?, ?, ?)",
1662
- (name, type, value, notes, now, now)
1663
- )
1664
- conn.commit()
1665
- eid = cursor.lastrowid
1666
- fts_upsert("entity", str(eid), name, f"{name} {value} {notes}", type or "general", commit=False)
1667
- return eid
1668
-
1669
-
1670
- def search_entities(query: str, type: str = "") -> list[dict]:
1671
- """Search entities by name or value. Multi-word AND search."""
1672
- conn = get_db()
1673
- frag, params = _multi_word_like(query, ["name", "value"])
1674
- if type:
1675
- where = f"type = ? AND ({frag})"
1676
- params.insert(0, type)
1677
- else:
1678
- where = frag
1679
- rows = conn.execute(
1680
- f"SELECT * FROM entities WHERE {where} ORDER BY updated_at DESC",
1681
- params
1682
- ).fetchall()
1683
- return [dict(r) for r in rows]
1684
-
1685
-
1686
- def list_entities(type: str = "") -> list[dict]:
1687
- """List all entities, optionally filtered by type."""
1688
- conn = get_db()
1689
- if type:
1690
- rows = conn.execute(
1691
- "SELECT * FROM entities WHERE type = ? ORDER BY name ASC",
1692
- (type,)
1693
- ).fetchall()
1694
- else:
1695
- rows = conn.execute(
1696
- "SELECT * FROM entities ORDER BY type ASC, name ASC"
1697
- ).fetchall()
1698
- return [dict(r) for r in rows]
1699
-
1700
-
1701
- def update_entity(id: int, **kwargs):
1702
- """Update entity fields: name, type, value, notes."""
1703
- conn = get_db()
1704
- allowed = {"name", "type", "value", "notes"}
1705
- updates = {k: v for k, v in kwargs.items() if k in allowed}
1706
- if not updates:
1707
- return
1708
- updates["updated_at"] = time.time()
1709
- set_clause = ", ".join(f"{k} = ?" for k in updates)
1710
- values = list(updates.values()) + [id]
1711
- conn.execute(f"UPDATE entities SET {set_clause} WHERE id = ?", values)
1712
- conn.commit()
1713
- row = conn.execute("SELECT * FROM entities WHERE id = ?", (id,)).fetchone()
1714
- if row:
1715
- r = dict(row)
1716
- fts_upsert("entity", str(id), r.get("name",""), f"{r.get('name','')} {r.get('value','')} {r.get('notes','')}", r.get("type","general"), commit=False)
1717
-
1718
-
1719
- def delete_entity(id: int) -> bool:
1720
- """Delete an entity. Returns True if deleted, False if not found."""
1721
- conn = get_db()
1722
- result = conn.execute("DELETE FROM entities WHERE id = ?", (id,))
1723
- conn.execute("DELETE FROM unified_search WHERE source = 'entity' AND source_id = ?", (str(id),))
1724
- conn.commit()
1725
- return result.rowcount > 0
1726
-
1727
-
1728
- # ── Preferences ───────────────────────────────────────────────────
1729
-
1730
- def set_preference(key: str, value: str, category: str = "general"):
1731
- """Set a preference (insert or update)."""
1732
- conn = get_db()
1733
- now = time.time()
1734
- conn.execute(
1735
- "INSERT OR REPLACE INTO preferences (key, value, category, updated_at) "
1736
- "VALUES (?, ?, ?, ?)",
1737
- (key, value, category, now)
1738
- )
1739
- conn.commit()
1740
-
1741
-
1742
- def get_preference(key: str) -> dict | None:
1743
- """Get a single preference by key."""
1744
- conn = get_db()
1745
- row = conn.execute("SELECT * FROM preferences WHERE key = ?", (key,)).fetchone()
1746
- return dict(row) if row else None
1747
-
1748
-
1749
- def list_preferences(category: str = "") -> list[dict]:
1750
- """List all preferences, optionally filtered by category."""
1751
- conn = get_db()
1752
- if category:
1753
- rows = conn.execute(
1754
- "SELECT * FROM preferences WHERE category = ? ORDER BY key ASC",
1755
- (category,)
1756
- ).fetchall()
1757
- else:
1758
- rows = conn.execute(
1759
- "SELECT * FROM preferences ORDER BY category ASC, key ASC"
1760
- ).fetchall()
1761
- return [dict(r) for r in rows]
1762
-
1763
-
1764
- def delete_preference(key: str) -> bool:
1765
- """Delete a preference. Returns True if deleted, False if not found."""
1766
- conn = get_db()
1767
- result = conn.execute("DELETE FROM preferences WHERE key = ?", (key,))
1768
- conn.commit()
1769
- return result.rowcount > 0
1770
-
1771
-
1772
- # ── Agents ────────────────────────────────────────────────────────
1773
-
1774
- def create_agent(id: str, name: str, specialization: str, model: str = "sonnet",
1775
- tools: str = "", context_files: str = "", rules: str = "") -> dict:
1776
- """Register a new agent. Uses INSERT OR REPLACE to allow re-registration."""
1777
- conn = get_db()
1778
- now = time.time()
1779
- conn.execute(
1780
- "INSERT OR REPLACE INTO agents (id, name, specialization, model, tools, context_files, rules, created_at, updated_at) "
1781
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1782
- (id, name, specialization, model, tools, context_files, rules, now, now)
1783
- )
1784
- conn.commit()
1785
- return {"id": id, "name": name}
1786
-
1787
-
1788
- def get_agent(id: str) -> dict | None:
1789
- """Get an agent by ID."""
1790
- conn = get_db()
1791
- row = conn.execute("SELECT * FROM agents WHERE id = ?", (id,)).fetchone()
1792
- return dict(row) if row else None
1793
-
1794
-
1795
- def list_agents() -> list[dict]:
1796
- """List all registered agents."""
1797
- conn = get_db()
1798
- rows = conn.execute(
1799
- "SELECT * FROM agents ORDER BY name ASC"
1800
- ).fetchall()
1801
- return [dict(r) for r in rows]
1802
-
1803
-
1804
- def update_agent(id: str, **kwargs):
1805
- """Update agent fields: name, specialization, model, tools, context_files, rules."""
1806
- conn = get_db()
1807
- allowed = {"name", "specialization", "model", "tools", "context_files", "rules"}
1808
- updates = {k: v for k, v in kwargs.items() if k in allowed}
1809
- if not updates:
1810
- return
1811
- updates["updated_at"] = time.time()
1812
- set_clause = ", ".join(f"{k} = ?" for k in updates)
1813
- values = list(updates.values()) + [id]
1814
- conn.execute(f"UPDATE agents SET {set_clause} WHERE id = ?", values)
1815
- conn.commit()
1816
-
1817
-
1818
- def delete_agent(id: str) -> bool:
1819
- """Delete an agent. Returns True if deleted, False if not found."""
1820
- conn = get_db()
1821
- result = conn.execute("DELETE FROM agents WHERE id = ?", (id,))
1822
- conn.commit()
1823
- return result.rowcount > 0
1824
-
1825
-
1826
- # ── Change Log ───────────────────────────────────────────────────
1827
-
1828
- def cleanup_old_changes(retention_days: int = 90) -> int:
1829
- """Delete change_log entries older than retention_days. Returns count deleted."""
1830
- conn = get_db()
1831
- ids = [str(r[0]) for r in conn.execute(
1832
- "SELECT id FROM change_log WHERE created_at < datetime('now', ?)",
1833
- (f"-{retention_days} days",)
1834
- ).fetchall()]
1835
- cursor = conn.execute(
1836
- "DELETE FROM change_log WHERE created_at < datetime('now', ?)",
1837
- (f"-{retention_days} days",)
1838
- )
1839
- for cid in ids:
1840
- conn.execute("DELETE FROM unified_search WHERE source = 'change' AND source_id = ?", (cid,))
1841
- conn.commit()
1842
- return cursor.rowcount
1843
-
1844
-
1845
- def log_change(session_id: str, files: str, what_changed: str, why: str,
1846
- triggered_by: str = '', affects: str = '', risks: str = '',
1847
- verify: str = '', commit_ref: str = '') -> dict:
1848
- """Log a code/config change with full context."""
1849
- conn = get_db()
1850
- cleanup_old_changes()
1851
- try:
1852
- cursor = conn.execute(
1853
- "INSERT INTO change_log (session_id, files, what_changed, why, triggered_by, affects, risks, verify, commit_ref) "
1854
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1855
- (session_id, files, what_changed, why, triggered_by, affects, risks, verify, commit_ref)
1856
- )
1857
- conn.commit()
1858
- cid = cursor.lastrowid
1859
- body = f"{what_changed} {why} {triggered_by} {affects} {risks}"
1860
- fts_upsert("change", str(cid), files, body, "change_log", commit=False)
1861
- row = conn.execute("SELECT * FROM change_log WHERE id = ?", (cid,)).fetchone()
1862
- return dict(row)
1863
- except Exception as e:
1864
- return {"error": str(e)}
1865
-
1866
-
1867
- def search_changes(query: str = '', files: str = '', days: int = 30) -> list[dict]:
1868
- """Search change log by text and/or file path."""
1869
- conn = get_db()
1870
- days = max(1, int(days))
1871
- conditions = []
1872
- params = []
1873
- if query:
1874
- frag, qparams = _multi_word_like(query, ["what_changed", "why", "affects", "triggered_by"])
1875
- conditions.append(f"({frag})")
1876
- params.extend(qparams)
1877
- if files:
1878
- frag_f, fparams = _multi_word_like(files, ["files"])
1879
- conditions.append(f"({frag_f})")
1880
- params.extend(fparams)
1881
- conditions.append("created_at >= datetime('now', ?)")
1882
- params.append(f"-{days} days")
1883
- where = " AND ".join(conditions)
1884
- rows = conn.execute(
1885
- f"SELECT * FROM change_log WHERE {where} ORDER BY created_at DESC",
1886
- params
1887
- ).fetchall()
1888
- return [dict(r) for r in rows]
1889
-
1890
-
1891
- def update_change_commit(id: int, commit_ref: str) -> dict:
1892
- """Link a change log entry to its git commit after commit."""
1893
- conn = get_db()
1894
- row = conn.execute("SELECT * FROM change_log WHERE id = ?", (id,)).fetchone()
1895
- if not row:
1896
- return {"error": f"Change {id} not found"}
1897
- conn.execute("UPDATE change_log SET commit_ref = ? WHERE id = ?", (commit_ref, id))
1898
- conn.commit()
1899
- row = conn.execute("SELECT * FROM change_log WHERE id = ?", (id,)).fetchone()
1900
- r = dict(row)
1901
- body = f"{r.get('what_changed','')} {r.get('why','')} {r.get('triggered_by','')} {r.get('affects','')} {r.get('risks','')}"
1902
- fts_upsert("change", str(id), r.get("files",""), body, "change_log", commit=False)
1903
- return r
1904
-
1905
-
1906
- # ── Decisions (episodic memory) ──────────────────────────────────
1907
-
1908
- def cleanup_old_decisions(retention_days: int = 90) -> int:
1909
- """Delete decisions entries older than retention_days. Returns count deleted."""
1910
- conn = get_db()
1911
- ids = [str(r[0]) for r in conn.execute(
1912
- "SELECT id FROM decisions WHERE created_at < datetime('now', ?)",
1913
- (f"-{retention_days} days",)
1914
- ).fetchall()]
1915
- cursor = conn.execute(
1916
- "DELETE FROM decisions WHERE created_at < datetime('now', ?)",
1917
- (f"-{retention_days} days",)
1918
- )
1919
- for did in ids:
1920
- conn.execute("DELETE FROM unified_search WHERE source = 'decision' AND source_id = ?", (did,))
1921
- conn.commit()
1922
- return cursor.rowcount
1923
-
1924
-
1925
- def log_decision(session_id: str, domain: str, decision: str,
1926
- alternatives: str = '', based_on: str = '',
1927
- confidence: str = 'medium', context_ref: str = '',
1928
- status: str = 'pending_review',
1929
- review_due_at: str | None = None) -> dict:
1930
- """Log a decision with reasoning context."""
1931
- conn = get_db()
1932
- cleanup_old_decisions()
1933
- try:
1934
- cursor = conn.execute(
1935
- "INSERT INTO decisions "
1936
- "(session_id, domain, decision, alternatives, based_on, confidence, context_ref, status, review_due_at) "
1937
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1938
- (
1939
- session_id, domain, decision, alternatives, based_on,
1940
- confidence, context_ref, status, review_due_at,
1941
- )
1942
- )
1943
- conn.commit()
1944
- did = cursor.lastrowid
1945
- body = f"{decision} {alternatives} {based_on}"
1946
- fts_upsert("decision", str(did), decision[:200], body, domain or '', commit=False)
1947
- row = conn.execute("SELECT * FROM decisions WHERE id = ?", (did,)).fetchone()
1948
- return dict(row)
1949
- except Exception as e:
1950
- return {"error": str(e)}
1951
-
1952
-
1953
- def update_decision_outcome(id: int, outcome: str) -> dict:
1954
- """Record the outcome of a past decision."""
1955
- conn = get_db()
1956
- row = conn.execute("SELECT * FROM decisions WHERE id = ?", (id,)).fetchone()
1957
- if not row:
1958
- return {"error": f"Decision {id} not found"}
1959
- conn.execute(
1960
- "UPDATE decisions "
1961
- "SET outcome = ?, outcome_at = datetime('now'), status = 'reviewed', "
1962
- "review_due_at = NULL, last_reviewed_at = datetime('now') "
1963
- "WHERE id = ?",
1964
- (outcome, id)
1965
- )
1966
- conn.commit()
1967
- row = conn.execute("SELECT * FROM decisions WHERE id = ?", (id,)).fetchone()
1968
- r = dict(row)
1969
- body = f"{r.get('decision','')} {r.get('alternatives','')} {r.get('based_on','')} {r.get('outcome','')}"
1970
- fts_upsert("decision", str(id), r.get("decision","")[:200], body, r.get("domain",""), commit=False)
1971
- return r
1972
-
1973
-
1974
- def get_memory_review_queue(days: int = 7) -> dict:
1975
- """Return learnings and decisions whose review date falls within N days."""
1976
- conn = get_db()
1977
- learning_cutoff = now_epoch() + (days * 86400)
1978
- learnings = conn.execute(
1979
- "SELECT * FROM learnings "
1980
- "WHERE review_due_at IS NOT NULL AND review_due_at <= ? "
1981
- "ORDER BY review_due_at ASC, updated_at DESC",
1982
- (learning_cutoff,)
1983
- ).fetchall()
1984
- decisions = conn.execute(
1985
- "SELECT * FROM decisions "
1986
- "WHERE review_due_at IS NOT NULL AND review_due_at <= datetime('now', ?) "
1987
- "ORDER BY review_due_at ASC, created_at DESC",
1988
- (f"+{days} days",)
1989
- ).fetchall()
1990
- return {
1991
- "learnings": [dict(r) for r in learnings],
1992
- "decisions": [dict(r) for r in decisions],
1993
- }
1994
-
1995
-
1996
- def find_decisions_by_context_ref(ref: str) -> list[dict]:
1997
- """Find decisions linked to a specific context_ref (e.g., followup ID)."""
1998
- conn = get_db()
1999
- rows = conn.execute(
2000
- "SELECT * FROM decisions WHERE context_ref = ? AND (outcome IS NULL OR outcome = '')",
2001
- (ref,)
2002
- ).fetchall()
2003
- return [dict(r) for r in rows]
2004
-
2005
-
2006
- def search_decisions(query: str = '', domain: str = '', days: int = 30) -> list[dict]:
2007
- """Search decisions by text and/or domain within a time window."""
2008
- conn = get_db()
2009
- days = max(1, int(days))
2010
- conditions = []
2011
- params = []
2012
- if query:
2013
- frag, qparams = _multi_word_like(query, ["decision", "alternatives", "based_on", "outcome"])
2014
- conditions.append(f"({frag})")
2015
- params.extend(qparams)
2016
- if domain:
2017
- conditions.append("domain = ?")
2018
- params.append(domain)
2019
- conditions.append("created_at >= datetime('now', ?)")
2020
- params.append(f"-{days} days")
2021
-
2022
- where = " AND ".join(conditions)
2023
- rows = conn.execute(
2024
- f"SELECT * FROM decisions WHERE {where} ORDER BY created_at DESC",
2025
- params
2026
- ).fetchall()
2027
- return [dict(r) for r in rows]
2028
-
2029
-
2030
- # ── Session Diary ────────────────────────────────────────────────
2031
-
2032
- def cleanup_old_diaries(retention_days: int = 180) -> int:
2033
- """Delete session_diary entries older than retention_days. Returns count deleted."""
2034
- conn = get_db()
2035
- ids = [str(r[0]) for r in conn.execute(
2036
- "SELECT id FROM session_diary WHERE created_at < datetime('now', ?)",
2037
- (f"-{retention_days} days",)
2038
- ).fetchall()]
2039
- cursor = conn.execute(
2040
- "DELETE FROM session_diary WHERE created_at < datetime('now', ?)",
2041
- (f"-{retention_days} days",)
2042
- )
2043
- for did in ids:
2044
- conn.execute("DELETE FROM unified_search WHERE source = 'diary' AND source_id = ?", (did,))
2045
- conn.commit()
2046
- return cursor.rowcount
2047
-
2048
-
2049
- def write_session_diary(session_id: str, decisions: str, summary: str,
2050
- discarded: str = '', pending: str = '',
2051
- context_next: str = '', mental_state: str = '',
2052
- domain: str = '', user_signals: str = '',
2053
- self_critique: str = '') -> dict:
2054
- """Write a session diary entry with mental state and self-critique for continuity."""
2055
- conn = get_db()
2056
- cleanup_old_diaries()
2057
- cursor = conn.execute(
2058
- "INSERT INTO session_diary (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique) "
2059
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
2060
- (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique)
2061
- )
2062
- conn.commit()
2063
- did = cursor.lastrowid
2064
- body = f"{summary} {decisions} {pending} {context_next} {mental_state} {self_critique}"
2065
- fts_upsert("diary", str(did), (summary or '')[:200], body, domain or "general", commit=False)
2066
- row = conn.execute("SELECT * FROM session_diary WHERE id = ?", (did,)).fetchone()
2067
- return dict(row)
2068
-
2069
-
2070
- def check_session_has_diary(session_id: str) -> bool:
2071
- """Return True if this session already has a diary entry."""
2072
- conn = get_db()
2073
- row = conn.execute(
2074
- "SELECT id FROM session_diary WHERE session_id = ? LIMIT 1",
2075
- (session_id,)
2076
- ).fetchone()
2077
- return row is not None
2078
-
2079
-
2080
- def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = False,
2081
- domain: str = '') -> list[dict]:
2082
- """Read session diary entries.
2083
-
2084
- - session_id: returns entries for that specific session
2085
- - last_day: returns ALL entries from the most recent day (multi-terminal aware)
2086
- - last_n: returns last N entries (default)
2087
- - domain: filter by project context (e.g., infrastructure, nexo, server, other)
2088
- """
2089
- conn = get_db()
2090
- domain_clause = " AND domain = ?" if domain else ""
2091
- domain_params = (domain,) if domain else ()
2092
-
2093
- if session_id:
2094
- rows = conn.execute(
2095
- f"SELECT * FROM session_diary WHERE session_id = ?{domain_clause} ORDER BY created_at DESC",
2096
- (session_id,) + domain_params
2097
- ).fetchall()
2098
- elif last_day:
2099
- if domain:
2100
- latest = conn.execute(
2101
- "SELECT date(created_at) as day FROM session_diary WHERE domain = ? ORDER BY created_at DESC LIMIT 1",
2102
- (domain,)
2103
- ).fetchone()
2104
- else:
2105
- latest = conn.execute(
2106
- "SELECT date(created_at) as day FROM session_diary ORDER BY created_at DESC LIMIT 1"
2107
- ).fetchone()
2108
- if not latest:
2109
- return []
2110
- rows = conn.execute(
2111
- f"SELECT * FROM session_diary WHERE date(created_at) = ?{domain_clause} ORDER BY created_at DESC",
2112
- (latest['day'],) + domain_params
2113
- ).fetchall()
2114
- else:
2115
- rows = conn.execute(
2116
- f"SELECT * FROM session_diary WHERE 1=1{domain_clause} ORDER BY created_at DESC LIMIT ?",
2117
- domain_params + (last_n,)
2118
- ).fetchall()
2119
- return [dict(r) for r in rows]
2120
-
2121
-
2122
- def _multi_word_like(query: str, columns: list[str]) -> tuple[str, list]:
2123
- """Build AND-ed LIKE conditions: every word must appear in at least one of the columns."""
2124
- words = query.strip().split()
2125
- if not words:
2126
- return "1=1", []
2127
- word_conditions = []
2128
- params = []
2129
- for word in words:
2130
- pattern = f"%{word}%"
2131
- col_or = " OR ".join(f"{c} LIKE ?" for c in columns)
2132
- word_conditions.append(f"({col_or})")
2133
- params.extend([pattern] * len(columns))
2134
- return " AND ".join(word_conditions), params
2135
-
2136
-
2137
- def recall(query: str, days: int = 30) -> list[dict]:
2138
- """Cross-search ALL memory using FTS5: learnings, decisions, changes, diary, followups, entities, .md files."""
2139
- results = fts_search(query, limit=40)
2140
- if results:
2141
- cutoff_epoch = now_epoch() - (days * 86400)
2142
- filtered = []
2143
- for r in results:
2144
- ua = str(r.get('updated_at', ''))
2145
- if not ua:
2146
- filtered.append(r)
2147
- continue
2148
- try:
2149
- if ua[0].isdigit() and ('.' in ua or len(ua) > 12):
2150
- if '-' in ua[:5]:
2151
- dt = datetime.datetime.fromisoformat(ua.replace(' ', 'T'))
2152
- ts = dt.timestamp()
2153
- else:
2154
- ts = float(ua)
2155
- else:
2156
- ts = float(ua)
2157
- if ts >= cutoff_epoch:
2158
- filtered.append(r)
2159
- except (ValueError, TypeError):
2160
- filtered.append(r)
2161
- if filtered:
2162
- return filtered[:20]
2163
-
2164
- days = max(1, int(days))
2165
- conn = get_db()
2166
- cutoff_dt = datetime.datetime.now() - datetime.timedelta(days=days)
2167
- cutoff_str = cutoff_dt.strftime("%Y-%m-%d")
2168
- cutoff_epoch = now_epoch() - (days * 86400)
2169
-
2170
- results = []
2171
-
2172
- frag, params = _multi_word_like(query, ["files", "what_changed", "why", "triggered_by", "affects", "risks"])
2173
- rows = conn.execute(f"""
2174
- SELECT id, created_at, 'change' AS source,
2175
- files AS title,
2176
- (what_changed || ' | ' || why) AS snippet, 'change_log' AS category, 0 AS rank
2177
- FROM change_log
2178
- WHERE created_at >= ? AND ({frag})
2179
- ORDER BY created_at DESC LIMIT 20
2180
- """, [cutoff_str] + params).fetchall()
2181
- results.extend([dict(r) for r in rows])
2182
-
2183
- frag, params = _multi_word_like(query, ["decision", "alternatives", "based_on", "outcome"])
2184
- rows = conn.execute(f"""
2185
- SELECT id, created_at, 'decision' AS source,
2186
- decision AS title,
2187
- (COALESCE(based_on,'') || ' | ' || COALESCE(alternatives,'')) AS snippet, domain AS category, 0 AS rank
2188
- FROM decisions
2189
- WHERE created_at >= ? AND ({frag})
2190
- ORDER BY created_at DESC LIMIT 20
2191
- """, [cutoff_str] + params).fetchall()
2192
- results.extend([dict(r) for r in rows])
2193
-
2194
- frag, params = _multi_word_like(query, ["title", "content", "reasoning"])
2195
- rows = conn.execute(f"""
2196
- SELECT id, datetime(created_at, 'unixepoch') AS created_at, 'learning' AS source,
2197
- title,
2198
- (COALESCE(content,'') || ' | ' || COALESCE(reasoning,'')) AS snippet, category, 0 AS rank
2199
- FROM learnings
2200
- WHERE created_at >= ? AND ({frag})
2201
- ORDER BY created_at DESC LIMIT 20
2202
- """, [cutoff_epoch] + params).fetchall()
2203
- results.extend([dict(r) for r in rows])
2204
-
2205
- frag, params = _multi_word_like(query, ["id", "description", "verification", "reasoning"])
2206
- rows = conn.execute(f"""
2207
- SELECT id, datetime(created_at, 'unixepoch') AS created_at, 'followup' AS source,
2208
- id AS title,
2209
- (COALESCE(description,'') || ' | ' || COALESCE(verification,'') || ' | ' || COALESCE(reasoning,'')) AS snippet,
2210
- 'followup' AS category, 0 AS rank
2211
- FROM followups
2212
- WHERE created_at >= ? AND ({frag})
2213
- ORDER BY created_at DESC LIMIT 20
2214
- """, [cutoff_epoch] + params).fetchall()
2215
- results.extend([dict(r) for r in rows])
2216
-
2217
- frag, params = _multi_word_like(query, ["decisions", "discarded", "pending", "context_next", "mental_state", "summary"])
2218
- rows = conn.execute(f"""
2219
- SELECT id, created_at, 'diary' AS source,
2220
- summary AS title,
2221
- (COALESCE(decisions,'') || ' | ' || COALESCE(pending,'') || ' | ' || COALESCE(context_next,'')) AS snippet,
2222
- COALESCE(domain, 'general') AS category, 0 AS rank
2223
- FROM session_diary
2224
- WHERE created_at >= ? AND ({frag})
2225
- ORDER BY created_at DESC LIMIT 20
2226
- """, [cutoff_str] + params).fetchall()
2227
- results.extend([dict(r) for r in rows])
2228
-
2229
- results.sort(key=lambda r: r.get('created_at', ''), reverse=True)
2230
- return results[:20]
2231
-
2232
-
2233
- # ── Evolution helpers ─────────────────────────────────────────────────────
2234
-
2235
- def insert_evolution_metric(dimension: str, score: int, evidence: str, delta: int = 0):
2236
- conn = get_db()
2237
- conn.execute(
2238
- "INSERT INTO evolution_metrics (dimension, score, evidence, delta) VALUES (?, ?, ?, ?)",
2239
- (dimension, score, evidence, delta)
2240
- )
2241
-
2242
-
2243
- def get_latest_metrics() -> dict:
2244
- conn = get_db()
2245
- rows = conn.execute(
2246
- "SELECT dimension, score, delta, measured_at FROM evolution_metrics "
2247
- "WHERE id IN (SELECT MAX(id) FROM evolution_metrics GROUP BY dimension)"
2248
- ).fetchall()
2249
- return {r["dimension"]: dict(r) for r in rows}
2250
-
2251
-
2252
- def insert_evolution_log(cycle_number: int, dimension: str, proposal: str,
2253
- classification: str, reasoning: str, **kwargs) -> int:
2254
- conn = get_db()
2255
- cur = conn.execute(
2256
- "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, "
2257
- "files_changed, snapshot_ref, test_result, status) "
2258
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
2259
- (cycle_number, dimension, proposal, classification, reasoning,
2260
- kwargs.get("files_changed"), kwargs.get("snapshot_ref"),
2261
- kwargs.get("test_result"), kwargs.get("status", "pending"))
2262
- )
2263
- return cur.lastrowid
2264
-
2265
-
2266
- def get_evolution_history(limit: int = 20) -> list:
2267
- conn = get_db()
2268
- rows = conn.execute(
2269
- "SELECT * FROM evolution_log ORDER BY id DESC LIMIT ?", (limit,)
2270
- ).fetchall()
2271
- return [dict(r) for r in rows]
2272
-
2273
-
2274
- def update_evolution_log_status(log_id: int, status: str, **kwargs):
2275
- conn = get_db()
2276
- sets = ["status = ?"]
2277
- vals = [status]
2278
- for k in ("test_result", "impact", "files_changed", "snapshot_ref"):
2279
- if k in kwargs:
2280
- sets.append(f"{k} = ?")
2281
- vals.append(kwargs[k])
2282
- vals.append(log_id)
2283
- conn.execute(f"UPDATE evolution_log SET {', '.join(sets)} WHERE id = ?", vals)