"""SQLite vault storage, sessions, FTS, and migrations.""" import json import os import re import sqlite3 import uuid from datetime import datetime, timezone from tai_pkg.consts import Consts from tai_pkg.release import Release from tai_pkg.seal import Seal from tai_pkg.seal import SealError from tai_pkg.text import Text __all__ = ["Store"] class Store: def __init__(self, config, seal): self.config = config self.seal = seal self.db = sqlite3.connect(config.db_path, timeout=30) self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA synchronous=NORMAL") self.db.execute("PRAGMA busy_timeout=30000") self.schema_notes = Store.ensure_schema(self.db) Store.migrate_profiles(self.db) self.db.commit() self.profile = config.profile self.secret_cache = {} self.fts_ok = Store.setup_fts(self.db) if seal.enabled: self.db.create_function("tai_enc", 1, seal.lock) self.db.create_function("tai_dec", 1, seal.safe_unlock) self.db.execute("UPDATE events SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") self.db.execute("UPDATE secrets SET value = tai_enc(value) WHERE value NOT LIKE 'tai1$%'") self.db.execute("UPDATE secrets SET meta = tai_enc(COALESCE(meta, '{}')) WHERE meta IS NULL OR meta NOT LIKE 'tai1$%'") self.db.execute("UPDATE schedules SET prompt = tai_enc(prompt) WHERE prompt NOT LIKE 'tai1$%'") self.db.commit() self.seal_files() else: sealed = self.db.execute("SELECT COUNT(*) FROM events WHERE text LIKE 'tai1$%'").fetchone()[0] if sealed: raise SealError("memory is sealed, set TAI_PASSPHRASE") locked = self.db.execute("SELECT COUNT(*) FROM secrets WHERE value LIKE 'tai1$%' OR meta LIKE 'tai1$%'").fetchone()[0] if locked: raise SealError("secrets are sealed, set TAI_PASSPHRASE") planned = self.db.execute("SELECT COUNT(*) FROM schedules WHERE prompt LIKE 'tai1$%'").fetchone()[0] if planned: raise SealError("schedules are sealed, set TAI_PASSPHRASE") self.memdb = None self.mem_events_max = 0 if seal.enabled and self.fts_ok: try: self.memdb = Store.open_mem_events() floor = self.db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0] - Consts.MEM_EVENTS_CAP self.mem_events_max = max(0, floor) self._sync_mem_events() except sqlite3.Error: self.memdb = None def _sync_mem_events(self): if self.memdb is None: return try: rows = self.db.execute("SELECT id, profile, ts, role, kind, tai_dec(text) FROM events WHERE id > ? ORDER BY id ASC", (self.mem_events_max,)).fetchall() except sqlite3.Error: return if not rows: return try: self.memdb.executemany("INSERT INTO mem_events(item, kind, sub, title, body, profile) VALUES (?, 'event', ? || '/' || ?, ? || ' ' || ? || '/' || ?, ?, ?)", [(row[0], row[3], row[4], row[2], row[3], row[4], row[5], row[1]) for row in rows]) self.memdb.commit() except sqlite3.Error: return self.mem_events_max = rows[-1][0] try: self.memdb.execute("DELETE FROM mem_events WHERE CAST(item AS INTEGER) <= ?", (self.mem_events_max - Consts.MEM_EVENTS_CAP,)) self.memdb.commit() except sqlite3.Error: pass def seal_files(self): for entry in os.listdir(self.config.profiles_dir): if not entry.endswith((".sys.md", ".session.json", ".bots.json")): continue path = os.path.join(self.config.profiles_dir, entry) try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() except OSError: continue if raw.startswith(Seal.PREFIX): continue try: with open(path, "w", encoding="utf-8") as handle: handle.write(self.seal.lock(raw)) except OSError: pass def log_event(self, profile, role, kind, text, tags=()): value = (text or "")[:2000] stamped = " " + " ".join([role, kind] + Text.normalize_tags(tags)) + " " try: if self.seal.enabled: self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, tai_enc(?), ?)", (profile, Text.now_iso(), role, kind, value, stamped)) else: self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, ?, ?)", (profile, Text.now_iso(), role, kind, value, stamped)) self.db.commit() self._sync_mem_events() except sqlite3.Error: pass def fts_search(self, query, kinds=(), profile=None, limit=10): profile = self._scope(profile) if not self.fts_ok: return [] terms = Store.fts_escape(query) if terms is None: return [] sql = "SELECT item, kind, sub, title, snippet(fts_docs, 4, '[', ']', '...', 12), bm25(fts_docs) FROM fts_docs WHERE fts_docs MATCH ? AND profile = ?" params = [Store.fts_match(terms), profile] if kinds: marks = ", ".join("?" * len(kinds)) sql += " AND kind IN (%s)" % marks params += list(kinds) if self.seal.enabled: sql += " AND kind != 'event'" sql += " ORDER BY bm25(fts_docs) LIMIT ?" capped = max(1, min(limit, 100)) params.append(capped) try: rows = self.db.execute(sql, tuple(params)).fetchall() except sqlite3.Error: return [] hits = [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in rows] if self.seal.enabled and self.memdb is not None and (not kinds or "event" in kinds): self._sync_mem_events() try: mem_rows = self.memdb.execute("SELECT item, kind, sub, title, snippet(mem_events, 4, '[', ']', '...', 12), bm25(mem_events) FROM mem_events WHERE mem_events MATCH ? AND profile = ? ORDER BY bm25(mem_events) LIMIT ?", (Store.fts_match(terms), profile, capped)).fetchall() except sqlite3.Error: mem_rows = [] hits += [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in mem_rows] hits.sort(key=lambda hit: hit["rank"]) hits = hits[:capped] return hits def audit_search(self, query, profile=None, limit=20): profile = self._scope(profile) hits = self.fts_search(query, ("audit",), profile, limit) found = [] for hit in hits: try: row_id = int(str(hit["item"]).split(":", 1)[1]) except (IndexError, ValueError): continue row = self.audit_get(row_id, profile) if row is not None: row["snippet"] = hit["snippet"] found.append(row) return found def search_events(self, profile, query, limit=8, tags=()): clean = Text.normalize_tags(tags) if self.fts_ok: hits = self.fts_search(query, ("event",), profile, max(limit * 5, 20)) if hits: ids = [int(hit["item"]) for hit in hits] marks = ", ".join("?" * len(ids)) text_col = "tai_dec(text)" if self.seal.enabled else "text" rows = {row[0]: row[1:] for row in self.db.execute("SELECT id, ts, role, kind, %s, tags FROM events WHERE id IN (%s)" % (text_col, marks), tuple(ids)).fetchall()} found = [] for hit in hits: row = rows.get(int(hit["item"])) if row is None: continue if clean and not all((" %s " % tag) in (" %s " % (row[4] or "")) for tag in clean): continue found.append(row[:4]) if len(found) >= limit: break if found: return found return self.events_like(profile, query, limit, clean) def events_like(self, profile, query, limit=8, tags=()): clean = Text.normalize_tags(tags) extra = "".join(" AND tags LIKE ?" for _tag in clean) wild = tuple("% %s %%" % tag for tag in clean) if self.seal.enabled: return self.db.execute("SELECT ts, role, kind, text FROM (SELECT id, ts, role, kind, tags, tai_dec(text) AS text FROM events WHERE profile = ? ORDER BY id DESC LIMIT 5000) WHERE text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() return self.db.execute("SELECT ts, role, kind, text FROM events WHERE profile = ? AND text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() def _scope(self, profile): return profile or self.profile def save_secret(self, name, value, meta=None, tags=None, profile=None): profile = self._scope(profile) self.secret_cache.pop(profile, None) stored = self.seal.lock(value) if self.seal.enabled else value blob = json.dumps(meta or {}) locked_meta = self.seal.lock(blob) if self.seal.enabled else blob self.db.execute("INSERT OR REPLACE INTO secrets (profile, name, value, meta, updated) VALUES (?, ?, ?, ?, ?)", (profile, name, stored, locked_meta, Text.now_iso())) if tags is None: self.tag_item("secret:" + name, ["secret"], profile) else: self.set_item_tags("secret:" + name, ["secret"] + list(tags), profile) self.db.commit() def secret_meta(self, name, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT meta FROM secrets WHERE profile = ? AND name = ?", (profile, name)).fetchone() if row is None or not row[0]: return {} raw = self.seal.safe_unlock(row[0]) if row[0].startswith(Seal.PREFIX) else row[0] try: data = json.loads(raw) except ValueError: return {} return data if isinstance(data, dict) else {} def list_secret_infos(self, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT name, meta, updated FROM secrets WHERE profile = ? ORDER BY name", (profile,)).fetchall() items = [] for name, meta, updated in rows: raw = None if meta: raw = self.seal.safe_unlock(meta) if meta.startswith(Seal.PREFIX) else meta try: data = json.loads(raw or "{}") except ValueError: data = {} items.append({"name": name, "meta": data if isinstance(data, dict) else {}, "tags": self.item_tags("secret:" + name, profile), "updated": updated}) return items def known_tags(self, profile=None): profile = self._scope(profile) return {row[0] for row in self.db.execute("SELECT DISTINCT tag FROM tags WHERE profile = ?", (profile,)).fetchall()} def canonical_tag(self, tag, vocab=None, profile=None): if vocab is None: vocab = self.known_tags(profile) if tag in vocab: return tag single = Text.singular_noun(tag) if single != tag and single in vocab: return single return tag def canonical_tags(self, tags, vocab=None, profile=None): profile = self._scope(profile) if vocab is None: vocab = self.known_tags(profile) seen = [] for tag in Text.normalize_tags(tags): clean = self.canonical_tag(tag, vocab) if clean not in seen: seen.append(clean) return seen def tag_counts(self, prefix="", limit=50, profile=None): profile = self._scope(profile) clean = Text.normalize_tag(prefix) sql = "SELECT tag, COUNT(*) FROM tags WHERE profile = ?" params = [profile] if clean: sql += " AND tag LIKE ? ESCAPE '\\'" params.append(clean.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%") sql += " GROUP BY tag ORDER BY COUNT(*) DESC, tag LIMIT ?" params.append(max(1, min(limit, 200))) return [(row[0], row[1]) for row in self.db.execute(sql, tuple(params)).fetchall()] def auto_tags_for(self, text, explicit=(), vocab=None): if vocab is None: vocab = self.known_tags() skip = set(explicit) | set(Consts.TAG_AUTO_SKIP) words = re.findall(r"[a-z0-9]+", (text or "").lower()) found = [] for pos, word in enumerate(words): if len(found) >= Consts.TAG_AUTO_MAX: break if pos + 1 < len(words): pair = word + "-" + words[pos + 1] if pair in vocab and pair not in skip and pair not in found: found.append(pair) continue if word in vocab and word not in skip and word not in found: found.append(word) continue single = Text.singular_noun(word) if single != word and single in vocab and single not in skip and single not in found: found.append(single) return found def tag_neighbors(self, record_id, limit=None, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT r.id, MIN(t.tag) FROM records r JOIN tags t ON t.item = r.id WHERE t.tag IN (SELECT tag FROM tags WHERE item = ? AND profile = ? AND tag NOT IN ('record', 'file')) AND r.id != ? AND r.profile = ? AND t.profile = ? GROUP BY r.id ORDER BY r.updated DESC LIMIT ?", (record_id, profile, record_id, profile, profile, max(1, limit or Consts.TAG_LINK_MAX))).fetchall() return [(row[0], row[1]) for row in rows] def tag_item(self, item, tags, profile=None): profile = self._scope(profile) for tag in self.canonical_tags(tags, None, profile): self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) self.db.commit() def set_item_tags(self, item, tags, profile=None): profile = self._scope(profile) self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) for tag in self.canonical_tags(tags, None, profile): self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) self.db.commit() def item_tags(self, item, profile=None): profile = self._scope(profile) return [row[0] for row in self.db.execute("SELECT tag FROM tags WHERE item = ? AND profile = ? ORDER BY tag", (item, profile)).fetchall()] def tagged_items(self, tags, profile=None): profile = self._scope(profile) clean = Text.normalize_tags(tags) if not clean: return None marks = ", ".join("?" * len(clean)) rows = self.db.execute("SELECT item FROM tags WHERE profile = ? AND tag IN (%s) GROUP BY item HAVING COUNT(DISTINCT tag) = ?" % marks, (profile,) + tuple(clean) + (len(clean),)).fetchall() return [row[0] for row in rows] def has_node(self, kind, key, profile=None): profile = self._scope(profile) if kind == "mem": row = self.db.execute("SELECT 1 FROM records WHERE profile = ? AND id = ?", (profile, "mem:" + key,)).fetchone() elif kind == "secret": row = self.db.execute("SELECT 1 FROM secrets WHERE profile = ? AND name = ?", (profile, key,)).fetchone() else: row = self.db.execute("SELECT 1 FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() return row is not None def node_title(self, node, profile=None): profile = self._scope(profile) parsed = Text.parse_node_id(node) if parsed is None: return node kind, key = parsed if kind == "mem": row = self.db.execute("SELECT title FROM records WHERE profile = ? AND id = ?", (profile, node,)).fetchone() return row[0] or node if row else node if kind == "secret": return "secret:" + key row = self.db.execute("SELECT name, prompt FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() if not row: return node return row[0] or "schedule #%s" % key def add_record(self, kind, title, content, tags=(), profile=None): profile = self._scope(profile) record_id = "mem:" + uuid.uuid4().hex[:16] while self.db.execute("SELECT 1 FROM records WHERE id = ?", (record_id,)).fetchone(): record_id = "mem:" + uuid.uuid4().hex[:16] stamp = datetime.now(timezone.utc).isoformat() size = len(content.encode("utf-8")) self.db.execute("INSERT INTO records(id, kind, profile, title, content, size, reads, created, updated) VALUES(?, ?, ?, ?, ?, ?, 0, ?, ?)", (record_id, kind, profile, title, content, size, stamp, stamp)) vocab = self.known_tags(profile) explicit = self.canonical_tags(tags, vocab, profile) scan = "%s\n%s" % (title, content.split("\n...[truncated", 1)[0]) auto = self.auto_tags_for(scan, explicit, vocab) self.set_item_tags(record_id, ["record"] + explicit + auto, profile) for other, shared in self.tag_neighbors(record_id, None, profile): self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (record_id, other, "shares-" + shared, profile, stamp)) self.db.commit() return record_id def add_chunks(self, parent_kind, parent_id, texts, profile=None): """Replaces any prior chunks for this parent, so re-indexing is idempotent.""" profile = self._scope(profile) self.db.execute("DELETE FROM chunks WHERE parent_kind = ? AND parent_id = ? AND profile = ?", (parent_kind, parent_id, profile)) stamp = datetime.now(timezone.utc).isoformat() for idx, text in enumerate(texts): self.db.execute("INSERT INTO chunks(parent_kind, parent_id, idx, text, profile, created) VALUES (?, ?, ?, ?, ?, ?)", (parent_kind, parent_id, idx, text, profile, stamp)) self.db.commit() def get_chunks_by_ids(self, ids, profile=None): if not ids: return {} profile = self._scope(profile) marks = ", ".join("?" * len(ids)) rows = self.db.execute("SELECT id, parent_kind, parent_id, idx, text FROM chunks WHERE profile = ? AND id IN (%s)" % marks, tuple([profile] + list(ids))).fetchall() return {row[0]: {"parent_kind": row[1], "parent_id": row[2], "idx": row[3], "text": row[4]} for row in rows} def get_record(self, record_id, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id, kind, title, content, size, reads, created, updated FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() if row is None: return None self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) self.db.commit() return {"id": row[0], "kind": row[1], "title": row[2], "content": row[3], "size": Text.as_int(row[4]), "reads": Text.as_int(row[5]) + 1, "created": row[6], "updated": row[7], "tags": self.item_tags(row[0], profile)} def read_record(self, record_id, offset=0, limit=4000, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT content FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() if row is None: return None content = row[0] total = len(content) start = max(0, min(offset, total)) end = min(total, start + max(1, limit)) self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) self.db.commit() return {"id": record_id, "slice": content[start:end], "start": start, "end": end, "total": total, "tags": self.item_tags(record_id, profile)} def search_records(self, query="", kind=None, tags=(), limit=8, profile=None): profile = self._scope(profile) clean = Text.normalize_tags(tags) terms = Store.fts_escape(query) if query else None if terms is not None and self.fts_ok: ranked = self.search_records_fts(terms, kind, clean, limit, profile) if ranked: return ranked sql = "SELECT id, kind, title, size, reads, updated FROM records" clauses = ["profile = ?"] params = [profile] if kind: clauses.append("kind = ?") params.append(kind) if query: clauses.append("(title LIKE ? OR content LIKE ?)") params += ["%" + query + "%", "%" + query + "%"] if clean: for tag in clean: options = Text.tag_variants(tag) marks = ", ".join("?" * len(options)) clauses.append("id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks) params += [profile] + options sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY updated DESC LIMIT ?" params.append(max(1, min(limit, 50))) rows = self.db.execute(sql, tuple(params)).fetchall() return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] def search_records_fts(self, terms, kind, clean, limit, profile): sql = "SELECT r.id, r.kind, r.title, r.size, r.reads, r.updated FROM records r JOIN fts_docs f ON f.item = r.id AND f.kind = 'record' WHERE r.profile = ? AND f MATCH ?" params = [profile, Store.fts_match(terms)] if kind: sql += " AND r.kind = ?" params.append(kind) for tag in clean: options = Text.tag_variants(tag) marks = ", ".join("?" * len(options)) sql += " AND r.id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks params += [profile] + options sql += " ORDER BY bm25(f) LIMIT ?" params.append(max(1, min(limit, 50))) try: rows = self.db.execute(sql, tuple(params)).fetchall() except sqlite3.Error: return [] return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] def delete_record(self, record_id, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM records WHERE profile = ? AND id = ?", (profile, record_id,)) if done.rowcount > 0: self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (record_id, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, record_id, record_id)) self.db.execute("DELETE FROM chunks WHERE parent_kind = 'record' AND parent_id = ? AND profile = ?", (record_id, profile)) self.db.commit() return True self.db.commit() return False def add_edge(self, src, dst, relation="linked", profile=None): profile = self._scope(profile) for node in (src, dst): parsed = Text.parse_node_id(node) if parsed is None or not self.has_node(*parsed, profile): raise ValueError("unknown node " + node) clean = re.sub(r"[^a-z0-9]+", "-", str(relation or "").lower()).strip("-")[:32] or "linked" self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (src, dst, clean, profile, datetime.now(timezone.utc).isoformat())) self.db.commit() return clean def edges_for(self, node, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT src, dst, relation FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)).fetchall() return [{"relation": row[2], "other": row[1] if row[0] == node else row[0], "direction": "out" if row[0] == node else "in"} for row in rows] def remove_edges(self, node, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)) self.db.commit() return done.rowcount def traverse(self, start, depth=2, limit=50, profile=None): profile = self._scope(profile) seen = {start} found = [{"node": start, "title": self.node_title(start, profile), "depth": 0, "via": ""}] frontier = [(start, 0, "")] capped = max(1, min(depth, 4)) while frontier and len(found) < max(1, min(limit, 200)): node, level, _via = frontier.pop(0) if level >= capped: continue for edge in self.edges_for(node, profile): other = edge["other"] if other in seen: continue seen.add(other) hop = "%s -[%s]-> %s" % (node, edge["relation"], other) if edge["direction"] == "out" else "%s <-[%s]- %s" % (node, edge["relation"], other) found.append({"node": other, "title": self.node_title(other, profile), "depth": level + 1, "via": hop}) frontier.append((other, level + 1, hop)) if len(found) >= max(1, min(limit, 200)): break return found def audit_event(self, actor, action, path, message="", old=None, new=None, tags=(), old_size=None, new_size=None, profile=None): profile = self._scope(profile) old_text, computed_old = Text.capped_text(old) new_text, computed_new = Text.capped_text(new) if old is not None and old_size is not None and old_size > len(old.encode("utf-8")): old_text = old[:Consts.AUDIT_MAX_CHARS] + Text.trunc_marker(old_size) computed_old = old_size if new is not None and new_size is not None and new_size > len(new.encode("utf-8")): new_text = new[:Consts.AUDIT_MAX_CHARS] + Text.trunc_marker(new_size) computed_new = new_size cursor = self.db.execute("INSERT INTO audit(profile, ts, actor, action, path, message, old_size, new_size, old, new, tags) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (profile, datetime.now(timezone.utc).isoformat(), actor, action, path, str(message or "")[:2000], computed_old, computed_new, old_text, new_text, " ".join(Text.normalize_tags(tags)))) self.db.commit() return cursor.lastrowid def audit_history(self, path=None, tag=None, limit=20, profile=None): profile = self._scope(profile) sql = "SELECT id, ts, actor, action, path, message, old_size, new_size, tags FROM audit" clauses = ["profile = ?"] params = [profile] if path: clauses.append("path = ?") params.append(path) if tag: clauses.append("tags LIKE ?") params.append("%" + tag + "%") sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY id DESC LIMIT ?" params.append(max(1, min(limit, 100))) rows = self.db.execute(sql, tuple(params)).fetchall() return [{"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "tags": row[8]} for row in rows] def audit_get(self, row_id, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id, ts, actor, action, path, message, old_size, new_size, old, new, tags FROM audit WHERE profile = ? AND id = ?", (profile, row_id,)).fetchone() if row is None: return None return {"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "old": row[8], "new": row[9], "tags": row[10]} def file_record_id(self, path, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id FROM records WHERE profile = ? AND kind = 'file' AND title = ?", (profile, path,)).fetchone() return row[0] if row else None def upsert_file_record(self, path, content, true_size=None, profile=None): profile = self._scope(profile) full_bytes = len(content.encode("utf-8")) if true_size is not None and true_size > full_bytes: text = content + Text.trunc_marker(true_size) elif len(content) <= Consts.FILE_RECORD_MAX: text = content else: text = content[:Consts.FILE_RECORD_MAX] + Text.trunc_marker(full_bytes) ext = os.path.splitext(path)[1].lstrip(".").lower() explicit = ["file"] + ([ext] if ext and ext.isalnum() and len(ext) <= 5 else []) vocab = self.known_tags(profile) tags = explicit + self.auto_tags_for("%s\n%s" % (path, content), explicit, vocab) record_id = self.file_record_id(path, profile) stamp = datetime.now(timezone.utc).isoformat() if record_id is None: return self.add_record("file", path, text, tags, profile) self.db.execute("UPDATE records SET content = ?, size = ?, updated = ? WHERE id = ?", (text, len(text.encode("utf-8")), stamp, record_id)) self.set_item_tags(record_id, tags, profile) self.db.commit() return record_id def delete_file_record(self, path, profile=None): profile = self._scope(profile) record_id = self.file_record_id(path, profile) if record_id is None: return False return self.delete_record(record_id, profile) def load_secret(self, name, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT value FROM secrets WHERE profile = ? AND name = ?", (profile, name,)).fetchone() if row is None: return None if row[0].startswith(Seal.PREFIX): return self.seal.unlock(row[0]) if self.seal.enabled else None return row[0] def delete_secret(self, name, profile=None): profile = self._scope(profile) self.secret_cache.pop(profile, None) done = self.db.execute("DELETE FROM secrets WHERE profile = ? AND name = ?", (profile, name,)) if done.rowcount > 0: item = "secret:" + name self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) self.db.commit() return True self.db.commit() return False def secret_values(self, profile=None): profile = self._scope(profile) if profile not in self.secret_cache: found = {} for name, value in self.db.execute("SELECT name, value FROM secrets WHERE profile = ?", (profile,)).fetchall(): if value.startswith(Seal.PREFIX): if not self.seal.enabled: continue value = self.seal.unlock(value) if len(value) >= 4: found[name] = value self.secret_cache[profile] = found return self.secret_cache[profile] def redact(self, text, profile=None): if not isinstance(text, str): return text for name, value in sorted(self.secret_values(profile).items(), key=lambda item: -len(item[1])): if value in text: text = text.replace(value, "[redacted:%s]" % name) return text def add_schedule(self, name, prompt, profile, every_sec, next_run, timeout, tags=None): stored = self.seal.lock(prompt) if self.seal.enabled else prompt done = self.db.execute("INSERT INTO schedules (name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created, updated) VALUES (?, ?, ?, ?, ?, ?, 'pending', '', '', ?, ?)", (name, stored, profile, every_sec, next_run, timeout, Text.now_iso(), Text.now_iso())) item = "sched:%s" % done.lastrowid if tags is None: self.tag_item(item, ["schedule"], profile) else: self.set_item_tags(item, ["schedule"] + list(tags), profile) self.db.commit() return done.lastrowid def remove_schedule(self, row_id, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM schedules WHERE id = ? AND profile = ?", (row_id, profile)) if done.rowcount > 0: item = "sched:%s" % row_id self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) self.db.commit() return True self.db.commit() return False def list_schedules(self, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT id, name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created FROM schedules WHERE profile = ? ORDER BY next_run", (profile,)).fetchall() items = [] for row in rows: prompt = self.seal.safe_unlock(row[2]) if row[2].startswith(Seal.PREFIX) else row[2] items.append({"id": row[0], "name": row[1], "prompt": prompt, "profile": row[3], "every": row[4], "next_run": row[5], "timeout": row[6], "status": row[7], "last": row[8], "result": row[9], "created": row[10], "tags": self.item_tags("sched:%s" % row[0], row[3])}) return items def profile_path(self, name): return os.path.join(self.config.profiles_dir, name + ".sys.md") def session_path(self, name): return os.path.join(self.config.profiles_dir, name + ".session.json") def list_profiles(self): names = [] for entry in sorted(os.listdir(self.config.profiles_dir)): if entry.endswith(".sys.md") and ".bot." not in entry: names.append(entry[:-7]) return names def unseal_file(self, raw): if raw.startswith(Seal.PREFIX): if not self.seal.enabled: raise SealError("profile is sealed, set TAI_PASSPHRASE") return self.seal.unlock(raw) return raw def load_system(self, name): path = self.profile_path(name) if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as handle: return self.unseal_file(handle.read()) except (OSError, ValueError): Release.quarantine(path) return None except SealError: if not self.seal.enabled: raise Release.quarantine(path) return None def save_system(self, name, text): try: Release.sealed_write(self.profile_path(name), self.seal.lock(text)) except OSError: pass def load_session(self, name, bot="main"): path = self.session_path(name) if bot == "main" else self.bot_session_path(name, bot) if not os.path.exists(path): return [] try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() data = json.loads(self.unseal_file(raw)) return [item for item in data if isinstance(item, dict) and item.get("role") in ("user", "assistant", "tool")] except (OSError, ValueError): Release.quarantine(path) return [] except SealError: if not self.seal.enabled: raise Release.quarantine(path) return [] def save_session(self, name, messages, bot="main"): path = self.session_path(name) if bot == "main" else self.bot_session_path(name, bot) try: Release.sealed_write(path, self.seal.lock(json.dumps([item for item in messages if item.get("role") != "system"]))) except OSError: pass def turn_state_path(self, profile, bot="main"): if bot == "main": return os.path.join(self.config.profiles_dir, "%s.turn.json" % profile) return os.path.join(self.config.profiles_dir, "%s.bot.%s.turn.json" % (profile, bot)) def load_turn_state(self, profile, bot="main"): path = self.turn_state_path(profile, bot) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.loads(self.unseal_file(handle.read())) return data if isinstance(data, dict) else {} except (OSError, ValueError): Release.quarantine(path) return {} except SealError: if not self.seal.enabled: raise Release.quarantine(path) return {} def save_turn_state(self, profile, bot, state): try: Release.sealed_write(self.turn_state_path(profile, bot), self.seal.lock(json.dumps(state))) except OSError: pass def bot_system_path(self, profile, bot): return os.path.join(self.config.profiles_dir, "%s.bot.%s.sys.md" % (profile, bot)) def bot_session_path(self, profile, bot): return os.path.join(self.config.profiles_dir, "%s.bot.%s.session.json" % (profile, bot)) def bots_registry_path(self, profile): return os.path.join(self.config.profiles_dir, "%s.bots.json" % profile) def load_bots(self, profile): path = self.bots_registry_path(profile) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.loads(self.unseal_file(handle.read())) bots = data.get("bots") if isinstance(data, dict) else None return bots if isinstance(bots, dict) else {} except (OSError, ValueError): Release.quarantine(path) return {} except SealError: if not self.seal.enabled: raise Release.quarantine(path) return {} def save_bots(self, profile, bots): try: Release.sealed_write(self.bots_registry_path(profile), self.seal.lock(json.dumps({"bots": bots}))) except OSError: pass def list_bots(self, profile): bots = self.load_bots(profile) found = [{"name": "main", "nicknames": []}] for name in sorted(bots): found.append({"name": name, "nicknames": (bots.get(name) or {}).get("nicknames", [])}) return found def resolve_bot(self, profile, mention): want = str(mention or "").strip().lower() if want in ("", "main"): return "main" bots = self.load_bots(profile) if want in bots: return want for name in sorted(bots): if want in (bots.get(name) or {}).get("nicknames", []): return name return None def load_bot_system(self, profile, bot): if bot == "main": return self.load_system(profile) path = self.bot_system_path(profile, bot) if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as handle: return self.unseal_file(handle.read()) except (OSError, ValueError): Release.quarantine(path) return None except SealError: if not self.seal.enabled: raise Release.quarantine(path) return None def save_bot_system(self, profile, bot, text): try: Release.sealed_write(self.bot_system_path(profile, bot), self.seal.lock(text)) except OSError: pass def close(self): try: self.db.execute("PRAGMA wal_checkpoint(TRUNCATE)") except sqlite3.Error: pass try: self.db.close() except sqlite3.Error: pass if getattr(self, "memdb", None) is not None: try: self.memdb.close() except sqlite3.Error: pass self.memdb = None @staticmethod def ensure_column(db, table, column, ddl): names = [row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()] if column not in names: db.execute("ALTER TABLE %s ADD COLUMN %s %s" % (table, column, ddl)) @staticmethod def fts_escape(query): terms = re.findall(r"[a-z0-9]+", (query or "").lower()) if not terms: return None return ['"%s"' % term for term in terms] @staticmethod def fts_match(terms): return " AND ".join("{title body} : %s" % term for term in terms) @staticmethod def open_mem_events(): mem = sqlite3.connect(":memory:") try: mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") except sqlite3.Error: mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile)") return mem @staticmethod def setup_fts(db): try: exists = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'fts_docs'").fetchone() if not exists: try: db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") except sqlite3.Error: db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile)") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ai AFTER INSERT ON records BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_au AFTER UPDATE ON records WHEN old.title != new.title OR old.content != new.content OR old.profile != new.profile BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ad AFTER DELETE ON records BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_ai AFTER INSERT ON events BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_au AFTER UPDATE ON events WHEN old.text != new.text OR old.profile != new.profile BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'event'; INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_audit_ai AFTER INSERT ON audit BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('audit:' || new.id, 'audit', new.action, new.path, new.message, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_chunks_ai AFTER INSERT ON chunks BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('chunk:' || new.id, 'chunk', new.parent_kind, new.parent_id, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_chunks_ad AFTER DELETE ON chunks BEGIN DELETE FROM fts_docs WHERE item = 'chunk:' || old.id AND kind = 'chunk'; END""") if db.execute("SELECT COUNT(*) FROM fts_docs").fetchone()[0] == 0: db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'record', kind, title, content, profile FROM records") db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'event', role || '/' || kind, ts || ' ' || role || '/' || kind, text, profile FROM events") db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT 'audit:' || id, 'audit', action, path, message, profile FROM audit") db.commit() return True except sqlite3.Error: return False @staticmethod def table_pk_columns(db, table): for row in db.execute("PRAGMA index_list(%s)" % table).fetchall(): if row[3] == "pk": return [info[2] for info in db.execute("PRAGMA index_info(%s)" % row[1]).fetchall()] return [] @staticmethod def rebuild_with_profile(db, table, columns, pk): if Store.table_pk_columns(db, table) == list(pk): return False names = [column.split()[0] for column in columns] have = {row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()} keep = [name for name in names if name in have] db.execute("CREATE TABLE %s_new (%s, PRIMARY KEY (%s))" % (table, ", ".join(columns), ", ".join(pk))) if keep: db.execute("INSERT OR IGNORE INTO %s_new (%s) SELECT %s FROM %s" % (table, ", ".join(keep), ", ".join(keep), table)) db.execute("DROP TABLE %s" % table) db.execute("ALTER TABLE %s_new RENAME TO %s" % (table, table)) return True @staticmethod def migrate_profiles(db): for table in ("secrets", "records", "tags", "edges", "audit", "events", "schedules"): Store.ensure_column(db, table, "profile", "TEXT") db.execute("UPDATE %s SET profile = 'default' WHERE profile IS NULL" % table) Store.rebuild_with_profile(db, "secrets", ("profile TEXT", "name TEXT", "value TEXT", "meta TEXT", "updated TEXT"), ("profile", "name")) Store.rebuild_with_profile(db, "tags", ("item TEXT", "tag TEXT", "profile TEXT"), ("item", "tag", "profile")) Store.rebuild_with_profile(db, "edges", ("src TEXT", "dst TEXT", "relation TEXT", "profile TEXT", "created TEXT"), ("src", "dst", "relation", "profile")) @staticmethod def ensure_schema(db): notes = [] for table, columns, pk in Consts.SCHEMA: exists = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table,)).fetchone() if not exists: extra = ", PRIMARY KEY (%s)" % ", ".join(pk) if pk else "" db.execute("CREATE TABLE %s (%s%s)" % (table, ", ".join(columns), extra)) continue have = {row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()} for column in columns: parts = column.split() if parts[0] not in have: db.execute("ALTER TABLE %s ADD COLUMN %s" % (table, " ".join(parts[:2]))) notes.append("added %s.%s" % (table, parts[0])) for index, target in Consts.SCHEMA_INDEXES: db.execute("CREATE INDEX IF NOT EXISTS %s ON %s" % (index, target)) db.commit() return notes