From d79f737485dcc859326288fe39405685cb9e51fa Mon Sep 17 00:00:00 2001 From: Laurie Luo Date: Fri, 24 Apr 2026 06:25:52 -0700 Subject: [PATCH] feat: add privacy-safe webui history view --- README.md | 4 +- README.zh-CN.md | 2 +- SECURITY.md | 4 +- cloakbot/__init__.py | 2 +- cloakbot/agent/context.py | 6 +- cloakbot/agent/loop.py | 73 ++- cloakbot/agent/memory.py | 14 +- cloakbot/agent/runner.py | 15 +- cloakbot/agent/subagent.py | 4 +- cloakbot/agent/tools/cron.py | 7 +- cloakbot/agent/tools/filesystem.py | 15 +- cloakbot/agent/tools/message.py | 2 +- cloakbot/agent/tools/registry.py | 8 +- cloakbot/agent/tools/sandbox.py | 6 +- cloakbot/agent/tools/shell.py | 4 +- cloakbot/agent/tools/web.py | 16 +- cloakbot/api/server.py | 4 +- cloakbot/channels/dingtalk.py | 15 +- cloakbot/channels/matrix.py | 19 +- cloakbot/channels/mochat.py | 2 +- cloakbot/channels/slack.py | 3 +- cloakbot/channels/telegram.py | 12 +- cloakbot/channels/webui.py | 248 ++++++++-- cloakbot/channels/wecom.py | 2 +- cloakbot/cli/commands.py | 21 +- cloakbot/cli/onboard.py | 8 +- cloakbot/command/builtin.py | 2 +- cloakbot/config/__init__.py | 2 +- cloakbot/config/paths.py | 8 + cloakbot/cron/service.py | 11 +- cloakbot/privacy/agents/__init__.py | 5 +- cloakbot/privacy/core/math/math_executor.py | 2 + cloakbot/privacy/core/sanitization/handler.py | 2 +- .../privacy/core/sanitization/restorer.py | 2 +- .../privacy/core/sanitization/sanitize.py | 8 +- cloakbot/privacy/core/state/vault.py | 13 +- cloakbot/privacy/core/types.py | 3 +- cloakbot/privacy/protocol/__init__.py | 2 +- cloakbot/privacy/transparency/report.py | 2 +- cloakbot/privacy/webui/__init__.py | 41 ++ cloakbot/privacy/webui/builders.py | 53 +++ cloakbot/privacy/webui/contracts.py | 104 +++++ cloakbot/privacy/webui/history.py | 70 +++ cloakbot/providers/__init__.py | 2 +- .../providers/openai_responses/parsing.py | 2 +- .../skill-creator/scripts/package_skill.py | 4 +- cloakbot/utils/helpers.py | 4 +- docs/CHANNEL_PLUGIN_GUIDE.md | 384 --------------- docs/MEMORY.md | 191 -------- docs/PYTHON_SDK.md | 138 ------ ...04-21-protocolized-collaboration-design.md | 439 ------------------ pyproject.toml | 2 +- tests/channels/test_webui_history.py | 63 +++ tests/config/test_config_paths.py | 5 + tests/privacy/webui/test_builders.py | 34 ++ tests/privacy/webui/test_history.py | 40 ++ webui/src/app/layout/AppShell.tsx | 61 ++- .../chat/components/MessageList.test.tsx | 50 +- .../features/chat/components/MessageList.tsx | 143 +++++- .../chat/hooks/use-chat-session.test.tsx | 1 + .../features/chat/hooks/use-chat-session.ts | 115 ++++- .../chat/services/chat-socket.test.ts | 24 +- .../src/features/chat/services/chat-socket.ts | 22 +- webui/src/features/chat/types.ts | 17 + .../navigation/components/NavigationPanel.tsx | 90 ++++ .../privacy/lib/annotated-markdown.tsx | 25 +- webui/src/features/privacy/types.ts | 20 + webui/src/index.css | 3 + 68 files changed, 1319 insertions(+), 1406 deletions(-) create mode 100644 cloakbot/privacy/webui/__init__.py create mode 100644 cloakbot/privacy/webui/builders.py create mode 100644 cloakbot/privacy/webui/contracts.py create mode 100644 cloakbot/privacy/webui/history.py delete mode 100644 docs/CHANNEL_PLUGIN_GUIDE.md delete mode 100644 docs/MEMORY.md delete mode 100644 docs/PYTHON_SDK.md delete mode 100644 docs/superpowers/specs/2026-04-21-protocolized-collaboration-design.md create mode 100644 tests/channels/test_webui_history.py create mode 100644 tests/privacy/webui/test_builders.py create mode 100644 tests/privacy/webui/test_history.py 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: <> PLACEHOLDER_RE = re.compile(r"<<[A-Z]+(?:_[A-Z]+)*_\d+>>") _TOKEN_RE = re.compile(r"^<<([A-Z]+(?:_[A-Z]+)*)_(\d+)>>$") @@ -235,6 +237,15 @@ def display_value(self, placeholder: str) -> str: _cache: dict[str, _SessionMap] = {} +_workspace: Path | None = None + + +def set_vault_workspace(workspace: str | Path) -> None: + global _workspace + next_workspace = Path(workspace).expanduser() + if _workspace != next_workspace: + _cache.clear() + _workspace = next_workspace def _safe_key(session_key: str) -> str: @@ -242,7 +253,7 @@ def _safe_key(session_key: str) -> str: def _map_path(session_key: str) -> Path: - maps_dir = Path.home() / ".cloakbot" / "sanitizer_maps" + maps_dir = get_privacy_vault_dir(_workspace) / "maps" maps_dir.mkdir(parents=True, exist_ok=True) return maps_dir / f"{_safe_key(session_key)}.json" diff --git a/cloakbot/privacy/core/types.py b/cloakbot/privacy/core/types.py index a00898e2..f1936a58 100644 --- a/cloakbot/privacy/core/types.py +++ b/cloakbot/privacy/core/types.py @@ -1,7 +1,8 @@ from __future__ import annotations from enum import Enum -from typing import List, Dict, Union +from typing import Dict, List, Union + from pydantic import BaseModel, computed_field diff --git a/cloakbot/privacy/protocol/__init__.py b/cloakbot/privacy/protocol/__init__.py index 9ff43e96..cbb549cc 100644 --- a/cloakbot/privacy/protocol/__init__.py +++ b/cloakbot/privacy/protocol/__init__.py @@ -6,8 +6,8 @@ PrivacyStage, ProtocolStatus, ToolInvocationContract, - TurnContract, TurnContextPayload, + TurnContract, ) from cloakbot.privacy.protocol.observability import emit_event, get_event_sink diff --git a/cloakbot/privacy/transparency/report.py b/cloakbot/privacy/transparency/report.py index 11899a4a..85933b4f 100644 --- a/cloakbot/privacy/transparency/report.py +++ b/cloakbot/privacy/transparency/report.py @@ -4,8 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field -from cloakbot.privacy.core.types import DetectedEntity, REGISTRY, Severity from cloakbot.privacy.core.state.vault import PLACEHOLDER_RE, get_map +from cloakbot.privacy.core.types import REGISTRY, DetectedEntity, Severity from cloakbot.privacy.hooks.context import TurnContext diff --git a/cloakbot/privacy/webui/__init__.py b/cloakbot/privacy/webui/__init__.py new file mode 100644 index 00000000..efbac687 --- /dev/null +++ b/cloakbot/privacy/webui/__init__.py @@ -0,0 +1,41 @@ +from cloakbot.privacy.webui.builders import ( + build_webui_privacy_payload, + build_webui_privacy_timeline, + build_webui_privacy_turn, +) +from cloakbot.privacy.webui.contracts import ( + WEBUI_PRIVACY_METADATA_KEY, + WebUIAssistantDeltaEvent, + WebUIAssistantDoneEvent, + WebUIAssistantMessageEvent, + WebUIPrivacyPayload, + WebUIPrivacySnapshotEvent, + WebUIPrivacyTimeline, + WebUIPrivacyTimelineEvent, + WebUIPrivacyTurn, + WebUIProgressEvent, + WebUISessionEvent, + WebUIStatusData, + WebUIStatusEvent, + WebUIUserMessage, +) + +__all__ = [ + "WEBUI_PRIVACY_METADATA_KEY", + "WebUIAssistantDeltaEvent", + "WebUIAssistantDoneEvent", + "WebUIAssistantMessageEvent", + "WebUIPrivacyPayload", + "WebUIPrivacySnapshotEvent", + "WebUIPrivacyTimeline", + "WebUIPrivacyTimelineEvent", + "WebUIPrivacyTurn", + "WebUIProgressEvent", + "WebUISessionEvent", + "WebUIStatusData", + "WebUIStatusEvent", + "WebUIUserMessage", + "build_webui_privacy_payload", + "build_webui_privacy_timeline", + "build_webui_privacy_turn", +] diff --git a/cloakbot/privacy/webui/builders.py b/cloakbot/privacy/webui/builders.py new file mode 100644 index 00000000..8d677253 --- /dev/null +++ b/cloakbot/privacy/webui/builders.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from cloakbot.privacy.hooks.context import TurnContext +from cloakbot.privacy.protocol.replay import build_turn_timeline +from cloakbot.privacy.transparency.report import build_session_privacy_snapshot +from cloakbot.privacy.webui.contracts import ( + WebUIPrivacyPayload, + WebUIPrivacyTimeline, + WebUIPrivacyTimelineEvent, + WebUIPrivacyTurn, +) + + +def build_webui_privacy_turn(ctx: TurnContext) -> WebUIPrivacyTurn: + return WebUIPrivacyTurn( + turn_id=ctx.turn_id, + intent=ctx.intent.value, + remote_prompt=ctx.sanitized_input, + local_computations=ctx.local_computations, + ) + + +def build_webui_privacy_timeline(session_key: str, ctx: TurnContext) -> WebUIPrivacyTimeline: + timeline = build_turn_timeline(session_key, ctx.turn_id) + return WebUIPrivacyTimeline( + turn_id=ctx.turn_id, + trace_id=timeline.trace_id, + total_duration_ms=timeline.total_duration_ms, + stage_durations_ms=timeline.stage_durations_ms, + events=[ + WebUIPrivacyTimelineEvent( + event_type=event.event_type.value, + sequence=event.sequence, + stage=event.stage.value, + status=event.status.value, + span_id=event.span_id, + parent_span_id=event.parent_span_id, + timestamp=event.timestamp, + duration_ms=event.duration_ms, + payload=event.payload, + ) + for event in timeline.events + ], + ) + + +def build_webui_privacy_payload(session_key: str, ctx: TurnContext) -> WebUIPrivacyPayload: + return WebUIPrivacyPayload( + privacy=build_session_privacy_snapshot(session_key), + privacy_annotations=ctx.display_output_annotations, + privacy_turn=build_webui_privacy_turn(ctx), + privacy_timeline=build_webui_privacy_timeline(session_key, ctx), + ) diff --git a/cloakbot/privacy/webui/contracts.py b/cloakbot/privacy/webui/contracts.py new file mode 100644 index 00000000..d7bba074 --- /dev/null +++ b/cloakbot/privacy/webui/contracts.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from cloakbot.privacy.core.math.math_executor import LocalComputationRecord +from cloakbot.privacy.core.sanitization.restorer import RestoredTokenAnnotation +from cloakbot.privacy.transparency.report import SessionPrivacySnapshot + +WEBUI_PRIVACY_METADATA_KEY = "webuiPrivacy" + + +class WebUIModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class WebUIUserMessage(WebUIModel): + content: str + + +class WebUIStatusData(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + ready: bool + frontend_built: bool = Field(alias="frontendBuilt") + + +class WebUIPrivacyTurn(WebUIModel): + turn_id: str = Field(alias="turnId") + intent: Literal["chat", "math", "doc"] + remote_prompt: str = Field(alias="remotePrompt") + local_computations: list[LocalComputationRecord] = Field(alias="localComputations") + + +class WebUIPrivacyTimelineEvent(WebUIModel): + event_type: str = Field(alias="eventType") + sequence: int + stage: str + status: str + span_id: str = Field(alias="spanId") + parent_span_id: str | None = Field(default=None, alias="parentSpanId") + timestamp: datetime + duration_ms: int | None = Field(default=None, alias="durationMs") + payload: dict[str, Any] + + +class WebUIPrivacyTimeline(WebUIModel): + turn_id: str = Field(alias="turnId") + trace_id: str = Field(alias="traceId") + total_duration_ms: int = Field(alias="totalDurationMs") + stage_durations_ms: dict[str, int] = Field(alias="stageDurationsMs") + events: list[WebUIPrivacyTimelineEvent] + + +class WebUIPrivacyPayload(WebUIModel): + privacy: SessionPrivacySnapshot + privacy_annotations: list[RestoredTokenAnnotation] = Field(alias="privacyAnnotations") + privacy_turn: WebUIPrivacyTurn = Field(alias="privacyTurn") + privacy_timeline: WebUIPrivacyTimeline = Field(alias="privacyTimeline") + + +class WebUISessionEvent(WebUIModel): + type: Literal["session"] = "session" + session_id: str = Field(alias="sessionId") + + +class WebUIStatusEvent(WebUIModel): + type: Literal["status"] = "status" + data: WebUIStatusData + + +class WebUIPrivacySnapshotEvent(WebUIModel): + type: Literal["privacy_snapshot"] = "privacy_snapshot" + data: SessionPrivacySnapshot + + +class WebUIProgressEvent(WebUIModel): + type: Literal["progress"] = "progress" + content: str + tool_hint: bool = Field(alias="toolHint") + + +class WebUIAssistantMessageEvent(WebUIModel): + type: Literal["assistant_message"] = "assistant_message" + content: str + privacy: SessionPrivacySnapshot | None = None + privacy_annotations: list[RestoredTokenAnnotation] | None = Field(default=None, alias="privacyAnnotations") + privacy_turn: WebUIPrivacyTurn | None = Field(default=None, alias="privacyTurn") + privacy_timeline: WebUIPrivacyTimeline | None = Field(default=None, alias="privacyTimeline") + + +class WebUIAssistantDeltaEvent(WebUIModel): + type: Literal["assistant_delta"] = "assistant_delta" + content: str + + +class WebUIAssistantDoneEvent(WebUIModel): + type: Literal["assistant_done"] = "assistant_done" + privacy: SessionPrivacySnapshot | None = None + privacy_annotations: list[RestoredTokenAnnotation] | None = Field(default=None, alias="privacyAnnotations") + privacy_turn: WebUIPrivacyTurn | None = Field(default=None, alias="privacyTurn") + privacy_timeline: WebUIPrivacyTimeline | None = Field(default=None, alias="privacyTimeline") diff --git a/cloakbot/privacy/webui/history.py b/cloakbot/privacy/webui/history.py new file mode 100644 index 00000000..5a74bb35 --- /dev/null +++ b/cloakbot/privacy/webui/history.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from cloakbot.config.paths import get_privacy_vault_dir +from cloakbot.privacy.core.state.vault import _safe_key +from cloakbot.privacy.webui.contracts import WebUIPrivacyPayload + + +def _turns_path(workspace: str | Path, session_key: str) -> Path: + turns_dir = get_privacy_vault_dir(workspace) / "turns" + turns_dir.mkdir(parents=True, exist_ok=True) + return turns_dir / f"{_safe_key(session_key)}.jsonl" + + +def append_webui_privacy_payload( + workspace: str | Path, + session_key: str, + payload: WebUIPrivacyPayload, +) -> None: + path = _turns_path(workspace, session_key) + line = payload.model_dump_json(by_alias=True) + with open(path, "a", encoding="utf-8") as f: + f.write(line + "\n") + + +def load_webui_privacy_payloads( + workspace: str | Path, + session_key: str, +) -> list[WebUIPrivacyPayload]: + path = _turns_path(workspace, session_key) + if not path.exists(): + return [] + + payloads: list[WebUIPrivacyPayload] = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + payloads.append(WebUIPrivacyPayload.model_validate_json(line)) + return payloads + + +def replace_webui_privacy_payloads( + workspace: str | Path, + session_key: str, + payloads: list[WebUIPrivacyPayload], +) -> None: + path = _turns_path(workspace, session_key) + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f"{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp_path = Path(tmp.name) + for payload in payloads: + tmp.write(json.dumps(payload.model_dump(mode="json", by_alias=True), ensure_ascii=False) + "\n") + tmp_path.replace(path) + except Exception: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + raise diff --git a/cloakbot/providers/__init__.py b/cloakbot/providers/__init__.py index 48288362..52af4081 100644 --- a/cloakbot/providers/__init__.py +++ b/cloakbot/providers/__init__.py @@ -29,8 +29,8 @@ from cloakbot.providers.anthropic_provider import AnthropicProvider from cloakbot.providers.azure_openai_provider import AzureOpenAIProvider from cloakbot.providers.github_copilot_provider import GitHubCopilotProvider - from cloakbot.providers.openai_compat_provider import OpenAICompatProvider from cloakbot.providers.openai_codex_provider import OpenAICodexProvider + from cloakbot.providers.openai_compat_provider import OpenAICompatProvider def __getattr__(name: str): diff --git a/cloakbot/providers/openai_responses/parsing.py b/cloakbot/providers/openai_responses/parsing.py index c41593d8..4d17df18 100644 --- a/cloakbot/providers/openai_responses/parsing.py +++ b/cloakbot/providers/openai_responses/parsing.py @@ -30,7 +30,7 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N buffer: list[str] = [] def _flush() -> dict[str, Any] | None: - data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")] + data_lines = [line[5:].strip() for line in buffer if line.startswith("data:")] buffer.clear() if not data_lines: return None diff --git a/cloakbot/skills/skill-creator/scripts/package_skill.py b/cloakbot/skills/skill-creator/scripts/package_skill.py index 48fcbbe5..4135cda8 100755 --- a/cloakbot/skills/skill-creator/scripts/package_skill.py +++ b/cloakbot/skills/skill-creator/scripts/package_skill.py @@ -80,7 +80,7 @@ def package_skill(skill_path, output_dir=None): skill_filename = output_path / f"{skill_name}.skill" - EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + excluded_dirs = {".git", ".svn", ".hg", "__pycache__", "node_modules"} files_to_package = [] resolved_archive = skill_filename.resolve() @@ -93,7 +93,7 @@ def package_skill(skill_path, output_dir=None): return None rel_parts = file_path.relative_to(skill_path).parts - if any(part in EXCLUDED_DIRS for part in rel_parts): + if any(part in excluded_dirs for part in rel_parts): continue if file_path.is_file(): diff --git a/cloakbot/utils/helpers.py b/cloakbot/utils/helpers.py index a65d35fb..dbb24e70 100644 --- a/cloakbot/utils/helpers.py +++ b/cloakbot/utils/helpers.py @@ -399,7 +399,7 @@ def build_status_content( search_usage_text: str | None = None, ) -> str: """Build a human-readable runtime status snapshot. - + Args: search_usage_text: Optional pre-formatted web search usage string (produced by SearchUsageInfo.format()). When provided @@ -431,7 +431,7 @@ def build_status_content( ] if search_usage_text: lines.append(search_usage_text) - return "\n".join(lines) + return "\n".join(lines) def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: diff --git a/docs/CHANNEL_PLUGIN_GUIDE.md b/docs/CHANNEL_PLUGIN_GUIDE.md deleted file mode 100644 index 7e350ca8..00000000 --- a/docs/CHANNEL_PLUGIN_GUIDE.md +++ /dev/null @@ -1,384 +0,0 @@ -# Channel Plugin Guide - -Build a custom cloakbot channel in three steps: subclass, package, install. - -> **Note:** We recommend developing channel plugins against a source checkout of cloakbot (`pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs. - -## How It Works - -cloakbot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `cloakbot gateway` starts, it scans: - -1. Built-in channels in `cloakbot/channels/` -2. External packages registered under the `cloakbot.channels` entry point group - -If a matching config section has `"enabled": true`, the channel is instantiated and started. - -## Quick Start - -We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back. - -### Project Structure - -``` -cloakbot-channel-webhook/ -├── cloakbot_channel_webhook/ -│ ├── __init__.py # re-export WebhookChannel -│ └── channel.py # channel implementation -└── pyproject.toml -``` - -### 1. Create Your Channel - -```python -# cloakbot_channel_webhook/__init__.py -from cloakbot_channel_webhook.channel import WebhookChannel - -__all__ = ["WebhookChannel"] -``` - -```python -# cloakbot_channel_webhook/channel.py -import asyncio -from typing import Any - -from aiohttp import web -from loguru import logger - -from cloakbot.channels.base import BaseChannel -from cloakbot.bus.events import OutboundMessage - - -class WebhookChannel(BaseChannel): - name = "webhook" - display_name = "Webhook" - - @classmethod - def default_config(cls) -> dict[str, Any]: - return {"enabled": False, "port": 9000, "allowFrom": []} - - async def start(self) -> None: - """Start an HTTP server that listens for incoming messages. - - IMPORTANT: start() must block forever (or until stop() is called). - If it returns, the channel is considered dead. - """ - self._running = True - port = self.config.get("port", 9000) - - app = web.Application() - app.router.add_post("/message", self._on_request) - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, "0.0.0.0", port) - await site.start() - logger.info("Webhook listening on :{}", port) - - # Block until stopped - while self._running: - await asyncio.sleep(1) - - await runner.cleanup() - - async def stop(self) -> None: - self._running = False - - async def send(self, msg: OutboundMessage) -> None: - """Deliver an outbound message. - - msg.content — markdown text (convert to platform format as needed) - msg.media — list of local file paths to attach - msg.chat_id — the recipient (same chat_id you passed to _handle_message) - msg.metadata — may contain "_progress": True for streaming chunks - """ - logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80]) - # In a real plugin: POST to a callback URL, send via SDK, etc. - - async def _on_request(self, request: web.Request) -> web.Response: - """Handle an incoming HTTP POST.""" - body = await request.json() - sender = body.get("sender", "unknown") - chat_id = body.get("chat_id", sender) - text = body.get("text", "") - media = body.get("media", []) # list of URLs - - # This is the key call: validates allowFrom, then puts the - # message onto the bus for the agent to process. - await self._handle_message( - sender_id=sender, - chat_id=chat_id, - content=text, - media=media, - ) - - return web.json_response({"ok": True}) -``` - -### 2. Register the Entry Point - -```toml -# pyproject.toml -[project] -name = "cloakbot-channel-webhook" -version = "0.1.0" -dependencies = ["cloakbot", "aiohttp"] - -[project.entry-points."cloakbot.channels"] -webhook = "cloakbot_channel_webhook:WebhookChannel" - -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.backends._legacy:_Backend" -``` - -The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass. - -### 3. Install & Configure - -```bash -pip install -e . -cloakbot plugins list # verify "Webhook" shows as "plugin" -cloakbot onboard # auto-adds default config for detected plugins -``` - -Edit `~/.cloakbot/config.json`: - -```json -{ - "channels": { - "webhook": { - "enabled": true, - "port": 9000, - "allowFrom": ["*"] - } - } -} -``` - -### 4. Run & Test - -```bash -cloakbot gateway -``` - -In another terminal: - -```bash -curl -X POST http://localhost:9000/message \ - -H "Content-Type: application/json" \ - -d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}' -``` - -The agent receives the message and processes it. Replies arrive in your `send()` method. - -## BaseChannel API - -### Required (abstract) - -| Method | Description | -|--------|-------------| -| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. | -| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. | -| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. | - -### Interactive Login - -If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`: - -```python -async def login(self, force: bool = False) -> bool: - """ - Perform channel-specific interactive login. - - Args: - force: If True, ignore existing credentials and re-authenticate. - - Returns True if already authenticated or login succeeds. - """ - # For QR-code-based login: - # 1. If force, clear saved credentials - # 2. Check if already authenticated (load from disk/state) - # 3. If not, show QR code and poll for confirmation - # 4. Save token on success -``` - -Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`. - -Users trigger interactive login via: -```bash -cloakbot channels login -cloakbot channels login --force # re-authenticate -``` - -### Provided by Base - -| Method / Property | Description | -|-------------------|-------------| -| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. | -| `is_allowed(sender_id)` | Checks against `config["allowFrom"]`; `"*"` allows all, `[]` denies all. | -| `default_config()` (classmethod) | Returns default config dict for `cloakbot onboard`. Override to declare your fields. | -| `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). | -| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. | -| `is_running` | Returns `self._running`. | -| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. | - -### Optional (streaming) - -| Method | Description | -|--------|-------------| -| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. | - -### Message Types - -```python -@dataclass -class OutboundMessage: - channel: str # your channel name - chat_id: str # recipient (same value you passed to _handle_message) - content: str # markdown text — convert to platform format as needed - media: list[str] # local file paths to attach (images, audio, docs) - metadata: dict # may contain: "_progress" (bool) for streaming chunks, - # "message_id" for reply threading -``` - -## Streaming Support - -Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it. - -### How It Works - -When **both** conditions are met, the agent streams content through your channel: - -1. Config has `"streaming": true` -2. Your subclass overrides `send_delta()` - -If either is missing, the agent falls back to the normal one-shot `send()` path. - -### Implementing `send_delta` - -Override `send_delta` to handle two types of calls: - -```python -async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: - meta = metadata or {} - - if meta.get("_stream_end"): - # Streaming finished — do final formatting, cleanup, etc. - return - - # Regular delta — append text, update the message on screen - # delta contains a small chunk of text (a few tokens) -``` - -**Metadata flags:** - -| Flag | Meaning | -|------|---------| -| `_stream_delta: True` | A content chunk (delta contains the new text) | -| `_stream_end: True` | Streaming finished (delta is empty) | -| `_resuming: True` | More streaming rounds coming (e.g. tool call then another response) | - -### Example: Webhook with Streaming - -```python -class WebhookChannel(BaseChannel): - name = "webhook" - display_name = "Webhook" - - def __init__(self, config, bus): - super().__init__(config, bus) - self._buffers: dict[str, str] = {} - - async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: - meta = metadata or {} - if meta.get("_stream_end"): - text = self._buffers.pop(chat_id, "") - # Final delivery — format and send the complete message - await self._deliver(chat_id, text, final=True) - return - - self._buffers.setdefault(chat_id, "") - self._buffers[chat_id] += delta - # Incremental update — push partial text to the client - await self._deliver(chat_id, self._buffers[chat_id], final=False) - - async def send(self, msg: OutboundMessage) -> None: - # Non-streaming path — unchanged - await self._deliver(msg.chat_id, msg.content, final=True) -``` - -### Config - -Enable streaming per channel: - -```json -{ - "channels": { - "webhook": { - "enabled": true, - "streaming": true, - "allowFrom": ["*"] - } - } -} -``` - -When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead. - -### BaseChannel Streaming API - -| Method / Property | Description | -|-------------------|-------------| -| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. | -| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. | - -## Config - -Your channel receives config as a plain `dict`. Access fields with `.get()`: - -```python -async def start(self) -> None: - port = self.config.get("port", 9000) - token = self.config.get("token", "") -``` - -`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself. - -Override `default_config()` so `cloakbot onboard` auto-populates `config.json`: - -```python -@classmethod -def default_config(cls) -> dict[str, Any]: - return {"enabled": False, "port": 9000, "allowFrom": []} -``` - -If not overridden, the base class returns `{"enabled": false}`. - -## Naming Convention - -| What | Format | Example | -|------|--------|---------| -| PyPI package | `cloakbot-channel-{name}` | `cloakbot-channel-webhook` | -| Entry point key | `{name}` | `webhook` | -| Config section | `channels.{name}` | `channels.webhook` | -| Python package | `cloakbot_channel_{name}` | `cloakbot_channel_webhook` | - -## Local Development - -```bash -git clone https://github.com/you/cloakbot-channel-webhook -cd cloakbot-channel-webhook -pip install -e . -cloakbot plugins list # should show "Webhook" as "plugin" -cloakbot gateway # test end-to-end -``` - -## Verify - -```bash -$ cloakbot plugins list - - Name Source Enabled - telegram builtin yes - discord builtin no - webhook plugin yes -``` diff --git a/docs/MEMORY.md b/docs/MEMORY.md deleted file mode 100644 index 8459e07d..00000000 --- a/docs/MEMORY.md +++ /dev/null @@ -1,191 +0,0 @@ -# Memory in cloakbot - -> **Note:** This design is currently an experiment in the latest source code version and is planned to officially ship in `v0.1.5`. - -cloakbot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic. - -Good memory is not a pile of notes. It is a quiet system of attention. It notices what is worth keeping, lets go of what no longer needs the spotlight, and turns lived experience into something calm, durable, and useful. - -That is the shape of memory in cloakbot. - -## The Design - -cloakbot does not treat memory as one giant file. - -It separates memory into layers, because different kinds of remembering deserve different tools: - -- `session.messages` holds the living short-term conversation. -- `memory/history.jsonl` is the running archive of compressed past turns. -- `SOUL.md`, `USER.md`, and `memory/MEMORY.md` are the durable knowledge files. -- `GitStore` records how those durable files change over time. - -This keeps the system light in the moment, but reflective over time. - -## The Flow - -Memory moves through cloakbot in two stages. - -### Stage 1: Consolidator - -When a conversation grows large enough to pressure the context window, cloakbot does not try to carry every old message forever. - -Instead, the `Consolidator` summarizes the oldest safe slice of the conversation and appends that summary to `memory/history.jsonl`. - -This file is: - -- append-only -- cursor-based -- optimized for machine consumption first, human inspection second - -Each line is a JSON object: - -```json -{"cursor": 42, "timestamp": "2026-04-03 00:02", "content": "- User prefers dark mode\n- Decided to use PostgreSQL"} -``` - -It is not the final memory. It is the material from which final memory is shaped. - -### Stage 2: Dream - -`Dream` is the slower, more thoughtful layer. It runs on a cron schedule by default and can also be triggered manually. - -Dream reads: - -- new entries from `memory/history.jsonl` -- the current `SOUL.md` -- the current `USER.md` -- the current `memory/MEMORY.md` - -Then it works in two phases: - -1. It studies what is new and what is already known. -2. It edits the long-term files surgically, not by rewriting everything, but by making the smallest honest change that keeps memory coherent. - -This is why cloakbot's memory is not just archival. It is interpretive. - -## The Files - -``` -workspace/ -├── SOUL.md # The bot's long-term voice and communication style -├── USER.md # Stable knowledge about the user -└── memory/ - ├── MEMORY.md # Project facts, decisions, and durable context - ├── history.jsonl # Append-only history summaries - ├── .cursor # Consolidator write cursor - ├── .dream_cursor # Dream consumption cursor - └── .git/ # Version history for long-term memory files -``` - -These files play different roles: - -- `SOUL.md` remembers how cloakbot should sound. -- `USER.md` remembers who the user is and what they prefer. -- `MEMORY.md` remembers what remains true about the work itself. -- `history.jsonl` remembers what happened on the way there. - -## Why `history.jsonl` - -The old `HISTORY.md` format was pleasant for casual reading, but it was too fragile as an operational substrate. - -`history.jsonl` gives cloakbot: - -- stable incremental cursors -- safer machine parsing -- easier batching -- cleaner migration and compaction -- a better boundary between raw history and curated knowledge - -You can still search it with familiar tools: - -```bash -# grep -grep -i "keyword" memory/history.jsonl - -# jq -cat memory/history.jsonl | jq -r 'select(.content | test("keyword"; "i")) | .content' | tail -20 - -# Python -python -c "import json; [print(json.loads(l).get('content','')) for l in open('memory/history.jsonl','r',encoding='utf-8') if l.strip() and 'keyword' in l.lower()][-20:]" -``` - -The difference is philosophical as much as technical: - -- `history.jsonl` is for structure -- `SOUL.md`, `USER.md`, and `MEMORY.md` are for meaning - -## Commands - -Memory is not hidden behind the curtain. Users can inspect and guide it. - -| Command | What it does | -|---------|--------------| -| `/dream` | Run Dream immediately | -| `/dream-log` | Show the latest Dream memory change | -| `/dream-log ` | Show a specific Dream change | -| `/dream-restore` | List recent Dream memory versions | -| `/dream-restore ` | Restore memory to the state before a specific change | - -These commands exist for a reason: automatic memory is powerful, but users should always retain the right to inspect, understand, and restore it. - -## Versioned Memory - -After Dream changes long-term memory files, cloakbot can record that change with `GitStore`. - -This gives memory a history of its own: - -- you can inspect what changed -- you can compare versions -- you can restore a previous state - -That turns memory from a silent mutation into an auditable process. - -## Configuration - -Dream is configured under `agents.defaults.dream`: - -```json -{ - "agents": { - "defaults": { - "dream": { - "intervalH": 2, - "modelOverride": null, - "maxBatchSize": 20, - "maxIterations": 10 - } - } - } -} -``` - -| Field | Meaning | -|-------|---------| -| `intervalH` | How often Dream runs, in hours | -| `modelOverride` | Optional Dream-specific model override | -| `maxBatchSize` | How many history entries Dream processes per run | -| `maxIterations` | The tool budget for Dream's editing phase | - -In practical terms: - -- `modelOverride: null` means Dream uses the same model as the main agent. Set it only if you want Dream to run on a different model. -- `maxBatchSize` controls how many new `history.jsonl` entries Dream consumes in one run. Larger batches catch up faster; smaller batches are lighter and steadier. -- `maxIterations` limits how many read/edit steps Dream can take while updating `SOUL.md`, `USER.md`, and `MEMORY.md`. It is a safety budget, not a quality score. -- `intervalH` is the normal way to configure Dream. Internally it runs as an `every` schedule, not as a cron expression. - -Legacy note: - -- Older source-based configs may still contain `dream.cron`. cloakbot continues to honor it for backward compatibility, but new configs should use `intervalH`. -- Older source-based configs may still contain `dream.model`. cloakbot continues to honor it for backward compatibility, but new configs should use `modelOverride`. - -## In Practice - -What this means in daily use is simple: - -- conversations can stay fast without carrying infinite context -- durable facts can become clearer over time instead of noisier -- the user can inspect and restore memory when needed - -Memory should not feel like a dump. It should feel like continuity. - -That is what this design is trying to protect. diff --git a/docs/PYTHON_SDK.md b/docs/PYTHON_SDK.md deleted file mode 100644 index 44e041d7..00000000 --- a/docs/PYTHON_SDK.md +++ /dev/null @@ -1,138 +0,0 @@ -# Python SDK - -> **Note:** This interface is currently an experiment in the latest source code version and is planned to officially ship in `v0.1.5`. - -Use cloakbot programmatically — load config, run the agent, get results. - -## Quick Start - -```python -import asyncio -from cloakbot import Cloakbot - -async def main(): - bot = Cloakbot.from_config() - result = await bot.run("What time is it in Tokyo?") - print(result.content) - -asyncio.run(main()) -``` - -## API - -### `Cloakbot.from_config(config_path?, *, workspace?)` - -Create a `Cloakbot` from a config file. - -| Param | Type | Default | Description | -|-------|------|---------|-------------| -| `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.cloakbot/config.json`. | -| `workspace` | `str \| Path \| None` | `None` | Override workspace directory from config. | - -Raises `FileNotFoundError` if an explicit path doesn't exist. - -### `await bot.run(message, *, session_key?, hooks?)` - -Run the agent once. Returns a `RunResult`. - -| Param | Type | Default | Description | -|-------|------|---------|-------------| -| `message` | `str` | *(required)* | The user message to process. | -| `session_key` | `str` | `"sdk:default"` | Session identifier for conversation isolation. Different keys get independent history. | -| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. | - -```python -# Isolated sessions — each user gets independent conversation history -await bot.run("hi", session_key="user-alice") -await bot.run("hi", session_key="user-bob") -``` - -### `RunResult` - -| Field | Type | Description | -|-------|------|-------------| -| `content` | `str` | The agent's final text response. | -| `tools_used` | `list[str]` | Tool names invoked during the run. | -| `messages` | `list[dict]` | Raw message history (for debugging). | - -## Hooks - -Hooks let you observe or modify the agent loop without touching internals. - -Subclass `AgentHook` and override any method: - -| Method | When | -|--------|------| -| `before_iteration(ctx)` | Before each LLM call | -| `on_stream(ctx, delta)` | On each streamed token | -| `on_stream_end(ctx)` | When streaming finishes | -| `before_execute_tools(ctx)` | Before tool execution (inspect `ctx.tool_calls`) | -| `after_iteration(ctx, response)` | After each LLM response | -| `finalize_content(ctx, content)` | Transform final output text | - -### Example: Audit Hook - -```python -from cloakbot.agent import AgentHook, AgentHookContext - -class AuditHook(AgentHook): - def __init__(self): - self.calls = [] - - async def before_execute_tools(self, ctx: AgentHookContext) -> None: - for tc in ctx.tool_calls: - self.calls.append(tc.name) - print(f"[audit] {tc.name}({tc.arguments})") - -hook = AuditHook() -result = await bot.run("List files in /tmp", hooks=[hook]) -print(f"Tools used: {hook.calls}") -``` - -### Composing Hooks - -Pass multiple hooks — they run in order, errors in one don't block others: - -```python -result = await bot.run("hi", hooks=[AuditHook(), MetricsHook()]) -``` - -Under the hood this uses `CompositeHook` for fan-out with error isolation. - -### `finalize_content` Pipeline - -Unlike the async methods (fan-out), `finalize_content` is a pipeline — each hook's output feeds the next: - -```python -class Censor(AgentHook): - def finalize_content(self, ctx, content): - return content.replace("secret", "***") if content else content -``` - -## Full Example - -```python -import asyncio -from cloakbot import Cloakbot -from cloakbot.agent import AgentHook, AgentHookContext - -class TimingHook(AgentHook): - async def before_iteration(self, ctx: AgentHookContext) -> None: - import time - ctx.metadata["_t0"] = time.time() - - async def after_iteration(self, ctx, response) -> None: - import time - elapsed = time.time() - ctx.metadata.get("_t0", 0) - print(f"[timing] iteration took {elapsed:.2f}s") - -async def main(): - bot = Cloakbot.from_config(workspace="/my/project") - result = await bot.run( - "Explain the main function", - hooks=[TimingHook()], - ) - print(result.content) - -asyncio.run(main()) -``` diff --git a/docs/superpowers/specs/2026-04-21-protocolized-collaboration-design.md b/docs/superpowers/specs/2026-04-21-protocolized-collaboration-design.md deleted file mode 100644 index 77e4ea8f..00000000 --- a/docs/superpowers/specs/2026-04-21-protocolized-collaboration-design.md +++ /dev/null @@ -1,439 +0,0 @@ -# CloakBot 协议化协作架构设计(方案 2: Protocol Hub) - -## 1. 背景与目标 - -当前 CloakBot 在 `privacy` 子系统内已具备分层结构(`hooks / agents / core / transparency`)和可运行主流程,但协作形态仍以“函数调用 + 局部日志”为主,尚未形成统一协议面。该状态在单路径运行时可接受,但在跨组件演进(loop、tools、gateway、webui/api)中会出现可观测字段不一致、错误语义不统一、异步任务与主链路难关联的问题。 - -本设计目标是构建全项目统一的“协议化协作”架构,以 `Protocol Hub` 为协作中枢,实现以下优先级目标: - -1. **A 阶段(最高优先)**:单次 turn 全链路可追踪(入口→路由→agent/tool→输出)。 -2. **C 阶段**:跨组件统一指标与面板(privacy、loop、tool、gateway)。 -3. **B 阶段**:跨 turn / 跨会话审计回放。 - -执行模式采用 **混合模式**:用户主路径同步执行,重任务异步调度。 - -## 2. 方案选择与取舍 - -### 2.1 候选方案 - -- 方案 1:轻量协议包裹层(最小改造) -- 方案 2:协议中枢(Protocol Hub) -- 方案 3:事件总线优先(分布式优先) - -### 2.2 选型结论 - -采用 **方案 2(Protocol Hub)**。原因: - -- 相比方案 1,能避免“局部可观测、全局碎片化”回潮。 -- 相比方案 3,复杂度可控,不会在当前阶段引入过度分布式成本。 -- 能在不破坏现有主路径的前提下,建立稳定契约,为后续演进保留接口。 - -## 3. 总体架构 - -### 3.1 新增中枢层 - -在 `AgentLoop` 与具体执行单元(privacy agents / tools / hooks)之间新增 `CollaborationProtocolHub`(简称 CPH)。 - -CPH 仅承担四类职责: - -1. **协议标准化**:统一 turn/task/tool 的 envelope 与元数据头。 -2. **路由与编排**:根据契约在同步主路径与异步任务间分流。 -3. **可观测性注入**:统一 trace/span/event 产出。 -4. **策略执行**:超时、重试、幂等、降级、熔断。 - -### 3.2 分层职责映射 - -- `hooks/`:保留入口/出口职责,只与 CPH 交互,不直接编排细节。 -- `agents/`:升级为协议化 worker(实现统一 `AgentContract`)。 -- `core/`:保持纯能力层(sanitize/vault/math),不耦合调度与观测。 -- `transparency/`:改为消费结构化事件生成报告,不再依赖散点日志拼接。 - -### 3.3 混合同步/异步策略 - -- **同步主路径**:意图判定、隐私预处理、主模型调用、输出后处理。 -- **异步路径**:文档解析、长工具链、批量检查、二次评估任务。 -- 原则:仅保留用户即时可见且必须阻塞的步骤在同步路径,其余转 `DeferredTask`。 - -## 4. 协议契约设计 - -### 4.1 统一协议头(所有契约共享) - -**实现规定(主方案)**:消息契约统一使用 **Pydantic v2** 模型定义与校验,不使用裸 `dict` 作为协议真源。 - -必填字段: - -- `trace_id` -- `span_id` -- `session_id` -- `turn_id` -- `intent` -- `privacy_stage` -- `idempotency_key` -- `timestamp` -- `status` -- `error_code` - -Pydantic 约束: - -- 协议模型默认 `extra="forbid"`,禁止未声明字段进入链路。 -- `intent/status/privacy_stage/event_type` 使用 `Literal` 或 `Enum` 强约束。 -- 仅边界层执行 `model_validate()`,内部流转使用已验证模型对象。 -- 统一通过 `model_dump()` 发射事件与日志载荷,保证字段一致性。 -- 所有契约字段变更必须带 `event_version` 兼容策略。 - -### 4.2 TurnContract(主链路) - -```json -{ - "meta": { - "trace_id": "uuid", - "span_id": "uuid", - "session_id": "string", - "turn_id": "uuid", - "idempotency_key": "string", - "timestamp": "iso8601" - }, - "context": { - "intent": "chat|math|doc", - "channel": "cli|gateway|webui|api", - "privacy_stage": "raw|sanitized|postprocessed" - }, - "payload": { - "user_input": "string", - "sanitized_input": "string|null", - "agent_hint": "string|null" - } -} -``` - -### 4.3 AgentTaskContract(子任务) - -```json -{ - "meta": { "trace_id": "...", "span_id": "...", "parent_span_id": "..." }, - "task": { - "task_id": "uuid", - "task_type": "intent_analysis|math_exec|doc_parse|tool_chain", - "mode": "sync|async", - "priority": "p0|p1|p2", - "deadline_ms": 3000 - }, - "input": { "data_ref": "inline_or_pointer" } -} -``` - -### 4.4 ToolInvocationContract(工具调用) - -```json -{ - "meta": { "trace_id": "...", "span_id": "...", "tool_span_id": "..." }, - "tool": { - "name": "bash|web|fs|custom", - "version": "semver", - "timeout_ms": 5000 - }, - "input": { "args": {} }, - "privacy": { - "sanitize_before": true, - "sanitize_after": true - } -} -``` - -## 5. Protocol Hub 组件清单 - -1. **ProtocolGateway**:接入统一入口,封装 `TurnEnvelope`,补全元数据。 -2. **ContractRouter**:按 `intent + task_type + stage` 路由 sync/async。 -3. **AgentRuntimeAdapter**:将现有 agent 适配到统一 `AgentContract`。 -4. **ToolRuntimeAdapter**:统一工具执行前后事件、错误与脱敏后置链。 -5. **PolicyEngine**:执行 timeout/retry/idempotency/fallback/circuit。 -6. **ObservabilityEmitter**:统一发送 structured events/metrics/traces。 - -## 6. 事件语义(Event Taxonomy) - -### 6.1 Turn 生命周期事件(必须) - -- `turn.received` -- `turn.intent.classified` -- `turn.sanitize.started|succeeded|failed` -- `turn.agent.dispatch.started|completed|failed` -- `turn.restore.started|completed|failed` -- `turn.completed` - -### 6.2 任务事件 - -- `task.created` -- `task.queued`(async) -- `task.started` -- `task.retried` -- `task.timed_out` -- `task.completed` -- `task.failed` - -### 6.3 工具事件 - -- `tool.invocation.started` -- `tool.invocation.completed` -- `tool.output.sanitized` -- `tool.invocation.failed` - -### 6.4 策略事件 - -- `policy.retry.applied` -- `policy.fallback.applied` -- `policy.idempotency.hit` -- `policy.circuit.opened` - -### 6.5 事件约束 - -- 任何节点不得仅输出自由文本日志;必须发结构化事件。 -- 事件需带 `event_version`,保证下游消费者升级兼容。 -- 异步任务必须回写同一 `trace_id`。 - -## 7. 错误处理与可靠性 - -### 7.1 统一错误模型 - -所有异常在协议层统一映射为 `ProtocolError`,最少包含: - -- `error_code` -- `retryable` -- `stage` -- `origin`(agent/tool/core) - -建议错误码示例: - -- `PRIVACY_SANITIZE_FAIL` -- `INTENT_CLASSIFY_FAIL` -- `TOOL_TIMEOUT` -- `ASYNC_QUEUE_OVERFLOW` - -### 7.2 幂等与重试 - -建议幂等键: - -`idempotency_key = session_id + turn_id + stage + hash(payload)` - -规则: - -- 仅 `retryable=true` 的阶段允许自动重试。 -- 重试需发 `task.retried` 并记录 `attempt/backoff_ms`。 -- 有副作用的工具调用必须显式声明“不自动重试”。 - -### 7.3 超时与降级 - -- 每个同步阶段定义 `deadline_ms`。 -- 超时触发显式降级(如 intent fallback=chat)。 -- 降级必须发 `policy.fallback.applied`,不可静默。 - -### 7.4 失败隔离 - -- 异步任务失败不阻塞主回复。 -- 失败结果必须可追踪且可查询(同 trace 下可见)。 -- 连续失败触发熔断并事件化。 - -## 8. 测试与验收 - -### 8.1 契约测试(优先级最高) - -- schema 必填字段与类型校验 -- 版本兼容校验 -- adapter 一致性校验 - -### 8.2 可观测性回归测试 - -验证每个 turn 均可获得关键事件链: - -`received -> sanitize -> dispatch -> restore -> completed` - -并校验字段:`trace_id/turn_id/stage/status/duration`。 - -### 8.3 混合集成测试 - -- 同步链路 + 异步任务并发 -- trace 关联正确性 -- 主路径时延不被异步污染 - -### 8.4 故障注入 - -注入 timeout、invalid json、tool failure、vault io failure,验证重试/降级/熔断行为和事件一致。 - -## 9. 分阶段实施路线(执行顺序:S0 -> S1 -> C -> B) - -### S0(最小结构优化,先做) - -目标:先清边界,再立协议,确保后续 Protocol Hub 不固化历史耦合。 - -交付: - -- `privacy/core` 完成最小子域重组(detection/sanitization/math/state) -- `math_executer.py` 更名为 `math_executor.py` 并全量更新引用 -- `privacy/agents` 完成最小重组(runtime/workers/classification) -- `tool_interceptor.py` 二选一:接入主链或删除空壳 -- 清理无用结构与死代码(见 9.4 简洁化约束) - -验收: - -- 行为等价(核心路径输出不变) -- import 与类型检查通过 -- 现有测试通过且无新增回归 - -### S1(Protocol Hub 主链,后做) - -目标:接通最小协议中枢,完成单 turn 全链路结构化可观测。 - -交付: - -- 基于 Pydantic 的 TurnContract/AgentTaskContract/ToolInvocationContract(主方案) -- 最小事件 taxonomy -- ObservabilityEmitter 接入主链路 -- ProtocolGateway + ContractRouter 最小实现 -- privacy 主流程关键节点全部事件化 - -验收: - -- 任意 turn 可重建时序与耗时分解 -- 关键事件链完整:`received -> sanitize -> dispatch -> restore -> completed` - -### Phase C(统一指标面板) - -交付: - -- 标准指标:阶段时延、错误率、重试率、降级率、工具成功率 -- 跨组件 dashboard - -验收: - -- 可按 stage/agent/tool 直接定位瓶颈 - -### Phase B(会话审计回放) - -交付: - -- SessionTraceIndex -- 事件持久化查询层 -- session 级回放视图 - -验收: - -- 给定 `session_id` 可回放关键协作路径与策略动作 - -### 9.4 代码简洁化硬约束(所有阶段强制) - -1. 删除未使用结构、空壳文件与未接线模块,不保留“未来预留占位”。 -2. 禁止过度抽象:同一能力只保留一个主入口。 -3. 禁止“兼容层堆叠”掩盖坏结构;若可直接迁移则直接迁移。 -4. 每个阶段提交必须附“删除清单”: - - 删除了哪些文件/类/函数 - - 删除依据(无引用/空壳/重复职责) -5. 验证门槛: - - 无死代码(静态检查通过) - - 全量引用可解析 - - 测试通过 - -## 10. 与现有目录的映射建议 - -建议新增目录(示意): - -- `cloakbot/protocol/` - - `hub.py` - - `contracts.py` - - `router.py` - - `policy.py` - - `observability.py` - - `errors.py` - - `adapters/agent_adapter.py` - - `adapters/tool_adapter.py` - -### 10.1 `privacy/core` 结构优化(纳入任务书) - -目标:将 `core` 从“平铺文件集合”升级为“按子域组织的纯能力层”,降低耦合并提高可测试性。 - -建议结构: - -- `privacy/core/detection/` - - `detector.py` - - `general_detector.py` - - `digit_detector.py` - - `llm_json.py` -- `privacy/core/sanitization/` - - `sanitize.py` - - `handler.py` - - `restorer.py` -- `privacy/core/math/` - - `math_executor.py`(由 `math_executer.py` 更名) - - `math_helpers.py` -- `privacy/core/state/` - - `vault.py` -- `privacy/core/types.py`(保留为跨子域共享模型) - -结构优化任务: - -1. 拆分 detection/sanitization/math/state 子目录并迁移文件。 -2. 统一 import 路径并清理循环依赖。 -3. 将 `math_executer.py` 更名为 `math_executor.py` 并全量更新引用。 -4. 保持对外 API 稳定(通过 `core/__init__.py` 或兼容导出层)。 -5. 增加最小回归测试,保证行为一致。 - -### 10.2 `privacy/agents` 结构优化(纳入任务书) - -目标:将 `agents` 从“路由式调用集合”升级为“可注册、可替换、可观测的 worker 运行层”。 - -建议结构: - -- `privacy/agents/runtime/` - - `orchestrator.py` - - `task_router.py` - - `registry.py`(新增,统一注册与发现) -- `privacy/agents/workers/` - - `chat_agent.py` - - `math_agent.py` - - `doc_agent.py`(后续补齐) -- `privacy/agents/classification/` - - `intent_analyzer.py` -- `privacy/agents/base.py` - -结构优化任务: - -1. 引入 `registry.py`,由运行时按能力注册 worker,不再硬编码单例分派。 -2. 将 `chat/math` agent 迁移到 `workers/`,将 intent 分析迁移到 `classification/`。 -3. `tool_interceptor.py` 在本阶段二选一:完成实现并纳入协议链,或删除空壳。 -4. 重新定义 `alias_resolver.py` 归属(迁入 `core` 或 `policy` 层),避免语义漂移。 -5. 为 agent runtime 增加契约测试,确保 worker 可替换性。 - -### 10.3 分阶段落地关系 - -- Phase A:先完成目录重组最小子集(不改变行为),并接通关键可观测事件。 -- Phase C:在新结构上统一指标采集点。 -- Phase B:基于统一 runtime 与事件索引实现会话回放。 - -现有目录职责约束: - -- `privacy/core` 仅保留纯能力,不承担调度。 -- `privacy/agents` 仅保留 worker 与运行时,不承载通用工具逻辑。 -- `privacy/hooks` 仅做入口桥接。 -- `privacy/transparency` 仅消费标准事件。 - -## 11. 非目标(当前阶段不做) - -- 不强制引入外部消息队列作为必需依赖 -- 不拆分为跨进程微服务 -- 不在本轮变更中重写全部历史日志系统 - -## 12. 风险与缓解 - -1. **风险:协议字段扩张失控** - - 缓解:字段最小集 + 版本化 + 兼容规则。 -2. **风险:事件量增加影响性能** - - 缓解:异步批量发送、采样、分级日志。 -3. **风险:迁移期双通道(旧日志+新事件)造成认知负担** - - 缓解:设定阶段性切换点,逐步收敛到事件单一事实源。 - -## 13. 结论 - -该设计将 CloakBot 从“可运行的编排式 agent 实现”升级为“可治理的协议化协作系统”: - -- 保持当前工程复杂度可控 -- 优先满足单 turn 可追踪能力 -- 为统一指标与审计回放提供稳定基础 -- 兼容后续向更强事件驱动架构演进 diff --git a/pyproject.toml b/pyproject.toml index 46b1e08d..702960e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cloakbot-ai" -version = "0.1.5" +version = "0.1.8" description = "A lightweight personal AI assistant framework" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/tests/channels/test_webui_history.py b/tests/channels/test_webui_history.py new file mode 100644 index 00000000..c7d6d280 --- /dev/null +++ b/tests/channels/test_webui_history.py @@ -0,0 +1,63 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from cloakbot.bus.queue import MessageBus +from cloakbot.channels.webui import WebUIChannel, WebUIConfig +from cloakbot.privacy.core.state.vault import _SessionMap, save_map, set_vault_workspace +from cloakbot.privacy.transparency.report import build_session_privacy_snapshot +from cloakbot.privacy.webui import WebUIPrivacyPayload, WebUIPrivacyTimeline, WebUIPrivacyTurn +from cloakbot.privacy.webui.history import append_webui_privacy_payload +from cloakbot.session.manager import Session, SessionManager + + +def test_webui_history_api_returns_messages_and_privacy_turns(tmp_path: Path) -> None: + set_vault_workspace(tmp_path) + session_key = "webui:session-1" + + smap = _SessionMap() + smap.get_or_create_placeholder("Alice Chen", "PERSON", turn_id="turn-1") + save_map(session_key, smap) + + session = Session(key=session_key) + session.add_message("user", "Hello <>") + session.add_message("assistant", "Hi <>") + SessionManager(tmp_path).save(session) + + append_webui_privacy_payload( + tmp_path, + session_key, + WebUIPrivacyPayload( + privacy=build_session_privacy_snapshot(session_key), + privacyAnnotations=[], + privacyTurn=WebUIPrivacyTurn( + turnId="turn-1", + intent="chat", + remotePrompt="Hello <>", + localComputations=[], + ), + privacyTimeline=WebUIPrivacyTimeline( + turnId="turn-1", + traceId="trace-1", + totalDurationMs=0, + stageDurationsMs={}, + events=[], + ), + ), + ) + + channel = WebUIChannel( + WebUIConfig(enabled=True, status={"workspace": str(tmp_path)}), + MessageBus(), + ) + + with TestClient(channel._app) as client: + sessions = client.get("/api/sessions").json()["sessions"] + detail = client.get("/api/sessions/session-1").json() + + assert sessions[0]["id"] == "session-1" + assert sessions[0]["title"] == "Hello Alice Chen" + assert detail["messages"][0]["content"] == "Hello Alice Chen" + assert detail["messages"][1]["content"] == "Hi Alice Chen" + assert detail["privacySnapshot"]["total_entities"] == 1 + assert detail["privacyTurns"][0]["remotePrompt"] == "Hello <>" diff --git a/tests/config/test_config_paths.py b/tests/config/test_config_paths.py index 70073de5..db7f3c0c 100644 --- a/tests/config/test_config_paths.py +++ b/tests/config/test_config_paths.py @@ -8,6 +8,7 @@ get_legacy_sessions_dir, get_logs_dir, get_media_dir, + get_privacy_vault_dir, get_runtime_subdir, get_workspace_path, is_default_workspace, @@ -43,6 +44,10 @@ def test_workspace_path_is_explicitly_resolved() -> None: assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace" +def test_privacy_vault_dir_is_workspace_scoped(tmp_path: Path) -> None: + assert get_privacy_vault_dir(tmp_path) == tmp_path / "privacy_vault" + + def test_is_default_workspace_distinguishes_default_and_custom_paths() -> None: assert is_default_workspace(None) is True assert is_default_workspace(Path.home() / ".cloakbot" / "workspace") is True diff --git a/tests/privacy/webui/test_builders.py b/tests/privacy/webui/test_builders.py new file mode 100644 index 00000000..413681c8 --- /dev/null +++ b/tests/privacy/webui/test_builders.py @@ -0,0 +1,34 @@ +from cloakbot.privacy.hooks.context import TurnContext +from cloakbot.privacy.protocol.contracts import EventType, PrivacyStage, ProtocolStatus +from cloakbot.privacy.protocol.observability import emit_event, get_event_sink +from cloakbot.privacy.webui import build_webui_privacy_payload + + +def test_build_webui_privacy_payload_includes_turn_and_timeline() -> None: + get_event_sink().clear() + ctx = TurnContext( + session_key="webui:session-1", + turn_id="turn-1", + raw_input="hello Alice", + sanitized_input="hello <>", + ) + + emit_event( + event_type=EventType.TURN_SANITIZE_SUCCEEDED, + trace_id=f"{ctx.session_key}:{ctx.turn_id}", + span_id="turn-1:sanitize:completed", + parent_span_id="turn-1:sanitize", + session_id=ctx.session_key, + turn_id=ctx.turn_id, + stage=PrivacyStage.SANITIZED, + status=ProtocolStatus.SUCCEEDED, + payload={"was_sanitized": True}, + ) + + payload = build_webui_privacy_payload(ctx.session_key, ctx).model_dump(mode="json", by_alias=True) + + assert payload["privacyTurn"]["turnId"] == "turn-1" + assert payload["privacyTurn"]["remotePrompt"] == "hello <>" + assert payload["privacyTimeline"]["turnId"] == "turn-1" + assert payload["privacyTimeline"]["events"][0]["eventType"] == "turn.sanitize.succeeded" + assert payload["privacyTimeline"]["events"][0]["payload"] == {"was_sanitized": True} diff --git a/tests/privacy/webui/test_history.py b/tests/privacy/webui/test_history.py new file mode 100644 index 00000000..b133b7b9 --- /dev/null +++ b/tests/privacy/webui/test_history.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from cloakbot.privacy.transparency.report import SessionPrivacySnapshot +from cloakbot.privacy.webui import ( + WebUIPrivacyPayload, + WebUIPrivacyTimeline, + WebUIPrivacyTurn, +) +from cloakbot.privacy.webui.history import ( + append_webui_privacy_payload, + load_webui_privacy_payloads, +) + + +def test_webui_privacy_history_round_trips_payloads(tmp_path: Path) -> None: + payload = WebUIPrivacyPayload( + privacy=SessionPrivacySnapshot(total_entities=0), + privacyAnnotations=[], + privacyTurn=WebUIPrivacyTurn( + turnId="turn-1", + intent="chat", + remotePrompt="hello <>", + localComputations=[], + ), + privacyTimeline=WebUIPrivacyTimeline( + turnId="turn-1", + traceId="trace-1", + totalDurationMs=0, + stageDurationsMs={}, + events=[], + ), + ) + + append_webui_privacy_payload(tmp_path, "webui:session-1", payload) + + loaded = load_webui_privacy_payloads(tmp_path, "webui:session-1") + + assert len(loaded) == 1 + assert loaded[0].privacy_turn.turn_id == "turn-1" + assert loaded[0].privacy_turn.remote_prompt == "hello <>" diff --git a/webui/src/app/layout/AppShell.tsx b/webui/src/app/layout/AppShell.tsx index e0ba3c55..a5443260 100644 --- a/webui/src/app/layout/AppShell.tsx +++ b/webui/src/app/layout/AppShell.tsx @@ -57,46 +57,37 @@ export function AppShell({ tabContent }: AppShellProps) { return (
-
-
- - +
+ onSelectGlobalView={handleGlobalViewChange} + onSelectSession={(id) => { + setCurrentView('chat') + chatNavigation.onSelectSession(id) + }} + onStartNewSession={() => { + setCurrentView('chat') + chatNavigation.onStartNewSession() + }} + onToggleSidebar={() => setDesktopSidebarCollapsed((prev) => !prev)} + collapsed={desktopSidebarCollapsed} + /> + setMobileNavigationOpen(true)} - className="md:hidden" + className="h-full" > {content} diff --git a/webui/src/features/chat/components/MessageList.test.tsx b/webui/src/features/chat/components/MessageList.test.tsx index 0a77d3cd..116e7a89 100644 --- a/webui/src/features/chat/components/MessageList.test.tsx +++ b/webui/src/features/chat/components/MessageList.test.tsx @@ -1,12 +1,33 @@ import { createRef } from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import type { ChatMessage } from '@/features/chat/types' +import type { PrivacyTimeline } from '@/features/privacy/types' import { MessageList } from './MessageList' describe('MessageList', () => { + const timeline: PrivacyTimeline = { + turnId: 'turn-1', + traceId: 'webui:test:turn-1', + totalDurationMs: 64, + stageDurationsMs: { sanitize: 12, restore: 8 }, + events: [ + { + eventType: 'turn.sanitize.succeeded', + sequence: 1, + stage: 'sanitized', + status: 'succeeded', + spanId: 'turn-1:sanitize:completed', + parentSpanId: 'turn-1:sanitize', + timestamp: '2026-04-24T00:00:00Z', + durationMs: 12, + payload: { was_sanitized: true }, + }, + ], + } + it('renders a thinking indicator above the assistant message it belongs to', () => { const messages: ChatMessage[] = [ { @@ -53,4 +74,31 @@ describe('MessageList', () => { expect(screen.getByText('Done in 12s')).toBeInTheDocument() expect(screen.getByText('Done in 12s').compareDocumentPosition(screen.getByText('Hi'))).toBeTruthy() }) + + it('expands a completed privacy trace from the assistant status line', () => { + const messages: ChatMessage[] = [ + { + id: 'assistant-1', + role: 'assistant', + content: 'Hi', + createdAt: 2, + assistantStatus: { + state: 'done', + startedAt: 1000, + finishedAt: 13000, + privacyTimeline: timeline, + }, + }, + ] + + render(()} />) + + const toggle = screen.getByRole('button', { name: /Done in 12s/i }) + expect(toggle).toHaveTextContent('Privacy trace') + + fireEvent.click(toggle) + + expect(screen.getByText('Turn Sanitize Succeeded')).toBeInTheDocument() + expect(screen.getByText('was_sanitized: true')).toBeInTheDocument() + }) }) diff --git a/webui/src/features/chat/components/MessageList.tsx b/webui/src/features/chat/components/MessageList.tsx index 54419b8d..a007972f 100644 --- a/webui/src/features/chat/components/MessageList.tsx +++ b/webui/src/features/chat/components/MessageList.tsx @@ -1,9 +1,11 @@ -import { Check, Copy } from 'lucide-react' +import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react' import { useEffect, useRef, useState, type RefObject } from 'react' import { Button } from '@/components/ui/button' +import { Chip } from '@/components/ui/chip' import { ScrollArea } from '@/components/ui/scroll-area' import type { ChatAssistantStatus, ChatMessage } from '@/features/chat/types' +import type { PrivacyTimeline, PrivacyTimelineEvent } from '@/features/privacy/types' import { AnnotatedMarkdown } from '@/features/privacy/lib/annotated-markdown' import { cn } from '@/lib/utils' @@ -123,30 +125,133 @@ function formatMessageTime(timestamp: number) { } function AssistantStatusLine({ assistantStatus }: { assistantStatus: ChatAssistantStatus }) { - const statusLabel = - assistantStatus.state === 'thinking' - ? 'Thinking' - : `Done in ${formatAssistantDuration(assistantStatus.startedAt, assistantStatus.finishedAt)}` + const [timelineOpen, setTimelineOpen] = useState(false) - return ( -
-
- {statusLabel} - {assistantStatus.state === 'thinking' ? ( - <> - - Bot is thinking - - ) : null} + if (assistantStatus.state === 'thinking') { + return ( +
+
+ Thinking + + Bot is thinking +
+
+ ) + } + + const statusLabel = `Done in ${formatAssistantDuration(assistantStatus.startedAt, assistantStatus.finishedAt)}` + const timeline = assistantStatus.privacyTimeline + + if (!timeline || timeline.events.length === 0) { + return ( +
+
+ {statusLabel} +
+ ) + } + + return ( +
+ + + {timelineOpen && ( +
+
+ Trace {shortTraceId(timeline.traceId)} + {Object.entries(timeline.stageDurationsMs).map(([stage, duration]) => ( + {formatEventLabel(stage)} {formatDurationMs(duration)} + ))} +
+ +
    + {timeline.events.map((event) => ( +
  1. +
    #{event.sequence}
    +
    +
    + {formatEventLabel(event.eventType)} + {event.status} + {event.stage} + {event.durationMs !== null && {formatDurationMs(event.durationMs)}} +
    +
    + {formatPayloadSummary(event)} +
    +
    +
  2. + ))} +
+
+ )}
) } +function privacyStatusClasses(status: PrivacyTimelineEvent['status']) { + if (status === 'failed') { + return 'border-[var(--privacy-high-border)] bg-[var(--privacy-high-bg)] text-[var(--privacy-high-text)]' + } + if (status === 'succeeded') { + return 'border-[var(--privacy-low-border)] bg-[var(--privacy-low-bg)] text-[var(--privacy-low-text)]' + } + return 'border-[var(--privacy-medium-border)] bg-[var(--privacy-medium-bg)] text-[var(--privacy-medium-text)]' +} + +function formatEventLabel(value: string) { + return value + .replaceAll('.', ' ') + .replaceAll('_', ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()) +} + +function formatPayloadSummary(event: PrivacyTimelineEvent) { + const entries = Object.entries(event.payload) + if (entries.length === 0) { + return event.spanId + } + + return entries + .slice(0, 3) + .map(([key, value]) => `${key}: ${String(value)}`) + .join(' | ') +} + +function formatDurationMs(durationMs: number) { + if (durationMs < 1000) { + return `${durationMs}ms` + } + + return `${(durationMs / 1000).toFixed(1)}s` +} + +function shortTraceId(traceId: PrivacyTimeline['traceId']) { + if (traceId.length <= 18) { + return traceId + } + + return `${traceId.slice(0, 8)}...${traceId.slice(-6)}` +} + function formatAssistantDuration(startedAt: number, finishedAt: number) { const durationMs = Math.max(0, finishedAt - startedAt) const durationSeconds = Math.max(1, Math.round(durationMs / 1000)) diff --git a/webui/src/features/chat/hooks/use-chat-session.test.tsx b/webui/src/features/chat/hooks/use-chat-session.test.tsx index 20897c4d..25fc6411 100644 --- a/webui/src/features/chat/hooks/use-chat-session.test.tsx +++ b/webui/src/features/chat/hooks/use-chat-session.test.tsx @@ -6,6 +6,7 @@ import { useChatSession } from './use-chat-session' const openReadyState = 1 afterEach(() => { + window.localStorage.clear() vi.restoreAllMocks() vi.unstubAllGlobals() }) diff --git a/webui/src/features/chat/hooks/use-chat-session.ts b/webui/src/features/chat/hooks/use-chat-session.ts index dd975701..52fb66b8 100644 --- a/webui/src/features/chat/hooks/use-chat-session.ts +++ b/webui/src/features/chat/hooks/use-chat-session.ts @@ -26,9 +26,11 @@ type UseChatSessionOptions = { createMessageId?: () => string createSessionId?: () => string initialMessages?: ChatMessage[] + fetchJson?: (url: string) => Promise } const defaultInitialMessages: ChatMessage[] = [] +const activeSessionStorageKey = 'cloakbot.activeSessionId' function createDefaultSocket(url: string): SocketLike { return new WebSocket(url) @@ -64,13 +66,42 @@ function createSessionRecord(id: string, initialMessages: ChatMessage[]): ChatSe } } +function upsertSession(sessions: ChatSessionRecord[], nextSession: ChatSessionRecord) { + if (sessions.some((session) => session.id === nextSession.id)) { + return sessions.map((session) => (session.id === nextSession.id ? nextSession : session)) + } + return [nextSession, ...sessions] +} + function isSocketWritable(socket: SocketLike | null): socket is SocketLike { return socket?.readyState === socketOpenReadyState } -function createSocketUrl() { +function createSocketUrl(sessionId: string) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - return `${protocol}//${window.location.host}/ws/chat` + return `${protocol}//${window.location.host}/ws/chat?session_id=${encodeURIComponent(sessionId)}` +} + +async function defaultFetchJson(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`) + } + return response.json() as Promise +} + +function readStoredActiveSessionId() { + if (typeof window === 'undefined') { + return '' + } + return window.localStorage.getItem(activeSessionStorageKey) ?? '' +} + +function storeActiveSessionId(sessionId: string) { + if (typeof window === 'undefined') { + return + } + window.localStorage.setItem(activeSessionStorageKey, sessionId) } function createPendingAssistantMessage(createMessageId: () => string, startedAt: number): ChatMessage { @@ -93,18 +124,23 @@ export function useChatSession(options: UseChatSessionOptions = {}) { createMessageId = createDefaultMessageId, createSessionId = createDefaultSessionId, initialMessages = defaultInitialMessages, + fetchJson = defaultFetchJson, } = options const createSocketRef = useRef(createSocket) const createMessageIdRef = useRef(createMessageId) const createSessionIdRef = useRef(createSessionId) + const fetchJsonRef = useRef(fetchJson) const initialMessagesRef = useRef(initialMessages) const activeSessionIdRef = useRef('') const inFlightAssistantSessionIdRef = useRef(null) const sessionsRef = useRef([]) const [bootstrap] = useState(() => { - const initialSessionId = createSessionId() + const initialSessionId = + createSessionId === createDefaultSessionId + ? (readStoredActiveSessionId() || createSessionId()) + : createSessionId() const initialSession = createSessionRecord(initialSessionId, initialMessages) return { sessions: [initialSession], @@ -122,8 +158,9 @@ export function useChatSession(options: UseChatSessionOptions = {}) { createSocketRef.current = createSocket createMessageIdRef.current = createMessageId createSessionIdRef.current = createSessionId + fetchJsonRef.current = fetchJson initialMessagesRef.current = initialMessages - }, [createSocket, createMessageId, createSessionId, initialMessages]) + }, [createSocket, createMessageId, createSessionId, fetchJson, initialMessages]) useEffect(() => { sessionsRef.current = sessions @@ -134,7 +171,11 @@ export function useChatSession(options: UseChatSessionOptions = {}) { }, [activeSessionId]) useEffect(() => { - const socket = createSocketRef.current(createSocketUrl()) + if (!activeSessionId) { + return + } + + const socket = createSocketRef.current(createSocketUrl(activeSessionId)) socketRef.current = socket socket.onmessage = (event) => { @@ -190,7 +231,62 @@ export function useChatSession(options: UseChatSessionOptions = {}) { return () => { socket.close() - socketRef.current = null + if (socketRef.current === socket) { + socketRef.current = null + } + } + }, [activeSessionId]) + + useEffect(() => { + let cancelled = false + + async function loadInitialHistory() { + try { + const list = await fetchJsonRef.current<{ sessions: Array> }>('/api/sessions') + if (cancelled || list.sessions.length === 0) { + return + } + + const storedSessionId = readStoredActiveSessionId() + const selectedSessionId = + list.sessions.some((session) => session.id === storedSessionId) + ? storedSessionId + : list.sessions[0]?.id + + setSessions((previousSessions) => { + const existingById = new Map(previousSessions.map((session) => [session.id, session])) + return list.sessions.map((session) => ({ + ...(existingById.get(session.id) ?? createSessionRecord(session.id, [])), + ...session, + })) + }) + + if (selectedSessionId) { + setActiveSessionId(selectedSessionId) + storeActiveSessionId(selectedSessionId) + await loadSessionHistory(selectedSessionId, cancelled) + } + } catch { + return + } + } + + async function loadSessionHistory(sessionId: string, isCancelled: boolean) { + try { + const session = await fetchJsonRef.current(`/api/sessions/${encodeURIComponent(sessionId)}`) + if (isCancelled || cancelled) { + return + } + setSessions((previousSessions) => upsertSession(previousSessions, session)) + } catch { + return + } + } + + void loadInitialHistory() + + return () => { + cancelled = true } }, []) @@ -269,11 +365,18 @@ export function useChatSession(options: UseChatSessionOptions = {}) { setSessions((previousSessions) => [nextSession, ...previousSessions]) setActiveSessionId(sessionId) + storeActiveSessionId(sessionId) setInput('') } const selectSession = (sessionId: string) => { setActiveSessionId(sessionId) + storeActiveSessionId(sessionId) + void fetchJsonRef.current(`/api/sessions/${encodeURIComponent(sessionId)}`) + .then((session) => { + setSessions((previousSessions) => upsertSession(previousSessions, session)) + }) + .catch(() => {}) } const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0] diff --git a/webui/src/features/chat/services/chat-socket.test.ts b/webui/src/features/chat/services/chat-socket.test.ts index 4b981753..9f1601c7 100644 --- a/webui/src/features/chat/services/chat-socket.test.ts +++ b/webui/src/features/chat/services/chat-socket.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { PrivacySnapshot, PrivacyTurn } from '@/features/privacy/types' +import type { PrivacySnapshot, PrivacyTimeline, PrivacyTurn } from '@/features/privacy/types' import type { ChatSessionState } from '../types' @@ -23,6 +23,26 @@ function createState(overrides: Partial = {}): ChatSessionStat } describe('reduceChatSocketEvent', () => { + const nextTimeline: PrivacyTimeline = { + turnId: 'turn-2', + traceId: 'webui:test:turn-2', + totalDurationMs: 42, + stageDurationsMs: { sanitize: 12 }, + events: [ + { + eventType: 'turn.sanitize.succeeded', + sequence: 1, + stage: 'sanitized', + status: 'succeeded', + spanId: 'turn-2:sanitize:completed', + parentSpanId: 'turn-2:sanitize', + timestamp: '2026-04-24T00:00:00Z', + durationMs: 12, + payload: { was_sanitized: true }, + }, + ], + } + it('appends assistant_message and applies privacy payloads', () => { vi.spyOn(Date, 'now').mockReturnValue(1000) @@ -175,6 +195,7 @@ describe('reduceChatSocketEvent', () => { }, ], privacyTurn: nextTurn, + privacyTimeline: nextTimeline, }, () => 'unused-id', ) @@ -184,6 +205,7 @@ describe('reduceChatSocketEvent', () => { state: 'done', startedAt: 1000, finishedAt: 4000, + privacyTimeline: nextTimeline, }) expect(result.privacyTurns).toEqual([nextTurn]) }) diff --git a/webui/src/features/chat/services/chat-socket.ts b/webui/src/features/chat/services/chat-socket.ts index 74151646..5eb746f6 100644 --- a/webui/src/features/chat/services/chat-socket.ts +++ b/webui/src/features/chat/services/chat-socket.ts @@ -1,4 +1,4 @@ -import type { PrivacyTurn } from '@/features/privacy/types' +import type { PrivacyTimeline, PrivacyTurn } from '@/features/privacy/types' import type { ChatAssistantStatus, ChatMessage, ChatSessionState, ChatSocketEvent } from '../types' @@ -26,14 +26,24 @@ function startAssistantStatus(startedAt: number): ChatAssistantStatus { } } -function completeAssistantStatus(previousStatus: ChatAssistantStatus | undefined, finishedAt: number): ChatAssistantStatus { +function completeAssistantStatus( + previousStatus: ChatAssistantStatus | undefined, + finishedAt: number, + privacyTimeline?: PrivacyTimeline, +): ChatAssistantStatus { const startedAt = previousStatus?.startedAt ?? finishedAt - return { + const nextStatus: ChatAssistantStatus = { state: 'done', startedAt, finishedAt, } + + if (privacyTimeline) { + nextStatus.privacyTimeline = privacyTimeline + } + + return nextStatus } function createAssistantMessage(createMessageId: () => string, content: string, createdAt: number): ChatMessage { @@ -109,7 +119,11 @@ export function reduceChatSocketEvent( nextMessages[pendingAssistantIndex] = { ...pendingMessage, privacyAnnotations: event.privacyAnnotations ?? pendingMessage.privacyAnnotations, - assistantStatus: completeAssistantStatus(pendingMessage.assistantStatus, Date.now()), + assistantStatus: completeAssistantStatus( + pendingMessage.assistantStatus, + Date.now(), + event.privacyTimeline, + ), } } diff --git a/webui/src/features/chat/types.ts b/webui/src/features/chat/types.ts index 45e79d69..bd988eec 100644 --- a/webui/src/features/chat/types.ts +++ b/webui/src/features/chat/types.ts @@ -1,6 +1,7 @@ import type { PrivacyAnnotation, PrivacySnapshot, + PrivacyTimeline, PrivacyTurn, } from '@/features/privacy/types' @@ -22,6 +23,7 @@ export type ChatAssistantStatus = state: 'done' startedAt: number finishedAt: number + privacyTimeline?: PrivacyTimeline } export type ChatSessionState = { @@ -41,12 +43,26 @@ export type ChatSessionRecord = { } export type ChatSocketEvent = + | { + type: 'session' + sessionId: string + } + | { + type: 'status' + data: Record + } + | { + type: 'progress' + content: string + toolHint: boolean + } | { type: 'assistant_message' content: string privacyAnnotations?: PrivacyAnnotation[] privacy?: PrivacySnapshot privacyTurn?: PrivacyTurn + privacyTimeline?: PrivacyTimeline } | { type: 'assistant_delta' @@ -57,6 +73,7 @@ export type ChatSocketEvent = privacyAnnotations?: PrivacyAnnotation[] privacy?: PrivacySnapshot privacyTurn?: PrivacyTurn + privacyTimeline?: PrivacyTimeline } | { type: 'privacy_snapshot' diff --git a/webui/src/features/navigation/components/NavigationPanel.tsx b/webui/src/features/navigation/components/NavigationPanel.tsx index 36a171cf..36878d9a 100644 --- a/webui/src/features/navigation/components/NavigationPanel.tsx +++ b/webui/src/features/navigation/components/NavigationPanel.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react' + import { PanelLeft, Plus } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -21,6 +23,8 @@ type NavigationPanelProps = { collapsed?: boolean } +type GatewayConnectionState = 'checking' | 'connected' | 'disconnected' + export function NavigationPanel({ sessions, activeSessionId, @@ -131,6 +135,92 @@ export function NavigationPanel({ })}
+ + +
+ ) +} + +function GatewayStatus({ collapsed, sectionPaddingClass }: { collapsed: boolean; sectionPaddingClass: string }) { + const [state, setState] = useState('checking') + const [detail, setDetail] = useState('') + + useEffect(() => { + let cancelled = false + let timeoutId: number | undefined + + async function refreshStatus() { + try { + const response = await fetch('/api/status') + if (!response.ok) { + throw new Error(`Gateway status failed: ${response.status}`) + } + + const payload = await response.json() as { ready?: boolean; model?: string; provider?: string } + if (cancelled) { + return + } + + setState(payload.ready === false ? 'disconnected' : 'connected') + setDetail([payload.model, payload.provider].filter(Boolean).join(' · ')) + } catch { + if (!cancelled) { + setState('disconnected') + setDetail('') + } + } finally { + if (!cancelled) { + timeoutId = window.setTimeout(refreshStatus, 15000) + } + } + } + + void refreshStatus() + + return () => { + cancelled = true + if (timeoutId) { + window.clearTimeout(timeoutId) + } + } + }, []) + + const isOnline = state === 'connected' + const label = isOnline ? 'Gateway: Connected' : 'Gatewat: Disconnected' + return ( +
+
+ + {!collapsed && ( + + {label} + {detail && isOnline && {detail}} + + )} +
) } + +function GatewayStatusDot({ isOnline }: { isOnline: boolean }) { + return ( +