云端身份与记忆:用户画像+分级记忆,不动 thread
- 新增 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、未部署。
This commit is contained in:
1 parent
38c0797cb3
commit
6e9d6dc5e8
6 files changed
+949
No files matched your search
@@ -0,0 +1,301 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user