- 新增 cloud/identity.py:user_profiles(显示名/时区/偏好,偏好禁密钥键)
与 identity_memories(user 级仅本人可见、project 级项目成员可见,
kind fact/preference/decision/convention,supersede 链+物理删除)。
- server 新增 /users/me/profile、/users/me/memories、/projects/{id}/
identity-memories、/users/me/identity-context,项目记忆先过项目
read/create_task 门;上下文只显式取用,不自动注入任务/thread。
- PG schema 与迁移清单同步新增两张 TEXT 主键表,序列清单不动;
server 补 do_PUT 入口(/users/me/profile 首个 PUT 路由)。
- 新测试 11 个;三套件 cloud 937 OK(4 跳过)、host 89 OK、deploy 29 OK。
- 未动:host.py、worker、CODEX_HOME、旧 summaries 表、控制台 UI;
未推、未建 PR、未部署。
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""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)
|