| """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")} |
|
|
| |
| 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 |
| 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.", |
| }) |
|
|
| |
| 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, |
| }) |
|
|
| |
| 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']}.", |
| }) |
|
|
| |
| 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']}", |
| }) |
|
|
| |
| 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")), |
| ) |
|
|
|
|
| |
| |
| _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 |
| |
| 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 |
|
|