diff --git a/README.md b/README.md index 44f40e6b..e55df521 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@
English | 简体中文
-Built on nanobot · A demo version has been submitted to the Gemma 4 Good Hackathon (Kaggle, May 2026)
+Built on nanobot · Submitted to the Gemma 4 Good Hackathon (Kaggle, May 2026)
CloakBot adds a **local privacy pipeline** between your session and any remote LLM. Before a message is sent upstream, a multi-agent system powered by trusted local model served through vLLM/Ollama runs two local JSON-only detectors: one for general sensitive entities and one for sensitive numeric or temporal values. Matched spans are rewritten into typed, reversible placeholders and stored in a session-scoped Vault. For math task turns, the remote LLM is asked for structure only while the real arithmetic happens locally with the original values from the Vault. @@ -269,7 +269,7 @@ cloakbot/ └── start_vllm.sh Start vLLM server ``` -Session-level placeholder mappings are persisted as JSON under `~/.cloakbot/sanitizer_maps/`, so the Vault can reuse the same placeholder mapping across turns in the same session. CloakBot now supports **multi-turn conversation privacy** by carrying forward placeholder mappings across turns while still restoring user-visible outputs locally. Computable placeholders also store normalized values for later local math execution. +Session-level placeholder mappings are persisted as JSON under `~/.cloakbot/workspace/privacy_vault/maps/`, so the Vault can reuse the same placeholder mapping across turns in the same session. CloakBot now supports **multi-turn conversation privacy** by carrying forward placeholder mappings across turns while still restoring user-visible outputs locally. Computable placeholders also store normalized values for later local math execution. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index b26b42de..fe719786 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -269,7 +269,7 @@ cloakbot/ └── start_vllm.sh 启动 vLLM 服务 ``` -会话级占位符映射会以 JSON 形式存到 `~/.cloakbot/sanitizer_maps/`,同一会话跨轮可复用。CloakBot 现在已支持**多轮会话隐私保护**:占位符映射可跨轮延续,同时对用户展示仍在本地恢复。可计算占位符还会保存规范化数值,用于后续本地数学执行。 +会话级占位符映射会以 JSON 形式存到 `~/.cloakbot/workspace/privacy_vault/maps/`,同一会话跨轮可复用。CloakBot 现在已支持**多轮会话隐私保护**:占位符映射可跨轮延续,同时对用户展示仍在本地恢复。可计算占位符还会保存规范化数值,用于后续本地数学执行。 --- diff --git a/SECURITY.md b/SECURITY.md index 907ab51d..360a3d76 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,7 @@ chmod 600 ~/.cloakbot/config.json The Vault holds plaintext `token ↔ raw value` mappings for the current session. Protect it accordingly: ```bash -chmod 700 ~/.cloakbot/vault/ +chmod 700 ~/.cloakbot/workspace/privacy_vault/ ``` Do not log vault contents, sync vault files to cloud storage, or leave stale vaults from old sessions on disk. @@ -74,7 +74,7 @@ Requires `bwrap` (`apt install bubblewrap`). Not available on macOS or Windows. If you suspect a key or session has been compromised: 1. Revoke all remote/local LLM API keys and channel bot tokens immediately. -2. Delete session Vault files under `~/.cloakbot/vault/`. +2. Delete session Vault files under `~/.cloakbot/workspace/privacy_vault/`. 3. Review logs for unauthorized access attempts. 4. Update to the latest release. 5. Report to maintainers via the channels above. diff --git a/cloakbot/__init__.py b/cloakbot/__init__.py index 4ddac10d..b0a9f327 100644 --- a/cloakbot/__init__.py +++ b/cloakbot/__init__.py @@ -2,7 +2,7 @@ cloakbot - A lightweight AI agent framework """ -__version__ = "0.1.5" +__version__ = "0.1.8" __logo__ = "🥷" from cloakbot.cloakbot import Cloakbot, RunResult diff --git a/cloakbot/agent/context.py b/cloakbot/agent/context.py index 10707f50..98c99190 100644 --- a/cloakbot/agent/context.py +++ b/cloakbot/agent/context.py @@ -6,12 +6,10 @@ from pathlib import Path from typing import Any -from cloakbot.utils.helpers import current_time_str - from cloakbot.agent.memory import MemoryStore -from cloakbot.utils.prompt_templates import render_template from cloakbot.agent.skills import SkillsLoader -from cloakbot.utils.helpers import build_assistant_message, detect_image_mime +from cloakbot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime +from cloakbot.utils.prompt_templates import render_template class ContextBuilder: diff --git a/cloakbot/agent/loop.py b/cloakbot/agent/loop.py index 674ae567..aead2f51 100644 --- a/cloakbot/agent/loop.py +++ b/cloakbot/agent/loop.py @@ -15,10 +15,10 @@ from cloakbot.agent.context import ContextBuilder from cloakbot.agent.hook import AgentHook, AgentHookContext, CompositeHook from cloakbot.agent.memory import Consolidator, Dream -from cloakbot.agent.runner import AgentRunSpec, AgentRunner +from cloakbot.agent.runner import AgentRunner, AgentRunSpec +from cloakbot.agent.skills import BUILTIN_SKILLS_DIR from cloakbot.agent.subagent import SubagentManager from cloakbot.agent.tools.cron import CronTool -from cloakbot.agent.skills import BUILTIN_SKILLS_DIR from cloakbot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool from cloakbot.agent.tools.message import MessageTool from cloakbot.agent.tools.registry import ToolRegistry @@ -27,12 +27,13 @@ from cloakbot.agent.tools.spawn import SpawnTool from cloakbot.agent.tools.web import WebFetchTool, WebSearchTool from cloakbot.bus.events import InboundMessage, OutboundMessage -from cloakbot.command import CommandContext, CommandRouter, register_builtin_commands from cloakbot.bus.queue import MessageBus +from cloakbot.command import CommandContext, CommandRouter, register_builtin_commands from cloakbot.config.schema import AgentDefaults -from cloakbot.providers.base import LLMProvider from cloakbot.privacy import Intent, post_llm_hook, pre_llm_hook -from cloakbot.privacy.transparency.report import build_session_privacy_snapshot +from cloakbot.privacy.webui import WEBUI_PRIVACY_METADATA_KEY, build_webui_privacy_payload +from cloakbot.privacy.webui.history import append_webui_privacy_payload +from cloakbot.providers.base import LLMProvider from cloakbot.session.manager import Session, SessionManager from cloakbot.utils.helpers import image_placeholder_text, truncate_text from cloakbot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE @@ -42,18 +43,6 @@ from cloakbot.cron.service import CronService -def _build_webui_privacy_turn_payload(turn_ctx) -> dict[str, Any]: - return { - "turnId": turn_ctx.turn_id, - "intent": turn_ctx.intent.value, - "remotePrompt": turn_ctx.sanitized_input, - "localComputations": [ - computation.model_dump(mode="json") - for computation in turn_ctx.local_computations - ], - } - - class _LoopHook(AgentHook): """Core hook for the main loop.""" @@ -226,6 +215,9 @@ def __init__( self._last_usage: dict[str, int] = {} self._extra_hooks: list[AgentHook] = hooks or [] + from cloakbot.privacy.core.state.vault import set_vault_workspace + + set_vault_workspace(workspace) self.context = ContextBuilder(workspace, timezone=timezone) self.sessions = session_manager or SessionManager(workspace) self.tools = ToolRegistry() @@ -471,21 +463,15 @@ async def on_stream(delta: str) -> None: async def on_stream_end( *, resuming: bool = False, - privacy: dict[str, Any] | None = None, - privacy_annotations: list[dict[str, Any]] | None = None, - privacy_turn: dict[str, Any] | None = None, + webui_privacy: dict[str, Any] | None = None, ) -> None: nonlocal stream_segment meta = dict(msg.metadata or {}) meta["_stream_end"] = True meta["_resuming"] = resuming meta["_stream_id"] = _current_stream_id() - if privacy is not None: - meta["privacy"] = privacy - if privacy_annotations is not None: - meta["privacyAnnotations"] = privacy_annotations - if privacy_turn is not None: - meta["privacyTurn"] = privacy_turn + if webui_privacy is not None: + meta[WEBUI_PRIVACY_METADATA_KEY] = webui_privacy await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="", @@ -667,21 +653,15 @@ async def _buffered_stream_end(*, resuming: bool = False) -> None: for i in range(0, len(finalized), chunk): await on_stream(finalized[i : i + chunk]) if on_stream_end is not None: - privacy = None - privacy_annotations = None - privacy_turn = None + webui_privacy = None if msg.channel == "webui": - privacy = build_session_privacy_snapshot(session_key).model_dump(mode="json") - privacy_annotations = [ - annotation.model_dump(mode="json") - for annotation in turn_ctx.display_output_annotations - ] - privacy_turn = _build_webui_privacy_turn_payload(turn_ctx) + webui_privacy = build_webui_privacy_payload( + session_key, + turn_ctx, + ).model_dump(mode="json", by_alias=True) await on_stream_end( resuming=resuming, - privacy=privacy, - privacy_annotations=privacy_annotations, - privacy_turn=privacy_turn, + webui_privacy=webui_privacy, ) effective_stream: Callable[[str], Awaitable[None]] | None = _buffered_stream @@ -706,6 +686,14 @@ async def _buffered_stream_end(*, resuming: bool = False) -> None: turn_ctx.tool_calls_made = len(tools_used) final_content = await _finalize_response_text(final_content) + webui_privacy_payload = None + if msg.channel == "webui": + webui_privacy_payload = build_webui_privacy_payload( + session_key, + turn_ctx, + ) + append_webui_privacy_payload(self.workspace, session_key, webui_privacy_payload) + self._save_turn(session, all_msgs, 1 + len(history)) self._clear_runtime_checkpoint(session) self.sessions.save(session) @@ -718,13 +706,8 @@ async def _buffered_stream_end(*, resuming: bool = False) -> None: logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) meta = dict(msg.metadata or {}) - if msg.channel == "webui": - meta["privacy"] = build_session_privacy_snapshot(session_key).model_dump(mode="json") - meta["privacyAnnotations"] = [ - annotation.model_dump(mode="json") - for annotation in turn_ctx.display_output_annotations - ] - meta["privacyTurn"] = _build_webui_privacy_turn_payload(turn_ctx) + if webui_privacy_payload is not None: + meta[WEBUI_PRIVACY_METADATA_KEY] = webui_privacy_payload.model_dump(mode="json", by_alias=True) if on_stream is not None: meta["_streamed"] = True return OutboundMessage( diff --git a/cloakbot/agent/memory.py b/cloakbot/agent/memory.py index 438d55c1..bd5d676f 100644 --- a/cloakbot/agent/memory.py +++ b/cloakbot/agent/memory.py @@ -12,12 +12,16 @@ from loguru import logger -from cloakbot.utils.prompt_templates import render_template -from cloakbot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think - -from cloakbot.agent.runner import AgentRunSpec, AgentRunner +from cloakbot.agent.runner import AgentRunner, AgentRunSpec from cloakbot.agent.tools.registry import ToolRegistry from cloakbot.utils.gitstore import GitStore +from cloakbot.utils.helpers import ( + ensure_dir, + estimate_message_tokens, + estimate_prompt_tokens_chain, + strip_think, +) +from cloakbot.utils.prompt_templates import render_template if TYPE_CHECKING: from cloakbot.providers.base import LLMProvider @@ -286,7 +290,7 @@ def _read_last_entry(self) -> dict[str, Any] | None: read_size = min(size, 4096) f.seek(size - read_size) data = f.read().decode("utf-8") - lines = [l for l in data.split("\n") if l.strip()] + lines = [line for line in data.split("\n") if line.strip()] if not lines: return None return json.loads(lines[-1]) diff --git a/cloakbot/agent/runner.py b/cloakbot/agent/runner.py index a8a024e1..667034f9 100644 --- a/cloakbot/agent/runner.py +++ b/cloakbot/agent/runner.py @@ -10,7 +10,6 @@ from loguru import logger from cloakbot.agent.hook import AgentHook, AgentHookContext -from cloakbot.utils.prompt_templates import render_template from cloakbot.agent.tools.registry import ToolRegistry from cloakbot.providers.base import LLMProvider, ToolCallRequest from cloakbot.utils.helpers import ( @@ -21,6 +20,7 @@ maybe_persist_tool_result, truncate_text, ) +from cloakbot.utils.prompt_templates import render_template from cloakbot.utils.runtime import ( EMPTY_FINAL_RESPONSE_MESSAGE, build_finalization_retry_message, @@ -380,7 +380,7 @@ async def _run_tool( tool_call: ToolCallRequest, external_lookup_counts: dict[str, int], ) -> tuple[Any, dict[str, str], BaseException | None]: - _HINT = "\n\n[Analyze the error above and try a different approach.]" + hint = "\n\n[Analyze the error above and try a different approach.]" lookup_error = repeated_external_lookup_error( tool_call.name, tool_call.arguments, @@ -393,8 +393,8 @@ async def _run_tool( "detail": "repeated external lookup blocked", } if spec.fail_on_tool_error: - return lookup_error + _HINT, event, RuntimeError(lookup_error) - return lookup_error + _HINT, event, None + return lookup_error + hint, event, RuntimeError(lookup_error) + return lookup_error + hint, event, None prepare_call = getattr(spec.tools, "prepare_call", None) tool, params, prep_error = None, tool_call.arguments, None if callable(prepare_call): @@ -410,7 +410,7 @@ async def _run_tool( "status": "error", "detail": prep_error.split(": ", 1)[-1][:120], } - return prep_error + _HINT, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None + return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None try: if tool is not None: result = await tool.execute(**params) @@ -435,8 +435,8 @@ async def _run_tool( "detail": result.replace("\n", " ").strip()[:120], } if spec.fail_on_tool_error: - return result + _HINT, event, RuntimeError(result) - return result + _HINT, event, None + return result + hint, event, RuntimeError(result) + return result + hint, event, None detail = "" if result is None else str(result) detail = detail.replace("\n", " ").strip() @@ -602,4 +602,3 @@ def _partition_tool_batches( if current: batches.append(current) return batches - diff --git a/cloakbot/agent/subagent.py b/cloakbot/agent/subagent.py index 13ec112a..2ab46894 100644 --- a/cloakbot/agent/subagent.py +++ b/cloakbot/agent/subagent.py @@ -9,8 +9,7 @@ from loguru import logger from cloakbot.agent.hook import AgentHook, AgentHookContext -from cloakbot.utils.prompt_templates import render_template -from cloakbot.agent.runner import AgentRunSpec, AgentRunner +from cloakbot.agent.runner import AgentRunner, AgentRunSpec from cloakbot.agent.skills import BUILTIN_SKILLS_DIR from cloakbot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool from cloakbot.agent.tools.registry import ToolRegistry @@ -21,6 +20,7 @@ from cloakbot.bus.queue import MessageBus from cloakbot.config.schema import ExecToolConfig, WebToolsConfig from cloakbot.providers.base import LLMProvider +from cloakbot.utils.prompt_templates import render_template class _SubagentHook(AgentHook): diff --git a/cloakbot/agent/tools/cron.py b/cloakbot/agent/tools/cron.py index 2266f8aa..cfdb6c00 100644 --- a/cloakbot/agent/tools/cron.py +++ b/cloakbot/agent/tools/cron.py @@ -5,7 +5,12 @@ from typing import Any from cloakbot.agent.tools.base import Tool, tool_parameters -from cloakbot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema +from cloakbot.agent.tools.schema import ( + BooleanSchema, + IntegerSchema, + StringSchema, + tool_parameters_schema, +) from cloakbot.cron.service import CronService from cloakbot.cron.types import CronJob, CronJobState, CronSchedule diff --git a/cloakbot/agent/tools/filesystem.py b/cloakbot/agent/tools/filesystem.py index e50d7c20..94387cff 100644 --- a/cloakbot/agent/tools/filesystem.py +++ b/cloakbot/agent/tools/filesystem.py @@ -6,9 +6,14 @@ from typing import Any from cloakbot.agent.tools.base import Tool, tool_parameters -from cloakbot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema -from cloakbot.utils.helpers import build_image_content_blocks, detect_image_mime +from cloakbot.agent.tools.schema import ( + BooleanSchema, + IntegerSchema, + StringSchema, + tool_parameters_schema, +) from cloakbot.config.paths import get_media_dir +from cloakbot.utils.helpers import build_image_content_blocks, detect_image_mime def _resolve_path( @@ -24,7 +29,7 @@ def _resolve_path( resolved = p.resolve() if allowed_dir: media_path = get_media_dir().resolve() - all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or []) + all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or []) if not any(_is_under(resolved, d) for d in all_dirs): raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") return resolved @@ -209,13 +214,13 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]: old_lines = old_text.splitlines() if not old_lines: return None, 0 - stripped_old = [l.strip() for l in old_lines] + stripped_old = [line.strip() for line in old_lines] content_lines = content.splitlines() candidates = [] for i in range(len(content_lines) - len(stripped_old) + 1): window = content_lines[i : i + len(stripped_old)] - if [l.strip() for l in window] == stripped_old: + if [line.strip() for line in window] == stripped_old: candidates.append("\n".join(window)) if candidates: diff --git a/cloakbot/agent/tools/message.py b/cloakbot/agent/tools/message.py index 7f23c342..427c4fbb 100644 --- a/cloakbot/agent/tools/message.py +++ b/cloakbot/agent/tools/message.py @@ -73,7 +73,7 @@ async def execute( ) -> str: from cloakbot.utils.helpers import strip_think content = strip_think(content) - + channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id # Only inherit default message_id when targeting the same channel+chat. diff --git a/cloakbot/agent/tools/registry.py b/cloakbot/agent/tools/registry.py index bb379797..9a026d73 100644 --- a/cloakbot/agent/tools/registry.py +++ b/cloakbot/agent/tools/registry.py @@ -84,19 +84,19 @@ def prepare_call( async def execute(self, name: str, params: dict[str, Any]) -> Any: """Execute a tool by name with given parameters.""" - _HINT = "\n\n[Analyze the error above and try a different approach.]" + hint = "\n\n[Analyze the error above and try a different approach.]" tool, params, error = self.prepare_call(name, params) if error: - return error + _HINT + return error + hint try: assert tool is not None # guarded by prepare_call() result = await tool.execute(**params) if isinstance(result, str) and result.startswith("Error"): - return result + _HINT + return result + hint return result except Exception as e: - return f"Error executing {name}: {str(e)}" + _HINT + return f"Error executing {name}: {str(e)}" + hint @property def tool_names(self) -> list[str]: diff --git a/cloakbot/agent/tools/sandbox.py b/cloakbot/agent/tools/sandbox.py index 9b40701a..bcc7c484 100644 --- a/cloakbot/agent/tools/sandbox.py +++ b/cloakbot/agent/tools/sandbox.py @@ -31,8 +31,10 @@ def _bwrap(command: str, workspace: str, cwd: str) -> str: "/etc/ssl/certs", "/etc/resolv.conf", "/etc/ld.so.cache"] args = ["bwrap", "--new-session", "--die-with-parent"] - for p in required: args += ["--ro-bind", p, p] - for p in optional: args += ["--ro-bind-try", p, p] + for p in required: + args += ["--ro-bind", p, p] + for p in optional: + args += ["--ro-bind-try", p, p] args += [ "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", str(ws.parent), # mask config dir diff --git a/cloakbot/agent/tools/shell.py b/cloakbot/agent/tools/shell.py index 59f86d50..b0258d9a 100644 --- a/cloakbot/agent/tools/shell.py +++ b/cloakbot/agent/tools/shell.py @@ -212,8 +212,8 @@ def _guard_command(self, command: str, cwd: str) -> str | None: continue media_path = get_media_dir().resolve() - if (p.is_absolute() - and cwd_path not in p.parents + if (p.is_absolute() + and cwd_path not in p.parents and p != cwd_path and media_path not in p.parents and p != media_path diff --git a/cloakbot/agent/tools/web.py b/cloakbot/agent/tools/web.py index 5ec4f895..603b0dd4 100644 --- a/cloakbot/agent/tools/web.py +++ b/cloakbot/agent/tools/web.py @@ -249,8 +249,18 @@ def __init__(self, max_chars: int = 50000, proxy: str | None = None): def read_only(self) -> bool: return True - async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> Any: - max_chars = maxChars or self.max_chars + async def execute( + self, + url: str, + extract_mode: str = "markdown", + max_chars: int | None = None, + **kwargs: Any, + ) -> Any: + if "extractMode" in kwargs: + extract_mode = kwargs.pop("extractMode") + if "maxChars" in kwargs: + max_chars = kwargs.pop("maxChars") + max_chars = max_chars or self.max_chars is_valid, error_msg = _validate_url_safe(url) if not is_valid: return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) @@ -275,7 +285,7 @@ async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | result = await self._fetch_jina(url, max_chars) if result is None: - result = await self._fetch_readability(url, extractMode, max_chars) + result = await self._fetch_readability(url, extract_mode, max_chars) return result async def _fetch_jina(self, url: str, max_chars: int) -> str | None: diff --git a/cloakbot/api/server.py b/cloakbot/api/server.py index adbbaa9c..d3e698e8 100644 --- a/cloakbot/api/server.py +++ b/cloakbot/api/server.py @@ -104,7 +104,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response: logger.info("API request session_key={} content={}", session_key, user_content[:80]) - _FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE + fallback = EMPTY_FINAL_RESPONSE_MESSAGE try: async with session_lock: @@ -140,7 +140,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response: "Empty response after retry for session {}, using fallback", session_key, ) - response_text = _FALLBACK + response_text = fallback except asyncio.TimeoutError: return _error_json(504, f"Request timed out after {timeout_s}s") diff --git a/cloakbot/channels/dingtalk.py b/cloakbot/channels/dingtalk.py index 51c1b4b4..ac0e75d5 100644 --- a/cloakbot/channels/dingtalk.py +++ b/cloakbot/channels/dingtalk.py @@ -278,9 +278,12 @@ def _is_http_url(value: str) -> bool: def _guess_upload_type(self, media_ref: str) -> str: ext = Path(urlparse(media_ref).path).suffix.lower() - if ext in self._IMAGE_EXTS: return "image" - if ext in self._AUDIO_EXTS: return "voice" - if ext in self._VIDEO_EXTS: return "video" + if ext in self._IMAGE_EXTS: + return "image" + if ext in self._AUDIO_EXTS: + return "voice" + if ext in self._VIDEO_EXTS: + return "video" return "file" def _guess_filename(self, media_ref: str, upload_type: str) -> str: @@ -401,8 +404,10 @@ async def _send_batch_message( if resp.status_code != 200: logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) return False - try: result = resp.json() - except Exception: result = {} + try: + result = resp.json() + except Exception: + result = {} errcode = result.get("errcode") if errcode not in (None, 0): logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) diff --git a/cloakbot/channels/matrix.py b/cloakbot/channels/matrix.py index e60dbcdf..3018d782 100644 --- a/cloakbot/channels/matrix.py +++ b/cloakbot/channels/matrix.py @@ -29,10 +29,11 @@ RoomMessageMedia, RoomMessageText, RoomSendError, + RoomSendResponse, RoomTypingError, SyncError, - UploadError, RoomSendResponse, -) + UploadError, + ) from nio.crypto.attachments import decrypt_attachment from nio.exceptions import EncryptionError except ImportError as e: @@ -107,7 +108,7 @@ class _StreamBuf: :ivar text: Stores the text content of the buffer. :type text: str - :ivar event_id: Identifier for the associated event. None indicates no + :ivar event_id: Identifier for the associated event. None indicates no specific event association. :type event_id: str | None :ivar last_edit: Timestamp of the most recent edit to the buffer. @@ -140,19 +141,19 @@ def _build_matrix_text_content( ) -> dict[str, object]: """ Constructs and returns a dictionary representing the matrix text content with optional - HTML formatting and reference to an existing event for replacement. This function is + HTML formatting and reference to an existing event for replacement. This function is primarily used to create content payloads compatible with the Matrix messaging protocol. :param text: The plain text content to include in the message. :type text: str - :param event_id: Optional ID of the event to replace. If provided, the function will - include information indicating that the message is a replacement of the specified + :param event_id: Optional ID of the event to replace. If provided, the function will + include information indicating that the message is a replacement of the specified event. :type event_id: str | None :param thread_relates_to: Optional Matrix thread relation metadata. For edits this is stored in ``m.new_content`` so the replacement remains in the same thread. :type thread_relates_to: dict[str, object] | None - :return: A dictionary containing the matrix text content, potentially enriched with + :return: A dictionary containing the matrix text content, potentially enriched with HTML formatting and replacement metadata if applicable. :rtype: dict[str, object] """ @@ -534,7 +535,7 @@ async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | return await self._stop_typing_keepalive(chat_id, clear_typing=True) - + content = _build_matrix_text_content( buf.text, buf.event_id, @@ -548,7 +549,7 @@ async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | buf = _StreamBuf() self._stream_bufs[chat_id] = buf buf.text += delta - + if not buf.text.strip(): return diff --git a/cloakbot/channels/mochat.py b/cloakbot/channels/mochat.py index f687f27f..b94bb95b 100644 --- a/cloakbot/channels/mochat.py +++ b/cloakbot/channels/mochat.py @@ -11,13 +11,13 @@ import httpx from loguru import logger +from pydantic import Field from cloakbot.bus.events import OutboundMessage from cloakbot.bus.queue import MessageBus from cloakbot.channels.base import BaseChannel from cloakbot.config.paths import get_runtime_subdir from cloakbot.config.schema import Base -from pydantic import Field try: import socketio diff --git a/cloakbot/channels/slack.py b/cloakbot/channels/slack.py index fbbde194..36d677fd 100644 --- a/cloakbot/channels/slack.py +++ b/cloakbot/channels/slack.py @@ -5,6 +5,7 @@ from typing import Any from loguru import logger +from pydantic import Field from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.socket_mode.websockets import SocketModeClient @@ -13,8 +14,6 @@ from cloakbot.bus.events import OutboundMessage from cloakbot.bus.queue import MessageBus -from pydantic import Field - from cloakbot.channels.base import BaseChannel from cloakbot.config.schema import Base diff --git a/cloakbot/channels/telegram.py b/cloakbot/channels/telegram.py index 30ec3cc0..0ef864b0 100644 --- a/cloakbot/channels/telegram.py +++ b/cloakbot/channels/telegram.py @@ -480,7 +480,7 @@ async def send(self, msg: OutboundMessage) -> None: async def _call_with_retry(self, fn, *args, **kwargs): """Call an async Telegram API function with retry on pool/network timeout and RetryAfter.""" from telegram.error import RetryAfter - + for attempt in range(1, _SEND_MAX_RETRIES + 1): try: return await fn(*args, **kwargs) @@ -689,13 +689,13 @@ async def _extract_reply_context(self, message) -> str | None: text = getattr(reply, "text", None) or getattr(reply, "caption", None) or "" if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN: text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..." - + if not text: return None - + bot_id, _ = await self._ensure_bot_identity() reply_user = getattr(reply, "from_user", None) - + if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id: return f"[Reply to bot: {text}]" elif reply_user and getattr(reply_user, "username", None): @@ -840,7 +840,7 @@ async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_T message = update.message user = update.effective_user self._remember_thread_context(message) - + # Strip @bot_username suffix if present content = message.text or "" if content.startswith("/") and "@" in content: @@ -848,7 +848,7 @@ async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_T cmd_part = cmd_part.split("@")[0] content = f"{cmd_part} {rest[0]}" if rest else cmd_part content = self._normalize_telegram_command(content) - + await self._handle_message( sender_id=self._sender_id(user), chat_id=str(message.chat_id), diff --git a/cloakbot/channels/webui.py b/cloakbot/channels/webui.py index 5a2e433a..b6cfd2e1 100644 --- a/cloakbot/channels/webui.py +++ b/cloakbot/channels/webui.py @@ -3,22 +3,43 @@ from __future__ import annotations import contextlib -import json +from datetime import datetime from pathlib import Path from uuid import uuid4 import uvicorn -from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from loguru import logger -from pydantic import AliasChoices, Field +from pydantic import AliasChoices, Field, ValidationError from cloakbot.bus.events import OutboundMessage from cloakbot.channels.base import BaseChannel +from cloakbot.config.paths import get_workspace_path from cloakbot.config.schema import Base +from cloakbot.privacy.core.sanitization.restorer import ( + restore_tokens, + restore_tokens_with_annotations, +) +from cloakbot.privacy.core.state.vault import get_map, set_vault_workspace from cloakbot.privacy.transparency.report import build_session_privacy_snapshot +from cloakbot.privacy.webui import ( + WEBUI_PRIVACY_METADATA_KEY, + WebUIAssistantDeltaEvent, + WebUIAssistantDoneEvent, + WebUIAssistantMessageEvent, + WebUIPrivacyPayload, + WebUIPrivacySnapshotEvent, + WebUIProgressEvent, + WebUISessionEvent, + WebUIStatusData, + WebUIStatusEvent, + WebUIUserMessage, +) +from cloakbot.privacy.webui.history import load_webui_privacy_payloads +from cloakbot.session.manager import Session, SessionManager class SPAStaticFiles(StaticFiles): @@ -61,6 +82,10 @@ def __init__(self, config, bus): self.port = self.config.port self.frontend_url = self.config.frontend_url or "" self.status_payload = dict(self.config.status) + workspace_value = self.status_payload.get("workspace") + self.workspace = get_workspace_path(workspace_value if isinstance(workspace_value, str) else None) + set_vault_workspace(self.workspace) + self.sessions = SessionManager(self.workspace) self.frontend_dist_dir = Path(__file__).resolve().parents[2] / "webui" / "dist" self._clients: dict[str, set[WebSocket]] = {} self._server: EmbeddedUvicornServer | None = None @@ -89,10 +114,47 @@ def _create_app(self) -> FastAPI: @app.get("/api/status") async def status() -> dict: - return { + return WebUIStatusData.model_validate({ **self.status_payload, "ready": True, "frontendBuilt": self.frontend_dist_dir.exists(), + }).model_dump(mode="json", by_alias=True) + + @app.get("/api/sessions") + async def sessions() -> dict: + items = [] + for item in self.sessions.list_sessions(): + key = item.get("key") or "" + if not key.startswith(f"{self.name}:"): + continue + session = self.sessions.get_or_create(key) + session_id = key.split(":", 1)[1] + items.append({ + "id": session_id, + "title": self._session_title(session), + "createdAt": self._timestamp_ms(item.get("created_at")), + "updatedAt": self._timestamp_ms(item.get("updated_at")), + }) + return {"sessions": items} + + @app.get("/api/sessions/{session_id}") + async def session_history(session_id: str) -> dict: + session_key = f"{self.name}:{session_id}" + if not any((item.get("key") or "") == session_key for item in self.sessions.list_sessions()): + raise HTTPException(status_code=404, detail="Session not found") + session = self.sessions.get_or_create(session_key) + payloads = load_webui_privacy_payloads(self.workspace, session_key) + return { + "id": session_id, + "title": self._session_title(session), + "messages": self._history_messages(session, payloads), + "privacySnapshot": build_session_privacy_snapshot(session_key).model_dump(mode="json"), + "privacyTurns": [ + payload.privacy_turn.model_dump(mode="json", by_alias=True) + for payload in payloads + ], + "createdAt": self._timestamp_ms(session.created_at.isoformat()), + "updatedAt": self._timestamp_ms(session.updated_at.isoformat()), } @app.websocket("/ws/chat") @@ -100,28 +162,31 @@ async def chat(websocket: WebSocket) -> None: session_id = websocket.query_params.get("session_id") or uuid4().hex await websocket.accept() self._clients.setdefault(session_id, set()).add(websocket) - await websocket.send_json({"type": "session", "sessionId": session_id}) await websocket.send_json( - { - "type": "status", - "data": { + WebUISessionEvent(session_id=session_id).model_dump(mode="json", by_alias=True) + ) + await websocket.send_json( + WebUIStatusEvent( + data=WebUIStatusData.model_validate({ **self.status_payload, "ready": True, "frontendBuilt": self.frontend_dist_dir.exists(), - }, - } + }) + ).model_dump(mode="json", by_alias=True) ) await websocket.send_json( - { - "type": "privacy_snapshot", - "data": build_session_privacy_snapshot(f"{self.name}:{session_id}").model_dump(mode="json"), - } + WebUIPrivacySnapshotEvent( + data=build_session_privacy_snapshot(f"{self.name}:{session_id}"), + ).model_dump(mode="json", by_alias=True) ) try: while True: - payload = json.loads(await websocket.receive_text()) - content = str(payload.get("content", "")).strip() + try: + payload = WebUIUserMessage.model_validate_json(await websocket.receive_text()) + except ValidationError: + continue + content = payload.content.strip() if not content: continue await self._handle_message( @@ -150,6 +215,96 @@ async def webui_asset(path: str, request: Request): return app + def _session_title(self, session: Session) -> str: + smap = get_map(session.key) + for message in session.messages: + if message.get("role") != "user": + continue + content = self._message_text(message.get("content")) + if not content: + continue + title = " ".join(restore_tokens(content, smap).strip().split()) + if not title: + return "New chat" + return title[:47] + "..." if len(title) > 48 else title + return "New chat" + + def _history_messages( + self, + session: Session, + payloads: list[WebUIPrivacyPayload], + ) -> list[dict]: + smap = get_map(session.key) + messages = [] + assistant_payload_index = 0 + + for index, message in enumerate(session.messages[session.last_consolidated:]): + role = message.get("role") + if role not in {"user", "assistant"}: + continue + + content = self._message_text(message.get("content")) + if role == "assistant" and not content: + continue + + created_at = self._timestamp_ms(message.get("timestamp")) + restored, annotations = restore_tokens_with_annotations(content, smap) + entry = { + "id": f"{session.key}:{index}", + "role": role, + "content": restored, + "createdAt": created_at, + } + + if role == "assistant": + payload = payloads[assistant_payload_index] if assistant_payload_index < len(payloads) else None + assistant_payload_index += 1 + if payload is not None: + annotations = payload.privacy_annotations + entry["assistantStatus"] = { + "state": "done", + "startedAt": created_at, + "finishedAt": created_at, + "privacyTimeline": payload.privacy_timeline.model_dump(mode="json", by_alias=True), + } + else: + entry["assistantStatus"] = { + "state": "done", + "startedAt": created_at, + "finishedAt": created_at, + } + entry["privacyAnnotations"] = [ + annotation.model_dump(mode="json", by_alias=True) + for annotation in annotations + ] + + messages.append(entry) + + return messages + + @staticmethod + def _message_text(content: object) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and isinstance(block.get("text"), str): + parts.append(block["text"]) + return "\n".join(parts) + return "" + + @staticmethod + def _timestamp_ms(value: object) -> int: + if isinstance(value, datetime): + return int(value.timestamp() * 1000) + if isinstance(value, str) and value: + try: + return int(datetime.fromisoformat(value).timestamp() * 1000) + except ValueError: + pass + return int(datetime.now().timestamp() * 1000) + async def start(self) -> None: self._running = True logger.info("Starting WebUI channel on http://{}:{}", self.host, self.port) @@ -181,25 +336,25 @@ async def send(self, msg: OutboundMessage) -> None: if msg.metadata.get("_progress"): await self._broadcast( msg.chat_id, - { - "type": "progress", - "content": msg.content, - "toolHint": bool(msg.metadata.get("_tool_hint")), - }, + WebUIProgressEvent( + content=msg.content, + tool_hint=bool(msg.metadata.get("_tool_hint")), + ).model_dump(mode="json", by_alias=True), ) return + privacy_fields = self._privacy_event_fields(msg.metadata) await self._broadcast( msg.chat_id, - { - "type": "assistant_message", - "content": msg.content, - "privacy": msg.metadata.get("privacy"), - "privacyAnnotations": msg.metadata.get("privacyAnnotations"), - "privacyTurn": msg.metadata.get("privacyTurn"), - }, + WebUIAssistantMessageEvent( + content=msg.content, + **privacy_fields, + ).model_dump(mode="json", by_alias=True), + ) + await self._broadcast( + msg.chat_id, + WebUIAssistantDoneEvent(**privacy_fields).model_dump(mode="json", by_alias=True), ) - await self._broadcast(msg.chat_id, {"type": "assistant_done"}) async def send_delta( self, @@ -209,19 +364,40 @@ async def send_delta( ) -> None: meta = metadata or {} if meta.get("_stream_end"): + privacy_fields = self._privacy_event_fields(meta) await self._broadcast( chat_id, - { - "type": "assistant_done", - "privacy": meta.get("privacy"), - "privacyAnnotations": meta.get("privacyAnnotations"), - "privacyTurn": meta.get("privacyTurn"), - }, + WebUIAssistantDoneEvent(**privacy_fields).model_dump(mode="json", by_alias=True), ) return if delta: - await self._broadcast(chat_id, {"type": "assistant_delta", "content": delta}) + await self._broadcast( + chat_id, + WebUIAssistantDeltaEvent(content=delta).model_dump(mode="json", by_alias=True), + ) + + def _privacy_event_fields(self, metadata: dict[str, object]) -> dict[str, object]: + raw_payload = metadata.get(WEBUI_PRIVACY_METADATA_KEY) + if raw_payload is None: + return {} + + try: + payload = ( + raw_payload + if isinstance(raw_payload, WebUIPrivacyPayload) + else WebUIPrivacyPayload.model_validate(raw_payload) + ) + except ValidationError: + logger.warning("webui: invalid privacy payload skipped") + return {} + + return { + "privacy": payload.privacy, + "privacy_annotations": payload.privacy_annotations, + "privacy_turn": payload.privacy_turn, + "privacy_timeline": payload.privacy_timeline, + } async def _broadcast(self, chat_id: str, event: dict[str, object]) -> None: clients = list(self._clients.get(chat_id, set())) diff --git a/cloakbot/channels/wecom.py b/cloakbot/channels/wecom.py index 2f9834a2..4c93cd61 100644 --- a/cloakbot/channels/wecom.py +++ b/cloakbot/channels/wecom.py @@ -7,13 +7,13 @@ from typing import Any from loguru import logger +from pydantic import Field from cloakbot.bus.events import OutboundMessage from cloakbot.bus.queue import MessageBus from cloakbot.channels.base import BaseChannel from cloakbot.config.paths import get_media_dir from cloakbot.config.schema import Base -from pydantic import Field WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None diff --git a/cloakbot/cli/commands.py b/cloakbot/cli/commands.py index c162b498..849dbbb5 100644 --- a/cloakbot/cli/commands.py +++ b/cloakbot/cli/commands.py @@ -35,6 +35,16 @@ from rich.text import Text from cloakbot import __logo__, __version__ +from cloakbot.channels.webui import WebUIConfig +from cloakbot.cli.stream import StreamRenderer, ThinkingSpinner +from cloakbot.config.paths import get_workspace_path, is_default_workspace +from cloakbot.config.schema import Config +from cloakbot.utils.helpers import sync_workspace_templates +from cloakbot.utils.restart import ( + consume_restart_notice_from_env, + format_restart_completed_message, + should_show_cli_restart_notice, +) class SafeFileHistory(FileHistory): @@ -48,16 +58,6 @@ class SafeFileHistory(FileHistory): def store_string(self, string: str) -> None: safe = string.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") super().store_string(safe) -from cloakbot.cli.stream import StreamRenderer, ThinkingSpinner -from cloakbot.channels.webui import WebUIConfig -from cloakbot.config.paths import get_workspace_path, is_default_workspace -from cloakbot.config.schema import Config -from cloakbot.utils.helpers import sync_workspace_templates -from cloakbot.utils.restart import ( - consume_restart_notice_from_env, - format_restart_completed_message, - should_show_cli_restart_notice, -) app = typer.Typer( name="cloakbot", @@ -591,6 +591,7 @@ def serve( raise typer.Exit(1) from loguru import logger + from cloakbot.agent.loop import AgentLoop from cloakbot.api.server import create_app from cloakbot.bus.queue import MessageBus diff --git a/cloakbot/cli/onboard.py b/cloakbot/cli/onboard.py index 32695c85..3edffbfa 100644 --- a/cloakbot/cli/onboard.py +++ b/cloakbot/cli/onboard.py @@ -191,13 +191,13 @@ def _get_field_type_info(field_info) -> FieldTypeInfo: origin = get_origin(annotation) args = get_args(annotation) - _SIMPLE_TYPES: dict[type, str] = {bool: "bool", int: "int", float: "float"} + simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"} if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"): return FieldTypeInfo("list", args[0] if args else str) if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"): return FieldTypeInfo("dict", None) - for py_type, name in _SIMPLE_TYPES.items(): + for py_type, name in simple_types.items(): if annotation is py_type: return FieldTypeInfo(name, None) if isinstance(annotation, type) and issubclass(annotation, BaseModel): @@ -1004,7 +1004,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: return OnboardResult(config=original_config, should_save=False) continue - _MENU_DISPATCH = { + menu_dispatch = { "[P] LLM Provider": lambda: _configure_providers(config), "[C] Chat Channel": lambda: _configure_channels(config), "[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"), @@ -1018,6 +1018,6 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: if answer == "[X] Exit Without Saving": return OnboardResult(config=original_config, should_save=False) - action_fn = _MENU_DISPATCH.get(answer) + action_fn = menu_dispatch.get(answer) if action_fn: action_fn() diff --git a/cloakbot/command/builtin.py b/cloakbot/command/builtin.py index 009d8cee..4d04558c 100644 --- a/cloakbot/command/builtin.py +++ b/cloakbot/command/builtin.py @@ -60,7 +60,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: pass if ctx_est <= 0: ctx_est = loop._last_usage.get("prompt_tokens", 0) - + # Fetch web search provider usage (best-effort, never blocks the response) search_usage_text: str | None = None try: diff --git a/cloakbot/config/__init__.py b/cloakbot/config/__init__.py index fb7cdf87..1a080779 100644 --- a/cloakbot/config/__init__.py +++ b/cloakbot/config/__init__.py @@ -7,11 +7,11 @@ get_cron_dir, get_data_dir, get_legacy_sessions_dir, - is_default_workspace, get_logs_dir, get_media_dir, get_runtime_subdir, get_workspace_path, + is_default_workspace, ) from cloakbot.config.schema import Config diff --git a/cloakbot/config/paths.py b/cloakbot/config/paths.py index 49ae0aa8..3bcaf243 100644 --- a/cloakbot/config/paths.py +++ b/cloakbot/config/paths.py @@ -40,6 +40,14 @@ def get_workspace_path(workspace: str | None = None) -> Path: return ensure_dir(path) +def get_privacy_vault_dir(workspace: str | Path | None = None) -> Path: + """Return the workspace-scoped privacy vault directory.""" + base = Path(workspace).expanduser() if workspace is not None else get_workspace_path() + path = ensure_dir(base / "privacy_vault") + path.chmod(0o700) + return path + + def is_default_workspace(workspace: str | Path | None) -> bool: """Return whether a workspace resolves to cloakbot's default workspace path.""" current = Path(workspace).expanduser() if workspace is not None else Path.home() / ".cloakbot" / "workspace" diff --git a/cloakbot/cron/service.py b/cloakbot/cron/service.py index 4649f028..2e5056cd 100644 --- a/cloakbot/cron/service.py +++ b/cloakbot/cron/service.py @@ -10,7 +10,14 @@ from loguru import logger -from cloakbot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore +from cloakbot.cron.types import ( + CronJob, + CronJobState, + CronPayload, + CronRunRecord, + CronSchedule, + CronStore, +) def _now_ms() -> int: @@ -197,7 +204,7 @@ def _save_store(self) -> None: stat = self.store_path.stat() self._last_mtime_ns = stat.st_mtime_ns self._last_size = stat.st_size - + async def start(self) -> None: """Start the cron service.""" self._running = True diff --git a/cloakbot/privacy/agents/__init__.py b/cloakbot/privacy/agents/__init__.py index 873b909d..9cceb215 100644 --- a/cloakbot/privacy/agents/__init__.py +++ b/cloakbot/privacy/agents/__init__.py @@ -1,5 +1,8 @@ from cloakbot.privacy.agents.base import BaseAgent -from cloakbot.privacy.agents.classification.intent_analyzer import UserIntentAnalyzer, analyze_user_intent +from cloakbot.privacy.agents.classification.intent_analyzer import ( + UserIntentAnalyzer, + analyze_user_intent, +) from cloakbot.privacy.agents.workers.chat_agent import ChatAgent from cloakbot.privacy.agents.workers.math_agent import MathAgent diff --git a/cloakbot/privacy/core/math/math_executor.py b/cloakbot/privacy/core/math/math_executor.py index 633c66b4..6440dd95 100644 --- a/cloakbot/privacy/core/math/math_executor.py +++ b/cloakbot/privacy/core/math/math_executor.py @@ -1,8 +1,10 @@ from __future__ import annotations import re + from loguru import logger from pydantic import BaseModel + from cloakbot.privacy.core.math.math_helpers import ( execute_privacy_math, extract_python_snippets, diff --git a/cloakbot/privacy/core/sanitization/handler.py b/cloakbot/privacy/core/sanitization/handler.py index 3c2e5cb1..bf45e1e5 100644 --- a/cloakbot/privacy/core/sanitization/handler.py +++ b/cloakbot/privacy/core/sanitization/handler.py @@ -6,7 +6,7 @@ from cloakbot.privacy.core.sanitization.alias_resolver import resolve_existing_placeholder from cloakbot.privacy.core.state.vault import PLACEHOLDER_RE, _SessionMap -from cloakbot.privacy.core.types import REGISTRY, DetectionResult, ComputableEntity +from cloakbot.privacy.core.types import REGISTRY, ComputableEntity, DetectionResult _IS_PLACEHOLDER_RE = re.compile(r"^<<[A-Z]+(?:_[A-Z]+)*_\d+>>$") diff --git a/cloakbot/privacy/core/sanitization/restorer.py b/cloakbot/privacy/core/sanitization/restorer.py index 75e8d674..47f64db8 100644 --- a/cloakbot/privacy/core/sanitization/restorer.py +++ b/cloakbot/privacy/core/sanitization/restorer.py @@ -7,8 +7,8 @@ from pydantic import BaseModel from cloakbot.privacy.core.math.math_executor import LocalComputationRecord -from cloakbot.privacy.core.types import REGISTRY, Severity from cloakbot.privacy.core.state.vault import PLACEHOLDER_RE, _SessionMap +from cloakbot.privacy.core.types import REGISTRY, Severity class RestoredTokenAnnotation(BaseModel): diff --git a/cloakbot/privacy/core/sanitization/sanitize.py b/cloakbot/privacy/core/sanitization/sanitize.py index 2f9c1489..b51efa5a 100644 --- a/cloakbot/privacy/core/sanitization/sanitize.py +++ b/cloakbot/privacy/core/sanitization/sanitize.py @@ -5,10 +5,14 @@ from loguru import logger from cloakbot.privacy.core.detection.detector import PiiDetector -from cloakbot.privacy.core.types import DetectedEntity, DetectionResult from cloakbot.privacy.core.sanitization.handler import apply_tokens -from cloakbot.privacy.core.sanitization.restorer import RestoredTokenAnnotation, restore_tokens, restore_tokens_with_annotations +from cloakbot.privacy.core.sanitization.restorer import ( + RestoredTokenAnnotation, + restore_tokens, + restore_tokens_with_annotations, +) from cloakbot.privacy.core.state.vault import _SessionMap, get_map, save_map +from cloakbot.privacy.core.types import DetectedEntity, DetectionResult _detector = PiiDetector() diff --git a/cloakbot/privacy/core/state/vault.py b/cloakbot/privacy/core/state/vault.py index ea5d0722..07261398 100644 --- a/cloakbot/privacy/core/state/vault.py +++ b/cloakbot/privacy/core/state/vault.py @@ -12,6 +12,8 @@ from loguru import logger from pydantic import BaseModel, Field +from cloakbot.config.paths import get_privacy_vault_dir + # Canonical placeholder format: <