"""state.py — per-user state tools (P3). Registered Hermes tools that let a skill read/write/list the CURRENT user's per-persona folder ``users///`` WITHOUT shelling out to python and WITHOUT the skill having to compute paths. The engine resolves the user from the session and hard-scopes every path through ``identity.user_path`` (escape-proof). Handler convention (verified from the bundled hello-world plugin): def handler(params: dict, **kwargs) -> str # returns a JSON string """ from __future__ import annotations import os import re import json from typing import Any, Dict from . import identity as ident _PERSONA_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") # no leading "_" (blocks _template/_index) def _err(msg: str) -> str: return json.dumps({"ok": False, "error": msg}, ensure_ascii=False) def _uid_or_none(): return ident.resolve_user() def _safe_persona(persona: str) -> bool: return bool(persona) and bool(_PERSONA_RE.match(persona)) # -- handlers --------------------------------------------------------------- def read_state(params: Dict[str, Any], **_: Any) -> str: uid = _uid_or_none() if not uid: return _err("no user identity (gateway only)") persona = (params or {}).get("persona", "") rel = (params or {}).get("file", "") if not _safe_persona(persona) or not rel: return _err("need valid 'persona' + 'file'") try: path = ident.user_path(uid, persona, rel) except ValueError as e: return _err(str(e)) if not os.path.isfile(path): return json.dumps({"ok": True, "exists": False, "content": ""}, ensure_ascii=False) with open(path, encoding="utf-8") as f: return json.dumps({"ok": True, "exists": True, "content": f.read()}, ensure_ascii=False) def write_state(params: Dict[str, Any], **_: Any) -> str: uid = _uid_or_none() if not uid: return _err("no user identity (gateway only)") p = params or {} persona, rel, content = p.get("persona", ""), p.get("file", ""), p.get("content", "") if not _safe_persona(persona) or not rel: return _err("need valid 'persona' + 'file'") if not isinstance(content, str): return _err("'content' must be a string") try: path = ident.user_path(uid, persona, rel) except ValueError as e: return _err(str(e)) ident.ensure_user_dir(uid) os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: f.write(content) os.replace(tmp, path) return json.dumps({"ok": True, "path": "users/%s/%s/%s" % (uid, persona, rel)}, ensure_ascii=False) def list_state(params: Dict[str, Any], **_: Any) -> str: uid = _uid_or_none() if not uid: return _err("no user identity (gateway only)") p = params or {} persona, sub = p.get("persona", ""), p.get("subdir", "") if not _safe_persona(persona): return _err("need valid 'persona'") try: base = ident.user_path(uid, persona, sub) if sub else ident.user_path(uid, persona) except ValueError as e: return _err(str(e)) if not os.path.isdir(base): return json.dumps({"ok": True, "files": []}, ensure_ascii=False) files = [] for root, _dirs, names in os.walk(base): for n in names: files.append(os.path.relpath(os.path.join(root, n), base).replace(os.sep, "/")) return json.dumps({"ok": True, "files": sorted(files)}, ensure_ascii=False) # -- schemas + registration ------------------------------------------------- def _schema(name, desc, props, required): return {"name": name, "description": desc, "parameters": {"type": "object", "properties": props, "required": required}} def register(ctx) -> None: ctx.register_tool( name="mentor_state_read", toolset="ai_os_state", schema=_schema("mentor_state_read", "Read a file from the current user's persona folder users///. Engine-scoped to the chatting user.", {"persona": {"type": "string", "description": "persona slot, e.g. ai_mentor"}, "file": {"type": "string", "description": "relative path, e.g. progress.md"}}, ["persona", "file"]), handler=read_state, description="Read current user's per-persona file.") ctx.register_tool( name="mentor_state_write", toolset="ai_os_state", schema=_schema("mentor_state_write", "Write a file in the current user's persona folder users///. Engine-scoped; cannot touch another user.", {"persona": {"type": "string"}, "file": {"type": "string"}, "content": {"type": "string"}}, ["persona", "file", "content"]), handler=write_state, description="Write current user's per-persona file.") ctx.register_tool( name="mentor_state_list", toolset="ai_os_state", schema=_schema("mentor_state_list", "List files under the current user's persona folder (optionally a subdir like artifacts).", {"persona": {"type": "string"}, "subdir": {"type": "string"}}, ["persona"]), handler=list_state, description="List current user's per-persona files.")