personas: elevate to server with per-persona fact scoping

Move personas off the mobile client into ImageApi as first-class
records, and scope entity_facts by persona so each one builds its own
voice over a shared entity graph. The new include_all_memories flag
lets a persona opt back into the full hive-mind pool for human
browsing of /knowledge/*; agentic generation always stays in-voice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-09 17:59:20 -04:00
parent 55a986c249
commit 3e2f36a748
15 changed files with 1024 additions and 20 deletions

View File

@@ -0,0 +1,43 @@
-- Drop the persona-scoping column on entity_facts via the table-rebuild
-- dance for SQLite-version portability (matches the pattern in
-- 2026-04-20-000000_add_backend_to_insights/down.sql).
DROP INDEX IF EXISTS idx_entity_facts_persona;
CREATE TABLE entity_facts_backup AS
SELECT id, subject_entity_id, predicate, object_entity_id, object_value,
source_photo, source_insight_id, confidence, status, created_at
FROM entity_facts;
DROP TABLE entity_facts;
CREATE TABLE entity_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
subject_entity_id INTEGER NOT NULL,
predicate TEXT NOT NULL,
object_entity_id INTEGER,
object_value TEXT,
source_photo TEXT,
source_insight_id INTEGER,
confidence REAL NOT NULL DEFAULT 0.6,
status TEXT NOT NULL DEFAULT 'active',
created_at BIGINT NOT NULL,
CONSTRAINT fk_ef_subject FOREIGN KEY (subject_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
CONSTRAINT fk_ef_object FOREIGN KEY (object_entity_id) REFERENCES entities(id) ON DELETE SET NULL,
CONSTRAINT fk_ef_insight FOREIGN KEY (source_insight_id) REFERENCES photo_insights(id) ON DELETE SET NULL,
CHECK (object_entity_id IS NOT NULL OR object_value IS NOT NULL)
);
INSERT INTO entity_facts
SELECT id, subject_entity_id, predicate, object_entity_id, object_value,
source_photo, source_insight_id, confidence, status, created_at
FROM entity_facts_backup;
DROP TABLE entity_facts_backup;
CREATE INDEX idx_entity_facts_subject ON entity_facts(subject_entity_id);
CREATE INDEX idx_entity_facts_predicate ON entity_facts(predicate);
CREATE INDEX idx_entity_facts_status ON entity_facts(status);
CREATE INDEX idx_entity_facts_source_photo ON entity_facts(source_photo);
DROP INDEX IF EXISTS idx_personas_user;
DROP TABLE IF EXISTS personas;

View File

@@ -0,0 +1,64 @@
-- Personas live server-side now (mobile previously stored them in
-- AsyncStorage only). Each user gets the three built-ins seeded; custom
-- personas land here too via POST /personas or POST /personas/migrate.
--
-- `entity_facts` gains a persona_id so each persona accumulates its own
-- voice over a shared entity graph (entities themselves stay unscoped).
-- Existing rows backfill to 'default' via the column DEFAULT — that
-- becomes the historical baseline. The `include_all_memories` flag on
-- personas lets any persona opt back into reading the full pool.
CREATE TABLE personas (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
persona_id TEXT NOT NULL,
name TEXT NOT NULL,
system_prompt TEXT NOT NULL,
is_built_in BOOLEAN NOT NULL DEFAULT FALSE,
include_all_memories BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE(user_id, persona_id),
CONSTRAINT fk_personas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_personas_user ON personas(user_id);
-- Seed built-ins for every existing user. System prompts copied verbatim
-- from FileViewer-React/hooks/usePersonas.tsx so server and client agree
-- on the canonical voice for each built-in.
INSERT INTO personas (user_id, persona_id, name, system_prompt, is_built_in, created_at, updated_at)
SELECT
u.id,
'default',
'Default Assistant',
'You are my long-term memory assistant. Use only the information provided. Do not invent details. Respond in 36 sentences in third person, leading with the most concrete moment from the photo and the surrounding context. Plain prose, no headings.',
TRUE,
strftime('%s', 'now') * 1000,
strftime('%s', 'now') * 1000
FROM users u
UNION ALL
SELECT
u.id,
'journal',
'Personal Journal',
'You are a personal journal writer. Write in first person, present tense, with warmth and reflection — focusing on emotions and meaningful moments. Use only the information provided; do not invent details. Aim for 48 sentences in a single flowing paragraph, no headings.',
TRUE,
strftime('%s', 'now') * 1000,
strftime('%s', 'now') * 1000
FROM users u
UNION ALL
SELECT
u.id,
'factual',
'Factual Reporter',
'You are a factual memory recorder. Be precise, objective, and concise. Lead with the date and place, then list what / when / who in 24 short sentences. Use only the information provided; if a detail is unknown, say so rather than guessing.',
TRUE,
strftime('%s', 'now') * 1000,
strftime('%s', 'now') * 1000
FROM users u;
-- Persona scoping on facts only. Entities and entity_photo_links stay
-- shared (real-world referents and shared photo ↔ entity associations).
ALTER TABLE entity_facts ADD COLUMN persona_id TEXT NOT NULL DEFAULT 'default';
CREATE INDEX idx_entity_facts_persona ON entity_facts(persona_id);