"""System-level identity and memory, cloud layer only. Design (deliberately NOT touching Codex threads): - Identity lives in the control-plane database, not in CODEX_HOME. A Codex thread is never rewritten by this module: no thread_id is created, resumed, forked or archived here, and no prompt is sent to any worker. ``build_identity_context`` only *formats* text for a caller that explicitly asks for it; whether that text ever reaches a new turn is the caller's decision, recorded separately. - Two scopes, no cross-user leakage by construction: - ``user``: owned by one user, visible only to that user, across projects (project_id must be empty). - ``project``: owned by a user but visible to project members; the store does NOT know project membership, so the server must check project ``read``/``create_task`` permission before calling the project-scope methods. Project-scope ownership checks in this module only guard the ``user`` scope; project membership is enforced one layer up. - Supersede is a chain, not an overwrite: the old row becomes ``superseded`` and points at its replacement (``superseded_by``), so a correction stays auditable. ``delete`` is physical removal, reserved for user-initiated forgetting, mirroring MemoryStore. - Profiles never hold secrets: preference keys containing password / secret / token / api_key / credential are rejected, and values are capped, so a profile cannot become a second credential store. Tables use TEXT primary keys on purpose: no IDENTITY sequence to keep in sync in cloud/migrations.py SEQUENCE_TABLES (see the test_p2_migrations alignment test). The same CREATE TABLE statements must appear verbatim in cloud/db.py POSTGRES_SCHEMA. """ import json import time import uuid from cloud.db import connect as _db_connect _SCOPES = ("user", "project") _KINDS = ("fact", "preference", "decision", "convention") _SECRET_KEY_WORDS = ("password", "secret", "token", "api_key", "apikey", "private_key", "credential") _MAX_CONTENT = 4000 _MAX_PREFERENCES_BYTES = 8192 def _clean_str(value, max_len, field): if not isinstance(value, str): raise ValueError("%s must be a string" % field) value = value.strip() if not value: raise ValueError("%s required" % field) if len(value) > max_len: raise ValueError("%s too long" % field) if any(ord(c) < 32 and c not in ("\n", "\t") for c in value): raise ValueError("%s contains control characters" % field) return value def _validate_preferences(preferences): if preferences is None: return None if not isinstance(preferences, dict): raise ValueError("preferences must be an object") for key in preferences: if not isinstance(key, str): raise ValueError("preferences keys must be strings") lowered = key.lower() if any(word in lowered for word in _SECRET_KEY_WORDS): raise ValueError( "preferences must not hold secrets: %s" % key) try: encoded = json.dumps(preferences, ensure_ascii=False) except (TypeError, ValueError): raise ValueError("preferences must be JSON-serializable") if len(encoded.encode("utf-8")) > _MAX_PREFERENCES_BYTES: raise ValueError("preferences too large") return preferences class IdentityStore: """User profiles + scoped identity memories (SQLite or Postgres). Instantiation creates the tables if missing (idempotent), the same pattern as MemoryStore / KBStore. """ def __init__(self, db_path): self.db_path = db_path self.conn = _db_connect(db_path) self.conn.execute( "CREATE TABLE IF NOT EXISTS user_profiles(" "user_id TEXT PRIMARY KEY, " "display_name TEXT, " "timezone TEXT, " "preferences TEXT, " "updated_at REAL)" ) self.conn.execute( "CREATE TABLE IF NOT EXISTS identity_memories(" "id TEXT PRIMARY KEY, " "owner_user_id TEXT, " "scope TEXT, " "project_id TEXT, " "kind TEXT, " "content TEXT, " "source_thread_id TEXT, " "status TEXT DEFAULT 'active', " "superseded_by TEXT, " "created_at REAL, " "updated_at REAL)" ) self.conn.commit() # -- profiles ------------------------------------------------------ def get_profile(self, user_id): row = self.conn.execute( "SELECT * FROM user_profiles WHERE user_id = ?", (user_id,)).fetchone() if row is None: return {"user_id": user_id, "display_name": "", "timezone": "", "preferences": {}, "updated_at": None} d = dict(row) try: d["preferences"] = json.loads(d["preferences"]) \ if d["preferences"] else {} except (TypeError, ValueError): d["preferences"] = {} return d def upsert_profile(self, user_id, display_name=None, timezone=None, preferences=None): """Partial update: only fields that are not None change.""" if display_name is not None: display_name = _clean_str(display_name, 100, "display_name") if timezone is not None: timezone = _clean_str(timezone, 64, "timezone") preferences = _validate_preferences(preferences) now = time.time() existing = self.conn.execute( "SELECT * FROM user_profiles WHERE user_id = ?", (user_id,)).fetchone() if existing is None: self.conn.execute( "INSERT INTO user_profiles(user_id, display_name, " "timezone, preferences, updated_at) VALUES(?, ?, ?, ?, ?)", (user_id, display_name or "", timezone or "", json.dumps(preferences) if preferences is not None else "{}", now)) else: row = dict(existing) try: old_prefs = json.loads(row["preferences"]) \ if row["preferences"] else {} except (TypeError, ValueError): old_prefs = {} self.conn.execute( "UPDATE user_profiles SET display_name = ?, " "timezone = ?, preferences = ?, updated_at = ? " "WHERE user_id = ?", (display_name if display_name is not None else (row["display_name"] or ""), timezone if timezone is not None else (row["timezone"] or ""), json.dumps(preferences) if preferences is not None else json.dumps(old_prefs), now, user_id)) self.conn.commit() return self.get_profile(user_id) # -- memories ------------------------------------------------------- def add_memory(self, owner_user_id, scope, kind, content, project_id=None, source_thread_id=None): if scope not in _SCOPES: raise ValueError("scope must be 'user' or 'project'") if kind not in _KINDS: raise ValueError("kind must be one of %s" % (", ".join(_KINDS),)) content = _clean_str(content, _MAX_CONTENT, "content") if scope == "user": if project_id: raise ValueError( "user-scope memory must not carry a project_id") project_id = "" else: if not project_id or not isinstance(project_id, str): raise ValueError( "project-scope memory requires a project_id") project_id = project_id.strip() if source_thread_id is not None: source_thread_id = _clean_str( source_thread_id, 128, "source_thread_id") mem_id = uuid.uuid4().hex now = time.time() self.conn.execute( "INSERT INTO identity_memories(id, owner_user_id, scope, " "project_id, kind, content, source_thread_id, status, " "superseded_by, created_at, updated_at) " "VALUES(?, ?, ?, ?, ?, ?, ?, 'active', '', ?, ?)", (mem_id, owner_user_id, scope, project_id, kind, content, source_thread_id or "", now, now)) self.conn.commit() return mem_id def get_memory(self, mem_id): row = self.conn.execute( "SELECT * FROM identity_memories WHERE id = ?", (mem_id,)).fetchone() return dict(row) if row is not None else None def list_user_memories(self, user_id, include_superseded=False): sql = ("SELECT * FROM identity_memories " "WHERE owner_user_id = ? AND scope = 'user'") if not include_superseded: sql += " AND status = 'active'" sql += " ORDER BY created_at DESC, id DESC" rows = self.conn.execute(sql, (user_id,)).fetchall() return [dict(r) for r in rows] def list_project_memories(self, project_id, include_superseded=False): """Caller must already hold project ``read`` permission.""" sql = ("SELECT * FROM identity_memories " "WHERE scope = 'project' AND project_id = ?") if not include_superseded: sql += " AND status = 'active'" sql += " ORDER BY created_at DESC, id DESC" rows = self.conn.execute(sql, (project_id,)).fetchall() return [dict(r) for r in rows] def supersede_memory(self, mem_id, actor_user_id, replacement_content=None, replacement_kind=None): """Mark old row superseded; optionally create its replacement. User-scope rows may only be superseded by their owner (PermissionError otherwise). Project-scope membership is enforced by the server before calling this method. Returns (old_id, new_id_or_None). """ row = self.get_memory(mem_id) if row is None: raise KeyError("memory not found") if row["scope"] == "user" and row["owner_user_id"] != actor_user_id: raise PermissionError("not the owner of this memory") new_id = None if replacement_content is not None: kind = replacement_kind or row["kind"] new_id = self.add_memory( row["owner_user_id"], row["scope"], kind, replacement_content, project_id=(row["project_id"] or None) if row["scope"] == "project" else None, source_thread_id=(row["source_thread_id"] or None)) self.conn.execute( "UPDATE identity_memories SET status = 'superseded', " "superseded_by = ?, updated_at = ? WHERE id = ?", (new_id or "", time.time(), mem_id)) self.conn.commit() return mem_id, new_id def delete_memory(self, mem_id, actor_user_id): """Physical removal (user-initiated forgetting only).""" row = self.get_memory(mem_id) if row is None: raise KeyError("memory not found") if row["scope"] == "user" and row["owner_user_id"] != actor_user_id: raise PermissionError("not the owner of this memory") self.conn.execute( "DELETE FROM identity_memories WHERE id = ?", (mem_id,)) self.conn.commit() return True def build_identity_context(self, user_id, project_id=None, top_k_user=5, top_k_project=5): """Format active memories for an explicit caller. Returns "" when there is nothing active. Never returns the full history: at most top_k per scope. Project memories are included only when project_id is given; the caller must have checked project ``read`` permission first. """ sections = [] user_rows = self.list_user_memories(user_id)[:top_k_user] if user_rows: lines = ["[用户记忆]"] for r in user_rows: lines.append("- [%s] %s" % (r["kind"], r["content"])) sections.append("\n".join(lines)) if project_id: proj_rows = self.list_project_memories( project_id)[:top_k_project] if proj_rows: lines = ["[项目记忆]"] for r in proj_rows: lines.append("- [%s] %s" % (r["kind"], r["content"])) sections.append("\n".join(lines)) return "\n".join(sections)