kith-ai / db.py
yooi's picture
Deploy Kith — relationship memory on small open models (ZeroGPU)
170f78c verified
Raw
History Blame Contribute Delete
18.9 kB
"""Kith Local — SQLite storage layer.
Local file storage is the product story: your conversations never leave the
machine. One connection per call (handlers may run concurrently on ZeroGPU).
"""
import os
import sqlite3
from datetime import date, datetime, timedelta
DB_PATH = os.environ.get("KITH_DB", os.path.join(os.path.dirname(__file__), "data", "kith.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY,
title TEXT,
transcript TEXT NOT NULL,
occurred_at TEXT NOT NULL,
processed_at TEXT
);
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_key TEXT NOT NULL UNIQUE,
relationship TEXT NOT NULL DEFAULT 'other',
summary TEXT NOT NULL DEFAULT '',
last_contact TEXT,
talk_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES people(id),
type TEXT NOT NULL,
text TEXT NOT NULL,
event_on TEXT,
quote TEXT,
source_id INTEGER REFERENCES conversations(id)
);
CREATE TABLE IF NOT EXISTS commitments (
id INTEGER PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES people(id),
owner TEXT NOT NULL CHECK (owner IN ('me','them')),
text TEXT NOT NULL,
promised_on TEXT,
due TEXT,
status TEXT NOT NULL DEFAULT 'open',
quote TEXT,
source_id INTEGER REFERENCES conversations(id)
);
CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
rationale TEXT,
decided_on TEXT,
source_id INTEGER REFERENCES conversations(id)
);
-- A dismissed card hides the suggestion without forgetting the underlying
-- memory: the feed query filters these out, the person/signal row stays.
CREATE TABLE IF NOT EXISTS dismissals (
card_id TEXT PRIMARY KEY,
dismissed_at TEXT NOT NULL
);
"""
def connect() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=15)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
_migrate(conn)
return conn
def _migrate(conn: sqlite3.Connection) -> None:
"""Add columns that may be missing on a DB created by an earlier version."""
for table in ("signals", "commitments"):
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}
if "quote" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN quote TEXT")
def _name_key(name: str) -> str:
return " ".join(name.lower().split())
def insert_conversation(transcript: str, title: str | None = None) -> int:
with connect() as conn:
cur = conn.execute(
"INSERT INTO conversations (title, transcript, occurred_at) VALUES (?, ?, ?)",
(title or "Conversation", transcript, datetime.now().isoformat(timespec="seconds")),
)
return cur.lastrowid
def apply_extraction(conversation_id: int, extraction: dict, today: str | None = None) -> dict:
"""Upsert extracted people, then attach signals/commitments/decisions.
Returns counts per entity type for the UI.
"""
today = today or date.today().isoformat()
counts = {"people": 0, "signals": 0, "commitments": 0, "decisions": 0}
with connect() as conn:
ids_by_key: dict[str, int] = {}
for p in extraction.get("people", []):
key = _name_key(p["name"])
conn.execute(
"""INSERT INTO people (name, name_key, relationship, summary, last_contact, talk_count)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(name_key) DO UPDATE SET
relationship = excluded.relationship,
summary = excluded.summary,
last_contact = excluded.last_contact,
talk_count = people.talk_count + 1""",
(p["name"], key, p.get("relationship", "other"), p.get("summary", ""), today),
)
counts["people"] += 1
for row in conn.execute("SELECT id, name_key FROM people"):
ids_by_key[row["name_key"]] = row["id"]
def pid(name: str) -> int | None:
return ids_by_key.get(_name_key(name or ""))
for s in extraction.get("signals", []):
person_id = pid(s.get("person"))
if person_id is None or not s.get("text"):
continue
dup = conn.execute(
"SELECT 1 FROM signals WHERE person_id = ? AND type = ? AND text = ?",
(person_id, s.get("type", "preference"), s["text"]),
).fetchone()
if dup:
continue
conn.execute(
"INSERT INTO signals (person_id, type, text, event_on, quote, source_id) VALUES (?, ?, ?, ?, ?, ?)",
(person_id, s.get("type", "preference"), s["text"], s.get("event_on"), s.get("quote"), conversation_id),
)
counts["signals"] += 1
for c in extraction.get("commitments", []):
person_id = pid(c.get("person"))
if person_id is None or not c.get("text"):
continue
dup = conn.execute(
"SELECT 1 FROM commitments WHERE person_id = ? AND owner = ? AND text = ? AND status = 'open'",
(person_id, c.get("owner", "me"), c["text"]),
).fetchone()
if dup:
continue
conn.execute(
"""INSERT INTO commitments (person_id, owner, text, promised_on, due, status, quote, source_id)
VALUES (?, ?, ?, ?, ?, 'open', ?, ?)""",
(person_id, c.get("owner", "me"), c["text"], c.get("promised_on", today), c.get("due"), c.get("quote"), conversation_id),
)
counts["commitments"] += 1
for d in extraction.get("decisions", []):
if not d.get("text"):
continue
if conn.execute("SELECT 1 FROM decisions WHERE text = ?", (d["text"],)).fetchone():
continue
conn.execute(
"INSERT INTO decisions (text, rationale, decided_on, source_id) VALUES (?, ?, ?, ?)",
(d["text"], d.get("rationale"), d.get("decided_on", today), conversation_id),
)
counts["decisions"] += 1
conn.execute(
"UPDATE conversations SET processed_at = ? WHERE id = ?",
(datetime.now().isoformat(timespec="seconds"), conversation_id),
)
return counts
def today_feed(today: str | None = None, limit: int = 6) -> list[dict]:
"""The Today feed. Four tiers, most-actionable first:
thoughtful — a dated event coming up (birthday, appointment) within 14 days
owed — an open promise YOU made (overdue ones first)
reconnect — a personal contact you haven't spoken to in 21+ days
remember — something meaningful you just learned (undated signal)
The 'remember' tier is what guarantees a fresh capture always produces
cards: right after recording, nothing is yet overdue or in the future, but
you've still learned things worth surfacing.
"""
today_d = date.fromisoformat(today) if today else date.today()
today_s = today_d.isoformat()
horizon = (today_d + timedelta(days=14)).isoformat()
reconnect_before = (today_d - timedelta(days=21)).isoformat()
cards: list[dict] = []
with connect() as conn:
dismissed = {r["card_id"] for r in conn.execute("SELECT card_id FROM dismissals")}
# 1. thoughtful — upcoming dated events
rows = conn.execute(
"""SELECT s.*, p.name FROM signals s JOIN people p ON p.id = s.person_id
WHERE s.type IN ('date','want','life_event') AND s.event_on IS NOT NULL
AND s.event_on >= ? AND s.event_on <= ?
ORDER BY s.event_on, (s.type = 'date') DESC LIMIT ?""",
(today_s, horizon, limit),
).fetchall()
seen_events: set[tuple[int, str]] = set()
for r in rows:
event_key = (r["person_id"], r["event_on"])
if event_key in seen_events:
continue # one card per person per dated event
seen_events.add(event_key)
days = (date.fromisoformat(r["event_on"]) - today_d).days
when = "today" if days == 0 else ("tomorrow" if days == 1 else f"in {days} days")
want = conn.execute(
"SELECT text FROM signals WHERE person_id = ? AND type = 'want' AND id != ? ORDER BY id DESC",
(r["person_id"], r["id"]),
).fetchone()
cards.append({
"id": f"thoughtful:{r['id']}",
"kind": "thoughtful",
"personName": r["name"],
"title": f"{r['text']}{when}",
"why": want["text"] if want else f"You talked about this with {r['name']} recently.",
})
# 2. owed — every open promise I made; overdue first, newest of the rest next
rows = conn.execute(
"""SELECT c.*, p.name FROM commitments c JOIN people p ON p.id = c.person_id
WHERE c.owner = 'me' AND c.status = 'open'
ORDER BY (c.due IS NOT NULL AND c.due <= ?) DESC, c.due, c.id DESC
LIMIT ?""",
(today_s, limit),
).fetchall()
for r in rows:
overdue = r["due"] and r["due"] <= today_s
if overdue:
why = f"Was due {r['due']}"
elif r["due"]:
why = f"Due {r['due']}"
elif r["promised_on"]:
why = f"You offered this on {r['promised_on']}"
else:
why = f"Something you offered {r['name']}"
cards.append({
"id": f"owed:{r['id']}",
"kind": "owed",
"personName": r["name"],
"title": f"You owe {r['name']}: {r['text']}",
"why": why,
})
# 3. reconnect — personal contacts gone quiet
rows = conn.execute(
"""SELECT * FROM people
WHERE relationship = 'personal' AND last_contact IS NOT NULL AND last_contact <= ?
ORDER BY last_contact LIMIT ?""",
(reconnect_before, limit),
).fetchall()
for r in rows:
cards.append({
"id": f"reconnect:{r['id']}",
"kind": "reconnect",
"personName": r["name"],
"title": f"It's been a while since you talked to {r['name']}",
"why": r["summary"] or f"Last contact {r['last_contact']}.",
})
# 4. remember — meaningful undated things you just learned (fills a fresh feed)
rows = conn.execute(
"""SELECT s.*, p.name FROM signals s JOIN people p ON p.id = s.person_id
WHERE s.type IN ('want','life_event','health','preference') AND s.event_on IS NULL
ORDER BY s.id DESC LIMIT ?""",
(limit,),
).fetchall()
verb = {"want": "wants", "life_event": "", "health": "", "preference": "mentioned"}
for r in rows:
cards.append({
"id": f"remember:{r['id']}",
"kind": "remember",
"personName": r["name"],
"title": r["text"],
"why": f"From your conversation with {r['name']}",
})
# de-dupe, drop dismissed, cap. Tiers are already in priority order.
seen_ids: set[str] = set()
out: list[dict] = []
for c in cards:
if c["id"] in dismissed or c["id"] in seen_ids:
continue
seen_ids.add(c["id"])
out.append(c)
return out[:limit]
def dismiss_card(card_id: str) -> None:
"""Hide one suggestion. The underlying memory is kept."""
with connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO dismissals (card_id, dismissed_at) VALUES (?, ?)",
(card_id, datetime.now().isoformat(timespec="seconds")),
)
# How each kind of card maps back to the row that produced it, so we can show
# its source quote and find the person it's about.
_CARD_SOURCE = {
"thoughtful": ("signals", "person_id"),
"remember": ("signals", "person_id"),
"owed": ("commitments", "person_id"),
"reconnect": ("people", "id"),
}
def card_detail(card_id: str) -> dict:
"""Everything behind one card: the source quote + the rest of what we
remember about that person. This is what makes a card auditable."""
kind, _, rid = card_id.partition(":")
if kind not in _CARD_SOURCE or not rid.isdigit():
return {}
table, person_col = _CARD_SOURCE[kind]
with connect() as conn:
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (int(rid),)).fetchone()
if row is None:
return {}
person_id = row[person_col]
person = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone()
if person is None:
return {}
quote = row["quote"] if kind in ("thoughtful", "owed", "remember") else None
# the conversation this fact came from (for "from your conversation on …")
occurred = None
if kind in ("thoughtful", "owed", "remember") and row["source_id"]:
conv = conn.execute(
"SELECT occurred_at FROM conversations WHERE id = ?", (row["source_id"],)
).fetchone()
occurred = conv["occurred_at"][:10] if conv else None
memories = []
for s in conn.execute(
"SELECT type, text, event_on FROM signals WHERE person_id = ? ORDER BY id", (person_id,)
):
memories.append({"kind": s["type"], "text": s["text"], "date": s["event_on"]})
for c in conn.execute(
"SELECT owner, text, due, status FROM commitments WHERE person_id = ? ORDER BY id", (person_id,)
):
label = ("You owe" if c["owner"] == "me" else f"{person['name']} owes you")
memories.append({"kind": "commitment", "text": f"{label}: {c['text']}", "date": c["due"]})
return {
"kind": kind,
"person": {
"name": person["name"],
"relationship": person["relationship"],
"summary": person["summary"],
"talkCount": person["talk_count"],
"lastContact": person["last_contact"],
},
"quote": quote,
"occurredOn": occurred,
"memories": memories,
}
def _conversation_title(conn: sqlite3.Connection, conv_id: int) -> str:
"""A human title for a conversation: the people it's about, else a snippet."""
names = [
r["name"]
for r in conn.execute(
"""SELECT DISTINCT p.name FROM people p
WHERE p.id IN (SELECT person_id FROM signals WHERE source_id = ?)
OR p.id IN (SELECT person_id FROM commitments WHERE source_id = ?)
ORDER BY p.name""",
(conv_id, conv_id),
)
]
if names:
if len(names) == 1:
return names[0]
if len(names) == 2:
return f"{names[0]} & {names[1]}"
return f"{names[0]}, {names[1]} +{len(names) - 2}"
row = conn.execute("SELECT transcript FROM conversations WHERE id = ?", (conv_id,)).fetchone()
snippet = (row["transcript"][:38].strip() + "…") if row and row["transcript"] else "Conversation"
return snippet
def list_conversations(limit: int = 50) -> list[dict]:
"""Newest-first history for the sidebar, with per-conversation counts."""
out = []
with connect() as conn:
rows = conn.execute(
"SELECT id, occurred_at FROM conversations ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
for r in rows:
cid = r["id"]
people = conn.execute(
"""SELECT COUNT(DISTINCT person_id) AS n FROM (
SELECT person_id FROM signals WHERE source_id = ?
UNION SELECT person_id FROM commitments WHERE source_id = ?
)""",
(cid, cid),
).fetchone()["n"]
signals = conn.execute("SELECT COUNT(*) AS n FROM signals WHERE source_id = ?", (cid,)).fetchone()["n"]
commitments = conn.execute("SELECT COUNT(*) AS n FROM commitments WHERE source_id = ?", (cid,)).fetchone()["n"]
out.append({
"id": cid,
"title": _conversation_title(conn, cid),
"occurredOn": r["occurred_at"][:10],
"counts": {"people": people, "signals": signals, "commitments": commitments},
})
return out
def conversation_detail(conversation_id: int) -> dict:
"""Full record of one past conversation: transcript + what was extracted."""
with connect() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id = ?", (conversation_id,)
).fetchone()
if conv is None:
return {}
memories = []
for s in conn.execute(
"""SELECT s.type, s.text, s.event_on, p.name FROM signals s
JOIN people p ON p.id = s.person_id WHERE s.source_id = ? ORDER BY s.id""",
(conversation_id,),
):
memories.append({"kind": s["type"], "person": s["name"], "text": s["text"], "date": s["event_on"]})
for c in conn.execute(
"""SELECT c.owner, c.text, c.due, p.name FROM commitments c
JOIN people p ON p.id = c.person_id WHERE c.source_id = ? ORDER BY c.id""",
(conversation_id,),
):
label = "You owe" if c["owner"] == "me" else f"{c['name']} owes you"
memories.append({"kind": "commitment", "person": c["name"], "text": f"{label}: {c['text']}", "date": c["due"]})
for d in conn.execute(
"SELECT text FROM decisions WHERE source_id = ? ORDER BY id", (conversation_id,)
):
memories.append({"kind": "decision", "person": None, "text": d["text"], "date": None})
return {
"id": conv["id"],
"title": _conversation_title(conn, conversation_id),
"occurredOn": conv["occurred_at"][:10],
"transcript": conv["transcript"],
"memories": memories,
}
def reset_all() -> dict:
"""Forget everything — the honest 'it's your local file' wipe."""
with connect() as conn:
for table in ("dismissals", "signals", "commitments", "decisions", "people", "conversations"):
conn.execute(f"DELETE FROM {table}")
return stats()
def stats() -> dict:
with connect() as conn:
out = {}
for table in ("conversations", "people", "signals", "commitments", "decisions"):
out[table] = conn.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"]
return out