tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
969 lines
47 KiB
Python
969 lines
47 KiB
Python
"""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
|