diff --git a/plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py b/plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py new file mode 100644 index 0000000000000000000000000000000000000000..8b64b1071fca8dac04a6f5dceef740a01123fbb6 --- /dev/null +++ b/plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py @@ -0,0 +1,8 @@ +from agent import LoopData +from helpers.extension import Extension +from plugins._context_window.helpers.usage import capture_context + + +class CaptureContextUsage(Extension): + def execute(self, loop_data: LoopData | None = None, **kwargs): + capture_context(self.agent, loop_data) diff --git a/plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js b/plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js new file mode 100644 index 0000000000000000000000000000000000000000..5a21adbd55f5133a771c30d853a15598f96bd3a3 --- /dev/null +++ b/plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js @@ -0,0 +1,42 @@ +import { store as contextWindowStore } from "/plugins/_context_window/webui/context-window-store.js"; + +const OVERRIDE_REVISION_KEY = "_model_config_override_revision"; +let lastContextId = ""; +let lastRevision = null; +let lastGenerationKey = ""; + +function latestGenerationKey(logs) { + if (!Array.isArray(logs)) return ""; + for (let index = logs.length - 1; index >= 0; index--) { + const item = logs[index]; + if (item?.type !== "agent" || Number(item.agentno || 0) !== 0) continue; + return `${item.no ?? ""}:${item.id ?? ""}`; + } + return ""; +} + +export default async function refreshContextWindow(ctx) { + const snapshot = ctx?.snapshot; + const contextId = String(snapshot?.context || ""); + if (!contextId) { + lastContextId = ""; + lastRevision = null; + lastGenerationKey = ""; + return; + } + + const contexts = Array.isArray(snapshot?.contexts) ? snapshot.contexts : []; + const active = contexts.find(item => item?.id === contextId) || null; + const revision = active?.[OVERRIDE_REVISION_KEY] || null; + const generationKey = latestGenerationKey(snapshot?.logs); + if ( + contextId === lastContextId + && revision === lastRevision + && (!generationKey || generationKey === lastGenerationKey) + ) return; + + lastContextId = contextId; + lastRevision = revision; + if (generationKey) lastGenerationKey = generationKey; + await contextWindowStore.refresh(contextId); +} diff --git a/plugins/_context_window/extensions/webui/interface-controls-end/context-window.html b/plugins/_context_window/extensions/webui/interface-controls-end/context-window.html new file mode 100644 index 0000000000000000000000000000000000000000..c56aed5e89050ac58b5c6b6187a4b2c324dcaaac --- /dev/null +++ b/plugins/_context_window/extensions/webui/interface-controls-end/context-window.html @@ -0,0 +1,40 @@ + + +
+ +
diff --git a/plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html b/plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html new file mode 100644 index 0000000000000000000000000000000000000000..fa8150452bbf5e39ed27725190e0f411b6220287 --- /dev/null +++ b/plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html @@ -0,0 +1,241 @@ + + +
+ +
+ + diff --git a/plugins/_context_window/helpers/__init__.py b/plugins/_context_window/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/plugins/_context_window/helpers/__init__.py @@ -0,0 +1 @@ + diff --git a/plugins/_context_window/helpers/usage.py b/plugins/_context_window/helpers/usage.py new file mode 100644 index 0000000000000000000000000000000000000000..ba0cc69c72ab4aa412d301d7163ea272459d1da8 --- /dev/null +++ b/plugins/_context_window/helpers/usage.py @@ -0,0 +1,278 @@ +import hashlib +import math +from typing import Any + +from helpers import files, history, skills, tokens +from helpers.llm_result import result_from_metadata + + +PARTS_KEY = "context_window_usage" +CACHE_KEY = "_context_window_usage_cache" +PROVIDER_USAGE_KEY = "context_window_provider_usage" +USAGE_KEYS = ( + "messages", + "system_tools", + "skills", + "mcp_tools", + "system_prompt", + "extras", +) +MEASURED_KEYS = tuple(key for key in USAGE_KEYS if key != "system_prompt") + + +def reset(agent: Any) -> None: + params = _temporary_params(agent) + if params is not None: + params[PARTS_KEY] = {} + + +def discard(agent: Any) -> None: + params = _temporary_params(agent) + if params is not None: + params.pop(PARTS_KEY, None) + + +def record_prompt(agent: Any, key: str, prompt: Any) -> None: + parts = _parts(agent) + if parts is None or key not in MEASURED_KEYS: + return + text = files.remove_code_fences(str(prompt or ""), language="json") + parts[key] = _cached_tokens(agent, f"prompt:{key}", text) + + +def capture_context(agent: Any, loop_data: Any) -> None: + parts = _parts(agent) + if parts is None or loop_data is None: + return + + output = list(getattr(loop_data, "history_output", None) or []) + parts["_history_output"] = output + skill_output = [message for message in output if skills.skill_instruction_name(message)] + skill_tokens = _output_tokens(agent, "history_skills", skill_output) + parts["messages"] = max(_history_tokens(agent, output) - skill_tokens, 0) + parts["skills"] = parts.get("skills", 0) + skill_tokens + + protocol_values = { + **getattr(loop_data, "protocol_persistent", {}), + **getattr(loop_data, "protocol_temporary", {}), + } + extras_values = { + **getattr(loop_data, "extras_persistent", {}), + **getattr(loop_data, "extras_temporary", {}), + } + protocol = agent._build_context_message( + "agent.context.protocol.md", + "protocol", + protocol_values, + include_empty=False, + ) + extras = agent._build_context_message( + "agent.context.extras.md", + "extras", + extras_values, + include_empty=True, + ) + parts["extras"] = _output_tokens(agent, "extras", protocol + extras) + + +def finalize(agent: Any) -> None: + params = _temporary_params(agent) + parts = params.pop(PARTS_KEY, None) if params is not None else None + window = agent.get_data(agent.DATA_NAME_CTX_WINDOW) if agent else None + if not isinstance(parts, dict) or not isinstance(window, dict): + return + + history_output = parts.pop("_history_output", None) + total = _non_negative_int(window.get("tokens")) + usage = {key: _non_negative_int(parts.get(key)) for key in MEASURED_KEYS} + measured_total = sum(usage.values()) + if total and measured_total >= total and isinstance(history_output, list): + message_output = [ + message + for message in history_output + if not skills.skill_instruction_name(message) + ] + usage["messages"] = _output_tokens( + agent, "history_messages", message_output + ) + measured_total = sum(usage.values()) + if measured_total > total and measured_total: + usage = _scale_to_total(usage, total, measured_total) + measured_total = total + usage["system_prompt"] = total - measured_total + usage = {key: usage.get(key, 0) for key in USAGE_KEYS} + + updated = dict(window) + updated["usage"] = usage + agent.set_data(agent.DATA_NAME_CTX_WINDOW, updated) + + +def usage_snapshot(value: Any) -> dict[str, int]: + if not isinstance(value, dict): + return {} + return {key: _non_negative_int(value.get(key)) for key in USAGE_KEYS} + + +def capture_provider_usage(agent: Any, result: Any) -> None: + if agent is None or result is None or not hasattr(result, "usage"): + return + + snapshot = provider_usage_snapshot(getattr(result, "usage", None)) + agent.set_data( + PROVIDER_USAGE_KEY, + snapshot if snapshot else {"available": False}, + ) + + +def latest_provider_usage(agent: Any) -> dict[str, int | float]: + data = getattr(agent, "data", None) + if isinstance(data, dict) and PROVIDER_USAGE_KEY in data: + stored = data.get(PROVIDER_USAGE_KEY) + if isinstance(stored, dict) and stored.get("available") is False: + return {} + return provider_usage_snapshot(stored) + + all_messages = getattr(getattr(agent, "history", None), "all_messages", None) + if not callable(all_messages): + return {} + for message in reversed(all_messages()): + if not getattr(message, "ai", False): + continue + result = result_from_metadata(getattr(message, "metadata", None)) + if result: + return provider_usage_snapshot(result.usage) + return {} + + +def provider_usage_snapshot(value: Any) -> dict[str, int | float]: + if not isinstance(value, dict): + return {} + + input_details = { + **_mapping(value.get("prompt_tokens_details")), + **_mapping(value.get("input_tokens_details")), + } + result: dict[str, int | float] = {} + fields = { + "input_tokens": (value.get("input_tokens"), value.get("prompt_tokens")), + "cached_tokens": ( + input_details.get("cached_tokens"), + input_details.get("cache_read_tokens"), + value.get("cache_read_input_tokens"), + value.get("cached_tokens"), + ), + "output_tokens": ( + value.get("output_tokens"), + value.get("completion_tokens"), + ), + } + for key, values in fields.items(): + number = _optional_non_negative_int(*values) + if number is not None: + result[key] = number + + cost = _optional_non_negative_float( + value.get("cost"), value.get("response_cost") + ) + if cost is not None: + result["cost"] = cost + return result + + +def _parts(agent: Any) -> dict[str, Any] | None: + params = _temporary_params(agent) + value = params.get(PARTS_KEY) if params is not None else None + return value if isinstance(value, dict) else None + + +def _temporary_params(agent: Any) -> dict[str, Any] | None: + loop_data = getattr(agent, "loop_data", None) + params = getattr(loop_data, "params_temporary", None) + return params if isinstance(params, dict) else None + + +def _history_tokens(agent: Any, output: list[history.OutputMessage]) -> int: + get_tokens = getattr(getattr(agent, "history", None), "get_tokens", None) + if callable(get_tokens): + return _non_negative_int(get_tokens()) + return _count_output_tokens(output) + + +def _output_tokens( + agent: Any, cache_key: str, output: list[history.OutputMessage] +) -> int: + text = history.output_text(output, ai_label="assistant", human_label="user") + return _cached_tokens(agent, cache_key, text) + + +def _count_output_tokens(output: list[history.OutputMessage]) -> int: + text = history.output_text(output, ai_label="assistant", human_label="user") + return tokens.approximate_prompt_tokens(text) + + +def _cached_tokens(agent: Any, key: str, text: str) -> int: + cache = _cache(agent) + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + cached = cache.get(key) if cache is not None else None + if isinstance(cached, tuple) and len(cached) == 2 and cached[0] == digest: + return _non_negative_int(cached[1]) + + count = tokens.approximate_prompt_tokens(text) + if cache is not None: + cache[key] = (digest, count) + return count + + +def _cache(agent: Any) -> dict[str, tuple[str, int]] | None: + data = getattr(agent, "data", None) + if not isinstance(data, dict): + return None + cache = data.get(CACHE_KEY) + if not isinstance(cache, dict): + cache = {} + data[CACHE_KEY] = cache + return cache + + +def _non_negative_int(value: Any) -> int: + try: + return max(int(value or 0), 0) + except (TypeError, ValueError): + return 0 + + +def _mapping(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _optional_non_negative_int(*values: Any) -> int | None: + for value in values: + if value is None: + continue + try: + return max(int(value), 0) + except (TypeError, ValueError): + continue + return None + + +def _optional_non_negative_float(*values: Any) -> float | None: + for value in values: + if value is None: + continue + try: + number = float(value) + except (TypeError, ValueError): + continue + if math.isfinite(number): + return max(number, 0) + return None + + +def _scale_to_total(values: dict[str, int], total: int, current: int) -> dict[str, int]: + scaled = {key: value * total // current for key, value in values.items()} + remainder = total - sum(scaled.values()) + order = sorted(values, key=lambda key: values[key] * total % current, reverse=True) + for key in order[:remainder]: + scaled[key] += 1 + return scaled diff --git a/plugins/_context_window/plugin.yaml b/plugins/_context_window/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b9d46289d4b7822d9e60f149a38537740b800ae9 --- /dev/null +++ b/plugins/_context_window/plugin.yaml @@ -0,0 +1,8 @@ +name: _context_window +title: Context Window +description: Shows token usage for the active chat context window. +version: 0.1.0 +settings_sections: [] +per_project_config: false +per_agent_config: false +always_enabled: true diff --git a/plugins/_context_window/tests/__init__.py b/plugins/_context_window/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/plugins/_context_window/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/plugins/_context_window/tests/test_context_window.py b/plugins/_context_window/tests/test_context_window.py new file mode 100644 index 0000000000000000000000000000000000000000..4ad144caf331bfff73f65ae3df5eb13012236ace --- /dev/null +++ b/plugins/_context_window/tests/test_context_window.py @@ -0,0 +1,434 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from langchain_core.messages import HumanMessage, SystemMessage + + +ROOT = Path(__file__).resolve().parents[3] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import models +from agent import Agent, LoopData +from helpers import extension, extract_tools, history, litellm_transport +from helpers.llm_result import LLMResult +from plugins._context_window.api.context_window import ContextWindow +from plugins._context_window.helpers import usage + + +class _Log: + def set_progress(self, _message: str) -> None: + pass + + +@pytest.mark.asyncio +async def test_usage_follows_prompt_sources_and_reconciles_to_total(monkeypatch): + agent = object.__new__(Agent) + loop_data = LoopData() + agent.loop_data = loop_data + agent.context = SimpleNamespace(log=_Log()) + agent.history = history.History(agent) + agent.data = {} + agent.history.add_message(False, "User asks a question.") + agent.history.add_message(True, "Assistant answers.") + agent.history.add_message( + False, + { + "tool_name": "skills_tool", + "tool_result": "Skill instructions without a special heading.", + "skill_instructions": { + "name": "test-skill", + "content_included": True, + }, + }, + ) + + system_parts = { + "system_prompt": "Main instructions without a special heading.", + "system_tools": "Tool definitions without a special heading.", + "mcp_tools": "Remote definitions without a special heading.", + "skills": "Available skill names without a special heading.", + } + + async def get_system_prompt(_loop_data): + for key in ("system_tools", "mcp_tools", "skills"): + usage.record_prompt(agent, key, system_parts[key]) + return list(system_parts.values()) + + def read_prompt(prompt_file: str, **kwargs) -> str: + if prompt_file == "agent.context.protocol.md": + return "[PROTOCOL]\n" + kwargs["protocol"] + if prompt_file == "agent.context.extras.md": + return "[EXTRAS]\n" + kwargs["extras"] + raise AssertionError(f"Unexpected prompt: {prompt_file}") + + async def call_extensions(extension_point: str, agent=None, **kwargs): + if extension_point == "message_loop_prompts_after": + current = kwargs["loop_data"] + current.protocol_persistent["project"] = "Project instructions." + current.extras_temporary["time"] = "Current time." + usage.capture_context(agent, current) + + agent.get_system_prompt = get_system_prompt + agent.read_prompt = read_prompt + monkeypatch.setattr(extension, "call_extensions_async", call_extensions) + monkeypatch.setattr(history.History, "_get_max_embeds", lambda self: 0) + + usage.reset(agent) + await Agent.prepare_prompt.__wrapped__(agent, loop_data) + usage.finalize(agent) + + window = agent.get_data(Agent.DATA_NAME_CTX_WINDOW) + breakdown = window["usage"] + assert tuple(breakdown) == usage.USAGE_KEYS + assert sum(breakdown.values()) == window["tokens"] + assert all(breakdown[key] > 0 for key in usage.USAGE_KEYS) + assert usage.PARTS_KEY not in loop_data.params_temporary + assert "history_messages" not in agent.data[usage.CACHE_KEY] + + +@pytest.mark.asyncio +async def test_api_returns_only_counts_and_effective_limit(monkeypatch): + agent = SimpleNamespace( + DATA_NAME_CTX_WINDOW="ctx_window", + get_data=lambda _key: { + "text": "private prompt", + "tokens": 120, + "usage": {"messages": 42}, + }, + ) + handler = object.__new__(ContextWindow) + handler.use_context = lambda _context_id: SimpleNamespace( + streaming_agent=None, + agent0=agent, + ) + monkeypatch.setattr( + "plugins._context_window.api.context_window.get_chat_model_config", + lambda _agent: {"ctx_length": 128_000}, + ) + + result = await handler.process({"context": "ctx-1"}, SimpleNamespace()) + + assert result == { + "tokens": 120, + "context_window": 128_000, + "usage": { + "messages": 42, + "system_tools": 0, + "skills": 0, + "mcp_tools": 0, + "system_prompt": 0, + "extras": 0, + }, + "provider_usage": {}, + } + assert "text" not in result + + +def test_webui_and_accounting_are_plugin_owned(): + model_switcher = ( + ROOT + / "plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html" + ).read_text(encoding="utf-8") + model_store = (ROOT / "plugins/_model_config/webui/switcher-mixin.js").read_text( + encoding="utf-8" + ) + component = ( + ROOT + / "plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html" + ).read_text(encoding="utf-8") + context_store = ( + ROOT / "plugins/_context_window/webui/context-window-store.js" + ).read_text(encoding="utf-8") + helper = (ROOT / "plugins/_context_window/helpers/usage.py").read_text( + encoding="utf-8" + ) + refresh_hook = ( + ROOT + / "plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js" + ).read_text(encoding="utf-8") + + assert 'id="model-context-strip-end"' in model_switcher + assert "contextWindowUsage" not in model_switcher + assert "contextUsage" not in model_store + assert "Context window" in component + assert "position: static" in component + assert "width: min(19rem, calc(100vw - 2rem))" in component + assert "right: 1.25rem" in component + assert "width: min(17rem, calc(100vw - 3rem))" in component + assert 'label: "Free space"' in context_store + assert "Last model call" not in component + assert ">Price<" in component + assert ">Cache hit<" in component + assert ">Tokens In/Out<" in component + assert "context-window-cache-meter" not in component + assert "price: {" in context_store + assert "hasData: cost !== null" in context_store + assert 'label: cost === null ? "" : formatCost(cost)' in context_store + assert "usage.provider.price.hasData" in component + assert "usage.provider.price.label" in component + assert 'value < 0.001 ? "<$0.001"' in context_store + assert "maximumSignificantDigits: 3" in context_store + assert "border-top: 1px solid var(--color-border)" in component + assert " → " in context_store + assert "summaryTokens" in context_store + assert 'summaryPercent: `${percentLabel} used`' in context_store + assert "formatTokens(output)} tok" not in context_store + assert "context-window-summary-tokens" in component + assert "context-window-summary-percent" in component + assert "font-family: var(--font-family-main)" in component + assert " 0 + assert sum(breakdown.values()) == 100 + + +def test_provider_usage_is_optional(): + data = {} + agent = SimpleNamespace( + data=data, + history=SimpleNamespace(all_messages=lambda: []), + set_data=lambda key, value: data.__setitem__(key, value), + ) + result = LLMResult.from_chat( + response="done", + usage={ + "prompt_tokens": 12_000, + "prompt_tokens_details": {"cached_tokens": 9_000}, + "completion_tokens": 80, + "cost": 0.0123, + }, + ) + + usage.capture_provider_usage(agent, result) + + assert usage.latest_provider_usage(agent) == { + "input_tokens": 12_000, + "cached_tokens": 9_000, + "output_tokens": 80, + "cost": 0.0123, + } + + usage.capture_provider_usage(agent, LLMResult.from_chat(response="no usage")) + assert usage.latest_provider_usage(agent) == {} + assert usage.provider_usage_snapshot( + {"input_tokens": 100, "cached_tokens": None, "cost": None} + ) == {"input_tokens": 100} diff --git a/plugins/_context_window/webui/context-window-store.js b/plugins/_context_window/webui/context-window-store.js new file mode 100644 index 0000000000000000000000000000000000000000..30349ea02b5fdd03708c47aa4fcb47bb353f97c2 --- /dev/null +++ b/plugins/_context_window/webui/context-window-store.js @@ -0,0 +1,160 @@ +import { createStore } from "/js/AlpineStore.js"; +import { callJsonApi } from "/js/api.js"; +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; +import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js"; + +const API_PATH = "/plugins/_context_window/context_window"; +const ROWS = [ + { key: "messages", label: "Messages" }, + { key: "system_tools", label: "System tools" }, + { key: "skills", label: "Skills" }, + { key: "mcp_tools", label: "MCP tools" }, + { key: "system_prompt", label: "System prompt" }, + { key: "extras", label: "Extras" }, +]; +const COST_FORMATTER = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumSignificantDigits: 3, +}); + +preferencesStore.registerUiControlVisibility("contextWindowUsage", { + mobile: true, + desktop: true, +}); + +function formatTokens(value) { + const amount = Math.max(Number(value) || 0, 0); + for (const [size, suffix] of [[1_000_000, "M"], [1_000, "K"]]) { + if (amount >= size) return `${(amount / size).toFixed(1).replace(/\.0$/, "")}${suffix}`; + } + return String(Math.round(amount)); +} + +function formatPercent(value) { + const rounded = Math.round(Math.max(Number(value) || 0, 0) * 10) / 10; + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}%`; +} + +function optionalNumber(value, key) { + if (!value || !Object.prototype.hasOwnProperty.call(value, key)) return null; + const number = Number(value[key]); + return Number.isFinite(number) && number >= 0 ? number : null; +} + +function formatCost(value) { + if (value === 0) return "$0"; + return value < 0.001 ? "<$0.001" : COST_FORMATTER.format(value); +} + +function buildProviderUsage(value = {}) { + const input = optionalNumber(value, "input_tokens"); + const cached = optionalNumber(value, "cached_tokens"); + const output = optionalNumber(value, "output_tokens"); + const cost = optionalNumber(value, "cost"); + + const tokenSummary = input === null && output === null + ? "" + : `${input === null ? "–" : formatTokens(input)} → ${output === null ? "–" : formatTokens(output)}`; + + const cachePercent = input > 0 && cached !== null + ? Math.min((cached / input) * 100, 100) + : null; + return { + hasData: cost !== null || cachePercent !== null || Boolean(tokenSummary), + price: { + hasData: cost !== null, + label: cost === null ? "" : formatCost(cost), + }, + cache: { + hasData: cachePercent !== null, + label: cachePercent === null ? "" : `${Math.round(cachePercent)}%`, + }, + tokens: tokenSummary, + }; +} + +function buildUsage(data = {}) { + const tokens = Math.max(Number(data.tokens) || 0, 0); + const contextWindow = Math.max(Number(data.context_window) || 0, 0); + const breakdown = data.usage && typeof data.usage === "object" ? data.usage : {}; + const percent = contextWindow > 0 ? (tokens / contextWindow) * 100 : 0; + const rows = ROWS.map(row => { + const rowTokens = Math.max(Number(breakdown[row.key]) || 0, 0); + const rowPercent = contextWindow > 0 ? (rowTokens / contextWindow) * 100 : 0; + return { + ...row, + tokensLabel: formatTokens(rowTokens), + percentLabel: formatPercent(rowPercent), + }; + }); + const hasBreakdown = rows.some(row => Number(breakdown[row.key]) > 0); + if (hasBreakdown) { + const freeTokens = Math.max(contextWindow - tokens, 0); + const freePercent = contextWindow > 0 ? (freeTokens / contextWindow) * 100 : 0; + rows.push({ + key: "free_space", + label: "Free space", + tokensLabel: formatTokens(freeTokens), + percentLabel: formatPercent(freePercent), + }); + } + const percentLabel = formatPercent(percent); + return { + rows: hasBreakdown ? rows : [], + hasBreakdown, + missingBreakdown: !hasBreakdown, + ariaLabel: `Context window ${percentLabel} used`, + ringLabel: contextWindow ? `${Math.round(percent)}%` : "–", + ringDasharray: `${Math.min(percent, 100)} 100`, + summaryTokens: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : "–"} tokens`, + summaryPercent: `${percentLabel} used`, + meterStyle: `width:${Math.min(percent, 100)}%`, + provider: buildProviderUsage(data.provider_usage), + }; +} + +const model = { + usage: buildUsage(), + loadSeq: 0, + open: false, + + get contextId() { + return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || ""; + }, + + async onMount(watch) { + await this.refresh(); + watch("$store.chats.selected", value => this.refresh(value || "")); + watch("$store.chats.selectedContext?.running", (running, previous) => { + if (previous && !running) void this.refresh(); + }); + }, + + cleanup() { + this.open = false; + this.loadSeq += 1; + }, + + toggle() { + this.open = !this.open; + if (this.open) void this.refresh(); + }, + + async refresh(contextId = this.contextId) { + const requestSeq = ++this.loadSeq; + if (!contextId) { + this.usage = buildUsage(); + return this.usage; + } + try { + const data = await callJsonApi(API_PATH, { context: contextId }); + if (requestSeq === this.loadSeq) this.usage = buildUsage(data); + } catch (error) { + if (requestSeq === this.loadSeq) console.error("Context window load failed:", error); + } + return this.usage; + }, +}; + +export const store = createStore("contextWindow", model); diff --git a/plugins/_context_window/webui/thumbnail.webp b/plugins/_context_window/webui/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..95f724c8400f2227e4207d69f71b2e7d75d0a864 Binary files /dev/null and b/plugins/_context_window/webui/thumbnail.webp differ diff --git a/plugins/_desktop/AGENTS.md b/plugins/_desktop/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..98a48833f75f72e0a87a011075b676d35f107164 --- /dev/null +++ b/plugins/_desktop/AGENTS.md @@ -0,0 +1,33 @@ +# Desktop Plugin DOX + +## Purpose + +- Own the Agent Zero Linux desktop runtime, Xpra/Xfce session integration, and live desktop surface. + +## Ownership + +- `helpers/` owns desktop routes, session lifecycle, state, and prompt context. +- `api/desktop_session.py` owns desktop session API behavior. +- `hooks.py` owns route registration and plugin lifecycle hooks. +- `prompts/`, `assets/`, `skills/`, `extensions/`, and `webui/` own desktop context, assets, skill guidance, hooks, and panel UI. + +## Local Contracts + +- Preserve session startup, cleanup, and route protection for desktop access. +- Keep the Xpra server, client modules, and GTK introspection runtime present as one compatible stack; Xpra shadow sessions import all three even when users connect only through HTML5. +- Keep desktop state injected into prompts accurate and bounded. +- Do not expose desktop routes without the expected auth protections. +- Keep Desktop host visibility tied to an attached modal or canvas host; modal cleanup may preserve the iframe in keepalive, but must not leave stale modal mode behind. +- Keep LibreOffice Writer as the default handler for Markdown and plain text files; keep Agent Zero Editor available as a secondary Open With target through the desktop intent bridge. + +## Work Guidance + +- Coordinate runtime and panel changes so live UI reflects actual desktop session state. + +## Verification + +- Smoke-test desktop startup, panel connection, route access, and session cleanup after changes. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_desktop/api/desktop_session.py b/plugins/_desktop/api/desktop_session.py new file mode 100644 index 0000000000000000000000000000000000000000..7b1120311c7411d2073c9c0a44fadf0d9cebed17 --- /dev/null +++ b/plugins/_desktop/api/desktop_session.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request +from plugins._desktop.helpers import desktop_session +from plugins._office.helpers import document_store +from plugins._office.helpers import libreoffice + + +class DesktopSession(ApiHandler): + async def process(self, input: dict, request: Request) -> dict: + action = str(input.get("action") or "desktop").lower().strip() + + if action == "status": + return desktop_session.collect_desktop_status() + if action in {"desktop", "open", "session"}: + return self._desktop() + if action in {"open_document", "document"}: + return self._open_document(input, request) + if action in {"save", "desktop_save"}: + return self._save(input) + if action in {"sync", "desktop_sync", "heartbeat"}: + return self._sync(input) + if action in {"state", "desktop_state"}: + return self._state(input) + if action in {"shutdown", "desktop_shutdown"}: + return self._shutdown(input) + return {"ok": False, "error": f"Unsupported desktop session action: {action}"} + + def _desktop(self) -> dict: + desktop = desktop_session.get_manager().ensure_system_desktop() + if not desktop.get("available"): + return { + "ok": False, + "error": desktop.get("error") or "Desktop session is unavailable.", + "status": desktop.get("status") or {}, + "desktop": desktop, + "libreoffice": libreoffice.collect_status(), + } + document = { + "file_id": desktop_session.SYSTEM_FILE_ID, + "path": desktop["path"], + "basename": desktop["title"], + "title": desktop["title"], + "extension": "desktop", + "size": 0, + "version": 0, + } + return { + "ok": True, + "session_id": desktop["session_id"], + "desktop_session_id": desktop["session_id"], + "file_id": desktop_session.SYSTEM_FILE_ID, + "title": desktop["title"], + "extension": "desktop", + "path": desktop["path"], + "text": "", + "document": document, + "version": 0, + "desktop": desktop, + "store_session_id": "", + "mode": "desktop", + } + + def _open_document(self, input: dict, request: Request) -> dict: + context_id = str(input.get("ctxid") or input.get("context_id") or "").strip() + file_id = str(input.get("file_id") or "").strip() + try: + doc = ( + document_store.get_document(file_id) + if file_id + else document_store.register_document(str(input.get("path") or ""), context_id=context_id) + ) + except Exception as exc: + return {"ok": False, "error": str(exc)} + + ext = str(doc.get("extension") or "").lower() + if ext in document_store.EDITOR_TEXT_EXTENSIONS: + return { + "ok": False, + "error": "Text documents use the Editor surface.", + "requires_editor": True, + "document": _public_doc(doc), + } + if ext not in desktop_session.OFFICIAL_EXTENSIONS: + return {"ok": False, "error": f".{ext} documents do not use the Desktop surface."} + + store_session = document_store.create_session( + doc["file_id"], + user_id=str(input.get("user_id") or "agent-zero-user"), + permission="write", + origin=self._origin(request), + ) + desktop = desktop_session.get_manager().open(doc, refresh=input.get("refresh") is True) + if not desktop.get("available"): + document_store.close_session(session_id=store_session["session_id"]) + return { + "ok": False, + "error": desktop.get("error") or desktop.get("reason") or "Desktop session is unavailable.", + "desktop": desktop, + "libreoffice": libreoffice.collect_status(), + } + return { + "ok": True, + "session_id": desktop["session_id"], + "desktop_session_id": desktop["session_id"], + "file_id": doc["file_id"], + "title": doc["basename"], + "extension": doc["extension"], + "path": doc["path"], + "text": "", + "document": _public_doc(doc), + "version": document_store.item_version(doc), + "desktop": desktop, + "store_session_id": store_session["session_id"], + "mode": "edit", + } + + def _save(self, input: dict) -> dict: + session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip() + if not session_id: + return {"ok": False, "error": "desktop_session_id is required."} + return desktop_session.get_manager().save( + session_id, + file_id=str(input.get("file_id") or ""), + ) + + def _sync(self, input: dict) -> dict: + return desktop_session.get_manager().sync( + session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""), + file_id=str(input.get("file_id") or ""), + ) + + def _state(self, input: dict) -> dict: + return desktop_session.get_manager().state( + include_screenshot=bool(input.get("include_screenshot") is True), + context_id=str(input.get("ctxid") or input.get("context_id") or ""), + ) + + def _shutdown(self, input: dict) -> dict: + return desktop_session.get_manager().shutdown_system_desktop( + save_first=input.get("save_first") is not False, + source=str(input.get("source") or "api"), + ) + + def _origin(self, request: Request) -> str: + origin = request.headers.get("Origin") or request.host_url.rstrip("/") + return origin.rstrip("/") + + +def _public_doc(doc: dict) -> dict: + return { + "file_id": doc["file_id"], + "path": document_store.display_path(doc["path"]), + "basename": doc["basename"], + "title": doc["basename"], + "extension": doc["extension"], + "size": doc["size"], + "version": document_store.item_version(doc), + "last_modified": doc["last_modified"], + } diff --git a/plugins/_desktop/assets/desktop/README.md b/plugins/_desktop/assets/desktop/README.md new file mode 100644 index 0000000000000000000000000000000000000000..61418690f9340c10a73c11d62d96db1da482457c --- /dev/null +++ b/plugins/_desktop/assets/desktop/README.md @@ -0,0 +1,142 @@ +# Agent Zero Desktop + +This Desktop is Agent Zero's habitat: a visible, stateful Linux workspace where humans and agents can share files, launch tools, inspect results, and turn reasoning into observable work. + +You can choose your harness, but now you and A0 have a habitat. + +Here, the model reasons, the environment remembers, the user grants permission, and the work leaves artifacts that can be opened, tested, revised, and trusted. It is a small workshop for the larger idea: agents become more useful when they have a safe world to act in, not only more instructions to carry. + +With gratitude to the open-source foundations that make this affordance possible: + +- Xpra, for carrying a live Linux desktop through the browser, making this desktop environment usable by AI agents. +- Kali Linux, for the practical Linux craft and tool culture that make capable workspaces feel natural. +- LibreOffice, for giving this habitat a serious office bench: documents, spreadsheets, presentations, and decades of open document stewardship. +- Xfce, for a fast, humane desktop that stays out of the way and lets the work breathe. +- Jan Tomášek, for creating Agent Zero and making this whole workshop possible. + +Agent Zero stands on this craft with respect. Open source is not just code we consume; it is shared affordance, accumulated care, and an invitation to build systems that are powerful without becoming opaque. + +## Open the Terminal + +Double-click **Terminal** on this Desktop. You will land in the Agent Zero workdir, where agent CLIs can inspect files, edit projects, and run commands inside the Linux environment. + +Before installing an agent, make a small safety habit: + +```bash +pwd +node -v || true +npm -v || true +python3 --version || true +curl --version | head -1 || true +``` + +Agent CLIs are powerful. Install from official sources, avoid `sudo npm install -g` unless the project explicitly requires it, and keep API keys in your shell or provider login flow rather than pasting secrets into documents. + +## Install Your Favorite Agent + +These commands were checked against official docs or project READMEs on 2026-05-02. Package names matter. + +### OpenAI Codex + +```bash +npm i -g @openai/codex +codex +``` + +### Claude Code + +Recommended native installer: + +```bash +curl -fsSL https://claude.ai/install.sh | bash +source ~/.bashrc +claude +``` + +NPM alternative: + +```bash +npm install -g @anthropic-ai/claude-code +claude +``` + +### Gemini CLI + +```bash +npm install -g @google/gemini-cli +gemini +``` + +### Aider + +```bash +curl -LsSf https://aider.chat/install.sh | sh +source ~/.bashrc +aider +``` + +### OpenCode + +```bash +curl -sL https://opencode.ai/install | bash +opencode +``` + +NPM alternative: + +```bash +npm i -g opencode +opencode +``` + +### Goose + +```bash +curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash +source ~/.bashrc +goose +``` + +### OpenHands CLI + +```bash +python3 -m pip install uv +uv tool install openhands --python 3.12 +openhands +``` + +### Qwen Code + +```bash +npm install -g @qwen-code/qwen-code@latest +qwen +``` + +### Cursor Agent + +```bash +curl https://cursor.com/install -fsS | bash +source ~/.bashrc +cursor-agent +``` + +### Hermes Agent + +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +source ~/.bashrc +hermes +``` + +### OpenClaw + +OpenClaw recommends Node 24, or Node 22.14+. + +```bash +npm install -g openclaw@latest +openclaw onboard --install-daemon +``` + +If in doubt, start with Codex or Claude Code for coding, Aider for a lighter patch-focused loop, Goose or OpenHands for a broader local agent, and Hermes or OpenClaw when you want the agent to grow into more of a persistent runtime working together with Agent Zero. + +Official references: [Codex](https://developers.openai.com/codex/cli), [Claude Code](https://code.claude.com/docs/en/getting-started), [Gemini CLI](https://google-gemini.github.io/gemini-cli/docs/get-started/), [Aider](https://aider.chat/docs/install.html), [OpenCode](https://www.opencode.live/), [Goose](https://goose-docs.ai/docs/getting-started/installation/), [OpenHands](https://docs.openhands.dev/openhands/usage/cli/installation), [Qwen Code](https://github.com/QwenLM/qwen-code), [Cursor Agent](https://docs.cursor.com/en/cli/installation), [Hermes Agent](https://github.com/NousResearch/hermes-agent), [OpenClaw](https://github.com/openclaw/openclaw). diff --git a/plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py b/plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py new file mode 100644 index 0000000000000000000000000000000000000000..3efa731e66a14b5d5b6dfd7868cf21d536edfb25 --- /dev/null +++ b/plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from agent import LoopData +from helpers.extension import Extension +from plugins._desktop.helpers import prompt_context + + +class IncludeDesktopState(Extension): + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "") + context = prompt_context.build_context(context_id=context_id) + if not context: + loop_data.extras_temporary.pop("desktop_state", None) + return + + loop_data.extras_temporary["desktop_state"] = self.agent.read_prompt( + "agent.extras.desktop_state.md", + desktop_state=context, + ) if self.agent else context diff --git a/plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py b/plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..1145337572f52c063f2c91b9dbbaeafa39556a90 --- /dev/null +++ b/plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import threading +from typing import Any + +from helpers.extension import Extension +from helpers.print_style import PrintStyle +from helpers import virtual_desktop_routes +from plugins._desktop import hooks + + +_startup_preparation_thread: threading.Thread | None = None + + +class DesktopStartup(Extension): + def execute(self, **kwargs): + virtual_desktop_routes.install_route_hooks() + _start_background_runtime_preparation() + + +def _start_background_runtime_preparation() -> threading.Thread: + global _startup_preparation_thread + + if _startup_preparation_thread and _startup_preparation_thread.is_alive(): + return _startup_preparation_thread + + _startup_preparation_thread = threading.Thread( + target=_prepare_runtime_safely, + name="a0-desktop-runtime-preparation", + daemon=True, + ) + _startup_preparation_thread.start() + return _startup_preparation_thread + + +def _prepare_runtime_safely() -> None: + try: + _log_runtime_preparation_result(hooks.cleanup_stale_runtime_state()) + except Exception as exc: + PrintStyle.warning("Desktop runtime preparation failed:", exc) + + +def _log_runtime_preparation_result(result: dict[str, Any]) -> None: + if result.get("errors"): + PrintStyle.warning("Desktop runtime preparation reported errors:", result["errors"]) + elif result.get("warnings"): + PrintStyle.warning("Desktop runtime preparation reported warnings:", result["warnings"]) + elif result.get("installed") or result.get("removed") or result.get("migrated"): + PrintStyle.info("Desktop runtime prepared:", result) diff --git a/plugins/_desktop/extensions/webui/right-canvas-panels/desktop-panel.html b/plugins/_desktop/extensions/webui/right-canvas-panels/desktop-panel.html new file mode 100644 index 0000000000000000000000000000000000000000..325d7bd71dfef352028980503f63d10d2b9f94d2 --- /dev/null +++ b/plugins/_desktop/extensions/webui/right-canvas-panels/desktop-panel.html @@ -0,0 +1,15 @@ +
+ +
diff --git a/plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html b/plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html new file mode 100644 index 0000000000000000000000000000000000000000..bb33649a96ab75044064bff62e5dacdb070bf3e6 --- /dev/null +++ b/plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html @@ -0,0 +1,38 @@ + diff --git a/plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js b/plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js new file mode 100644 index 0000000000000000000000000000000000000000..6efd5f15459b26ba082c9c0e16b85408db8848e2 --- /dev/null +++ b/plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js @@ -0,0 +1,49 @@ +import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js"; + +function waitForElement(selector, timeoutMs = 10000) { + const found = document.querySelector(selector); + if (found) return Promise.resolve(found); + return new Promise((resolve) => { + const timeout = globalThis.setTimeout(() => { + observer.disconnect(); + resolve(document.querySelector(selector)); + }, timeoutMs); + const observer = new MutationObserver(() => { + const element = document.querySelector(selector); + if (!element) return; + globalThis.clearTimeout(timeout); + observer.disconnect(); + resolve(element); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); +} + +export default async function registerDesktopSurface(surfaces) { + surfaces.registerSurface({ + id: "desktop", + title: "Desktop", + icon: "desktop_windows", + order: 20, + modalPath: "/plugins/_desktop/webui/main.html", + async beginDockHandoff() { + desktopStore.beforeDesktopHostHandoff?.(); + }, + async finishDockHandoff(payload = {}) { + if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" }); + }, + async cancelDockHandoff() { + desktopStore.cancelDesktopHostHandoff?.(); + }, + async open(payload = {}) { + const panel = await waitForElement('[data-surface-id="desktop"] .office-panel'); + if (!panel) throw new Error("Desktop surface panel did not mount."); + await desktopStore.onMount?.(panel, { mode: "canvas" }); + await desktopStore.onOpen?.(payload); + desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" }); + }, + async close(payload = {}) { + desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" }); + }, + }); +} diff --git a/plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js b/plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js new file mode 100644 index 0000000000000000000000000000000000000000..6efd5f15459b26ba082c9c0e16b85408db8848e2 --- /dev/null +++ b/plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js @@ -0,0 +1,49 @@ +import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js"; + +function waitForElement(selector, timeoutMs = 10000) { + const found = document.querySelector(selector); + if (found) return Promise.resolve(found); + return new Promise((resolve) => { + const timeout = globalThis.setTimeout(() => { + observer.disconnect(); + resolve(document.querySelector(selector)); + }, timeoutMs); + const observer = new MutationObserver(() => { + const element = document.querySelector(selector); + if (!element) return; + globalThis.clearTimeout(timeout); + observer.disconnect(); + resolve(element); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); +} + +export default async function registerDesktopSurface(surfaces) { + surfaces.registerSurface({ + id: "desktop", + title: "Desktop", + icon: "desktop_windows", + order: 20, + modalPath: "/plugins/_desktop/webui/main.html", + async beginDockHandoff() { + desktopStore.beforeDesktopHostHandoff?.(); + }, + async finishDockHandoff(payload = {}) { + if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" }); + }, + async cancelDockHandoff() { + desktopStore.cancelDesktopHostHandoff?.(); + }, + async open(payload = {}) { + const panel = await waitForElement('[data-surface-id="desktop"] .office-panel'); + if (!panel) throw new Error("Desktop surface panel did not mount."); + await desktopStore.onMount?.(panel, { mode: "canvas" }); + await desktopStore.onOpen?.(payload); + desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" }); + }, + async close(payload = {}) { + desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" }); + }, + }); +} diff --git a/plugins/_desktop/helpers/desktop_routes.py b/plugins/_desktop/helpers/desktop_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..fc12f68a386c44b38d9e591f6c071a04bb41ba44 --- /dev/null +++ b/plugins/_desktop/helpers/desktop_routes.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from helpers.virtual_desktop_routes import VirtualDesktopGateway, install_route_hooks, is_installed + + +__all__ = ["VirtualDesktopGateway", "install_route_hooks", "is_installed"] diff --git a/plugins/_desktop/helpers/desktop_session.py b/plugins/_desktop/helpers/desktop_session.py new file mode 100644 index 0000000000000000000000000000000000000000..d538db0379919f8dbab58d7803cb5775983bad9c --- /dev/null +++ b/plugins/_desktop/helpers/desktop_session.py @@ -0,0 +1,2601 @@ +from __future__ import annotations + +import atexit +import fcntl +import hashlib +import json +import os +import shutil +import socket +import subprocess +import threading +import time +import uuid +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from helpers import files, virtual_desktop +from helpers.localization import Localization +from plugins._desktop.helpers import desktop_state +from plugins._office.helpers import document_store, libreoffice + + +OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"} +PLUGIN_NAME = "_desktop" +SYSTEM_SESSION_ID = "agent-zero-desktop" +SYSTEM_FILE_ID = "system-desktop" +SYSTEM_TITLE = "Desktop" +STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME)) +RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME)) +SESSION_DIR = STATE_DIR / "sessions" +PROFILE_DIR = STATE_DIR / "profiles" +LEGACY_SESSION_DIRS = ( + RETIRED_STATE_DIR / "sessions", + Path(files.get_abs_path("tmp", "_office", "desktop", "sessions")), +) +DISPLAY_BASE = 120 +XPRA_PORT_BASE = 14500 +MAX_SESSIONS = 12 +DEFAULT_SCREEN_WIDTH = virtual_desktop.DEFAULT_WIDTH +DEFAULT_SCREEN_HEIGHT = virtual_desktop.DEFAULT_HEIGHT +MAX_SCREEN_WIDTH = virtual_desktop.MAX_WIDTH +MAX_SCREEN_HEIGHT = virtual_desktop.MAX_HEIGHT +BLOCKING_DIALOG_TITLES = ("Remote Files", "File Services") +DISPLAY_START_TIMEOUT_SECONDS = 30.0 +PORT_START_TIMEOUT_SECONDS = 30.0 +STARTUP_GRACE_SECONDS = 45 +RUNTIME_INSTALL_MESSAGE = ( + "Installing Agent Zero Desktop runtime dependencies. " + "This can take a few minutes after an update." +) +HIDDEN_XPRA_DESKTOP_ENTRIES = ( + "xpra.desktop", + "xpra-gui.desktop", + "xpra-launcher.desktop", + "xpra-shadow.desktop", + "xpra-start.desktop", +) +HIDDEN_XFCE_MENU_ENTRIES = ( + ("exo-mail-reader.desktop", "Mail Reader"), + ("exo-web-browser.desktop", "Web Browser"), + ("xfce4-mail-reader.desktop", "Mail Reader"), + ("xfce4-web-browser.desktop", "Web Browser"), + ("xfce4-session-logout.desktop", "Log Out"), + ("xfce4-lock-screen.desktop", "Lock Screen"), + ("xflock4.desktop", "Lock Screen"), + ("xfce4-switch-user.desktop", "Switch User"), +) +DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md" +DESKTOP_FOLDER_LINKS = ( + ("Projects", ("usr", "projects")), + ("Skills", ("usr", "skills")), + ("Agents", ("usr", "agents")), + ("Downloads", ("usr", "downloads")), +) +URL_INTENT_MAX_ITEMS = 50 +URL_INTENT_MAX_LENGTH = 8192 +URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop" +EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop" +WRITER_HANDLER_DESKTOP_ID = "libreoffice-writer.desktop" +SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop" +SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID +SHUTDOWN_CONFIRM_SECONDS = 8 +OOR_NS = "http://openoffice.org/2001/registry" +XS_NS = "http://www.w3.org/2001/XMLSchema" +XSI_NS = "http://www.w3.org/2001/XMLSchema-instance" + + +@dataclass +class DesktopSession: + session_id: str + file_id: str + extension: str + path: str + title: str + display: int + xpra_port: int + token: str + url: str + profile_dir: Path + width: int = DEFAULT_SCREEN_WIDTH + height: int = DEFAULT_SCREEN_HEIGHT + processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict) + process_ids: dict[str, int] = field(default_factory=dict) + owns_processes: bool = True + started_at: float = field(default_factory=time.time) + timezone: str = field(default_factory=lambda: Localization.get().get_timezone()) + + def alive(self) -> bool: + return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0)) + + def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]: + title = str(doc.get("basename") or "") if doc else self.title + path = str(doc.get("path") or "") if doc else self.path + extension = str(doc.get("extension") or "") if doc else self.extension + file_id = str(doc.get("file_id") or "") if doc else self.file_id + return { + "available": True, + "session_id": self.session_id, + "file_id": file_id, + "extension": extension, + "title": title, + "path": document_store.display_path(path), + "url": self.url, + "token": self.token, + "display": f":{self.display}", + "desktop_path": virtual_desktop.SESSION_PATH, + "width": self.width, + "height": self.height, + "started_at": self.started_at, + } + + +class DesktopSessionManager: + def __init__(self) -> None: + self._lock = threading.RLock() + self._sessions: dict[str, DesktopSession] = {} + + def ensure_system_desktop(self) -> dict[str, Any]: + try: + with self._lock: + self._reap_dead_locked() + session = self._ensure_system_desktop_locked() + return session.public() + except Exception as exc: + status = collect_desktop_status() + return { + "available": False, + "error": str(exc), + "status": status, + } + + def open(self, doc: dict[str, Any], *, refresh: bool = False) -> dict[str, Any]: + ext = str(doc.get("extension") or "").lower() + if ext not in OFFICIAL_EXTENSIONS: + return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."} + + with self._lock: + self._reap_dead_locked() + try: + session = self._ensure_system_desktop_locked() + except Exception as exc: + status = collect_desktop_status() + return { + "available": False, + "error": str(exc), + "status": status, + } + refreshed = False + try: + if refresh and session.file_id == str(doc.get("file_id") or ""): + refreshed = self._reload_document_locked(session, doc) + else: + self._open_document_locked(session, doc) + except Exception as exc: + return { + "available": False, + "error": str(exc), + "status": collect_desktop_status(), + } + session.file_id = str(doc["file_id"]) + session.extension = ext + session.path = str(doc["path"]) + session.title = str(doc["basename"]) + self._write_manifest(session) + public = session.public(doc) + public["refreshed"] = refreshed + return public + + def refresh_document(self, file_id: str) -> dict[str, Any]: + normalized = str(file_id or "").strip() + if not normalized: + return {"ok": True, "refreshed": False} + try: + doc = document_store.get_document(normalized) + except Exception: + return {"ok": False, "refreshed": False, "error": "Document not found."} + + ext = str(doc.get("extension") or "").lower() + if ext not in OFFICIAL_EXTENSIONS: + return {"ok": True, "refreshed": False} + + with self._lock: + self._reap_dead_locked() + session = self._find_by_file_id_locked(normalized) + if not session: + existing = self._load_system_desktop_from_manifest_locked() + if existing: + self._sessions[existing.session_id] = existing + self._register_virtual_desktop(existing) + if existing.file_id == normalized: + session = existing + if not session: + return {"ok": True, "refreshed": False} + refreshed = self._reload_document_locked(session, doc) + session.file_id = str(doc["file_id"]) + session.extension = ext + session.path = str(doc["path"]) + session.title = str(doc["basename"]) + self._write_manifest(session) + return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)} + + def save(self, session_id: str, file_id: str = "") -> dict[str, Any]: + with self._lock: + session = self.require(session_id) + doc = self._document_for_save(session, file_id) + if not doc: + return { + "ok": True, + "session_id": session.session_id, + "document": None, + "changed": False, + } + + xdotool = shutil.which("xdotool") + if not xdotool: + updated = document_store.register_document(doc["path"]) + return { + "ok": False, + "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.", + "document": _public_doc(updated), + } + + window_id = self._office_window_id_locked( + session, + title=str(doc.get("basename") or Path(doc["path"]).name), + fallback=False, + ) + if not window_id: + return { + "ok": False, + "error": f"LibreOffice window not found for {doc['basename']}.", + "document": _public_doc(doc), + } + + result = subprocess.run( + [ + xdotool, + "windowactivate", + "--sync", + window_id, + "key", + "--clearmodifiers", + "ctrl+s", + ], + check=False, + capture_output=True, + text=True, + timeout=8, + env=self._display_env(session), + ) + time.sleep(0.8) + updated = document_store.register_document(doc["path"]) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + return { + "ok": False, + "error": detail or "LibreOffice desktop save shortcut failed.", + "document": _public_doc(updated), + } + return { + "ok": True, + "session_id": session.session_id, + "document": _public_doc(updated), + "changed": updated.get("sha256") != doc.get("sha256"), + } + + def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]: + session = self.get(session_id) if session_id else self._find_by_file_id(file_id) + if not session: + return {"ok": False, "error": "LibreOffice desktop session not found."} + if not _url_bridge_script_path(session).exists(): + try: + self._prepare_desktop_url_bridge(session) + self._refresh_xfce_desktop(session) + except Exception: + pass + url_intents = self.claim_url_intents(session.session_id) + shutdown_request = self.claim_shutdown_request(session.session_id) + if shutdown_request: + return self.shutdown_system_desktop( + save_first=True, + source=str(shutdown_request.get("source") or "tray"), + ) + doc = self._document_for_save(session, file_id) + if not doc: + return { + "ok": True, + "session_id": session.session_id, + "desktop": session.public(), + "url_intents": url_intents, + } + updated = document_store.register_document(doc["path"]) + return { + "ok": True, + "session_id": session.session_id, + "document": _public_doc(updated), + "url_intents": url_intents, + } + + def state(self, *, include_screenshot: bool = False, context_id: str = "") -> dict[str, Any]: + with self._lock: + self._reap_dead_locked() + return desktop_state.collect_state( + include_screenshot=include_screenshot, + context_id=context_id, + ) + + def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]: + session = self.get(session_id) or self.get(SYSTEM_SESSION_ID) + if not session: + return [] + return _claim_url_intents(session) + + def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None: + session = self.get(session_id) or self.get(SYSTEM_SESSION_ID) + if not session: + return None + return _claim_shutdown_request(session) + + def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]: + with self._lock: + session = self._sessions.get(SYSTEM_SESSION_ID) + if not session: + _remove_system_manifest() + return { + "ok": True, + "closed": 0, + "session_id": SYSTEM_SESSION_ID, + "shutdown": True, + "intentional_shutdown": True, + "source": source, + } + + save_result = None + if save_first: + try: + save_result = self.save(session.session_id) + except Exception as exc: + save_result = {"ok": False, "error": str(exc)} + + with self._lock: + if self._sessions.get(SYSTEM_SESSION_ID) is session: + self._sessions.pop(SYSTEM_SESSION_ID, None) + virtual_desktop.unregister_session(session.token) + self._terminate_session(session, include_rehydrated=True) + self._remove_manifest(session.session_id) + _clear_shutdown_request(session) + return { + "ok": True, + "closed": 1, + "session_id": session.session_id, + "shutdown": True, + "intentional_shutdown": True, + "source": source, + "save": save_result, + } + + def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]: + session = self._find_by_file_id(file_id) + if not session: + return {"ok": True, "updated": False} + with self._lock: + session.path = str(doc["path"]) + session.title = str(doc["basename"]) + session.extension = str(doc["extension"]) + self._write_manifest(session) + return {"ok": True, "updated": True, "desktop": session.public(doc)} + + def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]: + with self._lock: + normalized = str(session_id or "").strip() + session = self._sessions.get(normalized) + if not session: + return {"ok": True, "closed": 0} + if session.session_id == SYSTEM_SESSION_ID: + save_result = None + if save_first: + try: + save_result = self.save(session.session_id) + except Exception as exc: + save_result = {"ok": False, "error": str(exc)} + return { + "ok": True, + "closed": 0, + "session_id": session.session_id, + "persistent": True, + "save": save_result, + } + + save_result = None + if save_first: + try: + save_result = self.save(session.session_id) + except Exception as exc: + save_result = {"ok": False, "error": str(exc)} + with self._lock: + self._sessions.pop(session.session_id, None) + virtual_desktop.unregister_session(session.token) + self._terminate_session(session, include_rehydrated=True) + self._remove_manifest(session.session_id) + return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result} + + def close_file(self, file_id: str) -> int: + return 0 + + def resize(self, session_id: str, width: int, height: int) -> dict[str, Any]: + session = self.get(session_id) + if not session: + return {"ok": False, "error": "LibreOffice desktop session not found."} + is_system_desktop = session.session_id == SYSTEM_SESSION_ID and session.extension == "desktop" + if is_system_desktop: + width, height = virtual_desktop.normalize_desktop_display_size(width, height) + result = virtual_desktop.resize_display( + display=session.display, + width=width, + height=height, + max_width=MAX_SCREEN_WIDTH, + max_height=MAX_SCREEN_HEIGHT, + window_class="" if is_system_desktop else "libreoffice", + keys=() if is_system_desktop else ("Escape",), + xauthority=self._xauthority(session), + home=str(session.profile_dir), + ) + if result.get("ok"): + session.width = int(result["width"]) + session.height = int(result["height"]) + if not is_system_desktop: + self._dismiss_blocking_dialogs(session) + return result + + def proxy_for_token(self, token: str) -> tuple[str, int] | None: + normalized = str(token or "").strip() + with self._lock: + session = self._sessions.get(normalized) + if not session: + session = next((item for item in self._sessions.values() if item.token == normalized), None) + if not session or not session.alive(): + return None + return ("127.0.0.1", session.xpra_port) + + def resize_for_token(self, token: str, width: int, height: int) -> dict[str, Any]: + normalized = str(token or "").strip() + with self._lock: + session = self._sessions.get(normalized) + if not session: + session = next((item for item in self._sessions.values() if item.token == normalized), None) + if not session: + return {"ok": False, "error": "LibreOffice desktop session not found."} + return self.resize(session.session_id, width, height) + + def get(self, session_id: str) -> DesktopSession | None: + with self._lock: + session = self._sessions.get(str(session_id or "").strip()) + return session if session and session.alive() else None + + def require(self, session_id: str) -> DesktopSession: + session = self.get(session_id) + if not session: + raise FileNotFoundError(f"LibreOffice desktop session not found: {session_id}") + return session + + def shutdown(self) -> None: + with self._lock: + sessions = list(self._sessions.values()) + self._sessions.clear() + for session in sessions: + virtual_desktop.unregister_session(session.token) + if session.owns_processes: + self._terminate_session(session) + self._remove_manifest(session.session_id) + + def sync_timezone(self, timezone: str | None = None) -> dict[str, Any]: + target_timezone = str(timezone or Localization.get().get_timezone()).strip() + if not target_timezone: + target_timezone = Localization.get().get_timezone() + + with self._lock: + self._reap_dead_locked() + session = self._sessions.get(SYSTEM_SESSION_ID) + if not session or not session.alive(): + return {"ok": True, "restarted": False, "reason": "no_active_desktop"} + if session.timezone == target_timezone: + return {"ok": True, "restarted": False, "timezone": target_timezone} + + replacement = self._restart_system_desktop_for_timezone_locked(session) + return { + "ok": True, + "restarted": True, + "session_id": replacement.session_id, + "timezone": replacement.timezone, + } + + def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None: + normalized = str(file_id or "").strip() + if normalized == SYSTEM_FILE_ID: + return None + if normalized and normalized != SYSTEM_FILE_ID: + return document_store.get_document(normalized) + if session.file_id and session.file_id != SYSTEM_FILE_ID: + try: + return document_store.get_document(session.file_id) + except Exception: + path = Path(session.path) + if path.is_file(): + return document_store.register_document(path) + return None + + def _restart_system_desktop_for_timezone_locked(self, session: DesktopSession) -> DesktopSession: + try: + doc = self._document_for_save(session, session.file_id) + except Exception: + doc = None + if doc: + try: + self.save(session.session_id, str(doc.get("file_id") or "")) + except Exception: + pass + + virtual_desktop.unregister_session(session.token) + self._sessions.pop(session.session_id, None) + self._terminate_session(session, include_rehydrated=True) + self._remove_manifest(session.session_id) + + replacement = self._ensure_system_desktop_locked() + if doc: + self._open_document_locked(replacement, doc) + replacement.file_id = str(doc["file_id"]) + replacement.extension = str(doc["extension"]) + replacement.path = str(doc["path"]) + replacement.title = str(doc["basename"]) + self._write_manifest(replacement) + return replacement + + def _register_virtual_desktop(self, session: DesktopSession) -> None: + virtual_desktop.register_session( + token=session.token, + host="127.0.0.1", + port=session.xpra_port, + owner="desktop", + title=session.title, + resize=lambda width, height, session_id=session.session_id: self.resize(session_id, width, height), + ) + + def _ensure_system_desktop_locked(self) -> DesktopSession: + target_timezone = Localization.get().get_timezone() + existing = self._sessions.get(SYSTEM_SESSION_ID) + if existing and existing.alive(): + if existing.timezone != target_timezone: + return self._restart_system_desktop_for_timezone_locked(existing) + self._prepare_desktop_url_bridge(existing) + self._refresh_xfce_desktop(existing) + return existing + + existing = self._load_system_desktop_from_manifest_locked() + if existing: + self._sessions[existing.session_id] = existing + self._register_virtual_desktop(existing) + if existing.timezone != target_timezone: + return self._restart_system_desktop_for_timezone_locked(existing) + self._prepare_desktop_url_bridge(existing) + self._refresh_xfce_desktop(existing) + return existing + + status = collect_desktop_status() + if not status["healthy"]: + raise RuntimeError(status["message"]) + + display, xpra_port = self._allocate_endpoint_locked() + profile_dir = PROFILE_DIR / SYSTEM_SESSION_ID + session = DesktopSession( + session_id=SYSTEM_SESSION_ID, + file_id=SYSTEM_FILE_ID, + extension="desktop", + path=str(document_store.document_binary_home()), + title=SYSTEM_TITLE, + display=display, + xpra_port=xpra_port, + token=SYSTEM_SESSION_ID, + url=_xpra_url(SYSTEM_SESSION_ID), + profile_dir=profile_dir, + timezone=target_timezone, + ) + try: + self._prepare_profile(session) + self._prepare_desktop_launchers(session) + self._spawn_desktop_locked(session) + except Exception: + self._terminate_session(session) + raise + self._sessions[session.session_id] = session + self._register_virtual_desktop(session) + self._write_manifest(session) + return session + + def _load_system_desktop_from_manifest_locked(self) -> DesktopSession | None: + manifest = SESSION_DIR / f"{SYSTEM_SESSION_ID}.json" + if not manifest.exists(): + return None + try: + payload = json.loads(manifest.read_text(encoding="utf-8")) + display = int(payload.get("display") or 0) + xpra_port = int(payload.get("xpra_port") or 0) + process_ids = { + str(name): pid + for name, value in dict(payload.get("pids") or {}).items() + if (pid := _coerce_pid(value)) + } + if not display or not xpra_port: + return None + if not _pid_is_running(process_ids.get("xpra", 0)): + return None + if not _port_is_accepting("127.0.0.1", xpra_port): + return None + + path = str(payload.get("path") or document_store.document_binary_home()) + file_id = str(payload.get("file_id") or SYSTEM_FILE_ID) + extension = str(payload.get("extension") or "").lower() + if not extension: + extension = "desktop" if file_id == SYSTEM_FILE_ID else Path(path).suffix.lower().lstrip(".") + title = str(payload.get("title") or "") + if not title: + title = SYSTEM_TITLE if file_id == SYSTEM_FILE_ID else Path(path).name or SYSTEM_TITLE + return DesktopSession( + session_id=SYSTEM_SESSION_ID, + file_id=file_id, + extension=extension, + path=path, + title=title, + display=display, + xpra_port=xpra_port, + token=SYSTEM_SESSION_ID, + url=_xpra_url(SYSTEM_SESSION_ID), + profile_dir=_state_path_from_retired_root( + Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID) + ), + width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH), + height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT), + process_ids=process_ids, + owns_processes=False, + started_at=float(payload.get("started_at") or time.time()), + timezone=str(payload.get("timezone") or Localization.get().get_timezone()), + ) + except Exception: + return None + + def _spawn_desktop_locked(self, session: DesktopSession) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + SESSION_DIR.mkdir(parents=True, exist_ok=True) + session.profile_dir.mkdir(parents=True, exist_ok=True) + + xpra = _require_binary("xpra") + xvfb = _require_binary("Xvfb") + _require_binary("xfce4-session") + _require_binary("dbus-launch") + xfce_launcher = self._prepare_xfce_launcher(session) + + session.processes["xvfb"] = subprocess.Popen( + _xvfb_command(xvfb, session), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self._session_env(session), + ) + self._wait_for_display(session) + self._set_display_size(session, session.width, session.height) + self._prepare_root_window(session) + session.processes["xfce"] = subprocess.Popen( + [str(xfce_launcher)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self._display_env(session), + ) + self._wait_for_xfce(session) + session.processes["xpra"] = subprocess.Popen( + _xpra_shadow_command(xpra, session), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self._display_env(session), + ) + _wait_for_port( + "127.0.0.1", + session.xpra_port, + timeout=PORT_START_TIMEOUT_SECONDS, + process=session.processes.get("xpra"), + ) + self._refresh_xfce_desktop(session) + + def _restart_xpra_shadow(self, session: DesktopSession) -> None: + xpra = _require_binary("xpra") + process = session.processes.get("xpra") + if process: + _terminate_process(process) + session.processes["xpra"] = subprocess.Popen( + _xpra_shadow_command(xpra, session), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self._display_env(session), + ) + _wait_for_port( + "127.0.0.1", + session.xpra_port, + timeout=PORT_START_TIMEOUT_SECONDS, + process=session.processes.get("xpra"), + ) + + def _open_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> None: + soffice = libreoffice.find_soffice() + if not soffice: + raise RuntimeError("LibreOffice is not installed in this runtime.") + path = str(doc["path"]) + self._remove_stale_lock_file(session, path=path) + process_key = f"soffice-{doc['file_id']}" + session.processes[process_key] = subprocess.Popen( + [ + soffice, + "--norestore", + "--nofirststartwizard", + "--nolockcheck", + f"-env:UserInstallation=file://{session.profile_dir}", + path, + ], + cwd=str(Path(path).parent), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self._display_env(session), + ) + self._fit_office_window(session, process=session.processes[process_key]) + window_id = self._wait_for_office_window_locked( + session, + title=str(doc.get("basename") or ""), + process=session.processes[process_key], + ) + if not window_id: + raise RuntimeError( + f"LibreOffice did not show {doc.get('basename') or 'the document'} " + f"on desktop :{session.display}.", + ) + self._fit_office_window_id_locked( + session, + window_id, + env=self._display_env(session), + keys=("Escape",), + ) + + def _reload_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool: + if self._close_document_window_locked(session, doc): + self._open_document_locked(session, doc) + return True + if self._send_reload_shortcut_locked(session, doc): + return True + self._open_document_locked(session, doc) + return False + + def _close_document_window_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool: + xdotool = shutil.which("xdotool") + if not xdotool: + return False + title = str(doc.get("basename") or Path(str(doc.get("path") or "")).name or "").strip() + if not title: + return False + window_id = self._office_window_id_locked(session, title=title, fallback=False) + if not window_id: + return False + env = self._display_env(session) + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",)) + for command in ( + [xdotool, "key", "--clearmodifiers", "alt+F4"], + [xdotool, "windowclose", window_id], + ): + try: + subprocess.run( + command, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=3, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if self._wait_for_window_closed_locked(session, window_id): + return True + self._dismiss_blocking_dialogs(session) + return self._wait_for_window_closed_locked(session, window_id, timeout_seconds=1.5) + + def _send_reload_shortcut_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool: + xdotool = shutil.which("xdotool") + if not xdotool: + return False + window_id = self._office_window_id_locked( + session, + title=str(doc.get("basename") or ""), + ) + if not window_id: + return False + env = self._display_env(session) + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",)) + try: + result = subprocess.run( + [xdotool, "key", "--clearmodifiers", "ctrl+shift+r"], + check=False, + capture_output=True, + text=True, + timeout=4, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + return False + time.sleep(0.8) + self._dismiss_blocking_dialogs(session) + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",)) + return result.returncode == 0 + + def _office_window_id_locked( + self, + session: DesktopSession, + *, + title: str = "", + fallback: bool = True, + ) -> str: + xdotool = shutil.which("xdotool") + if not xdotool: + return "" + env = self._display_env(session) + title = str(title or "").strip() + try: + result = subprocess.run( + [xdotool, "search", "--onlyvisible", "--class", "libreoffice"], + check=False, + capture_output=True, + text=True, + timeout=2, + env=env, + ) + window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()] + except (OSError, subprocess.TimeoutExpired): + window_ids = [] + if title: + for window_id in reversed(window_ids): + try: + result = subprocess.run( + [xdotool, "getwindowname", window_id], + check=False, + capture_output=True, + text=True, + timeout=2, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if title.casefold() in result.stdout.strip().casefold(): + return window_id + if fallback and window_ids: + return window_ids[-1] + if fallback: + try: + result = subprocess.run( + [xdotool, "search", "--onlyvisible", "--name", "LibreOffice"], + check=False, + capture_output=True, + text=True, + timeout=2, + env=env, + ) + window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()] + except (OSError, subprocess.TimeoutExpired): + window_ids = [] + if window_ids: + return window_ids[-1] + return "" + + def _wait_for_office_window_locked( + self, + session: DesktopSession, + *, + title: str = "", + process: subprocess.Popen[Any] | None = None, + timeout_seconds: float = 20.0, + ) -> str: + deadline = time.time() + timeout_seconds + last_fallback = "" + title = str(title or "").strip() + while time.time() < deadline: + window_id = self._office_window_id_locked(session, title=title, fallback=False) + if window_id: + return window_id + last_fallback = self._office_window_id_locked(session, fallback=True) or last_fallback + if last_fallback and not title: + return last_fallback + if process and process.poll() is not None and last_fallback: + return last_fallback + time.sleep(0.25) + return self._office_window_id_locked(session, title=title, fallback=True) or last_fallback + + def _wait_for_window_closed_locked( + self, + session: DesktopSession, + window_id: str, + *, + timeout_seconds: float = 6.0, + ) -> bool: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if not self._window_exists_locked(session, window_id): + return True + time.sleep(0.2) + return not self._window_exists_locked(session, window_id) + + def _window_exists_locked(self, session: DesktopSession, window_id: str) -> bool: + xdotool = shutil.which("xdotool") + if not xdotool or not window_id: + return False + try: + result = subprocess.run( + [xdotool, "getwindowname", str(window_id)], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + env=self._display_env(session), + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + def _fit_office_window_id_locked( + self, + session: DesktopSession, + window_id: str, + *, + env: dict[str, str], + keys: tuple[str, ...] = (), + ) -> None: + xdotool = shutil.which("xdotool") + if not xdotool or not window_id: + return + for command in ( + [xdotool, "windowactivate", window_id], + [ + xdotool, + "windowmove", + window_id, + "0", + "0", + "windowsize", + window_id, + str(session.width), + str(session.height), + ], + ): + try: + subprocess.run( + command, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + continue + for key in keys: + try: + subprocess.run( + [xdotool, "key", "--clearmodifiers", key], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + continue + + def _prepare_profile(self, session: DesktopSession) -> None: + user_dir = session.profile_dir / "user" + user_dir.mkdir(parents=True, exist_ok=True) + registry = user_dir / "registrymodifications.xcu" + _write_libreoffice_registry_defaults(registry, document_store.document_home()) + + def _prepare_desktop_launchers(self, session: DesktopSession) -> None: + soffice = libreoffice.find_soffice() + if not soffice: + raise RuntimeError("LibreOffice is not installed in this runtime.") + workdir_home = document_store.document_home() + workdir_home.mkdir(parents=True, exist_ok=True) + documents_home = document_store.document_binary_home() + documents_home.mkdir(parents=True, exist_ok=True) + downloads_home = Path(files.get_abs_path("usr", "downloads")) + downloads_home.mkdir(parents=True, exist_ok=True) + + desktop_dir = session.profile_dir / "Desktop" + desktop_dir.mkdir(parents=True, exist_ok=True) + _install_desktop_readme(desktop_dir) + _remove_path_if_owned(desktop_dir / "Browser.desktop") + _remove_path_if_owned(desktop_dir / "Files.desktop") + config_dir = session.profile_dir / ".config" + config_dir.mkdir(parents=True, exist_ok=True) + _remove_path_if_owned(config_dir / "xfce4" / "panel") + data_dir = session.profile_dir / ".local" / "share" + data_dir.mkdir(parents=True, exist_ok=True) + applications_dir = data_dir / "applications" + applications_dir.mkdir(parents=True, exist_ok=True) + cache_dir = session.profile_dir / ".cache" + cache_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "user-dirs.dirs").write_text( + "\n".join( + [ + 'XDG_DESKTOP_DIR="$HOME/Desktop"', + f'XDG_DOCUMENTS_DIR="{workdir_home}"', + f'XDG_DOWNLOAD_DIR="{downloads_home}"', + f'XDG_TEMPLATES_DIR="{workdir_home}"', + f'XDG_PUBLICSHARE_DIR="{workdir_home}"', + f'XDG_MUSIC_DIR="{workdir_home}"', + f'XDG_PICTURES_DIR="{downloads_home}"', + f'XDG_VIDEOS_DIR="{workdir_home}"', + "", + ], + ), + encoding="utf-8", + ) + xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml" + xfce_conf_dir.mkdir(parents=True, exist_ok=True) + (xfce_conf_dir / "xfce4-desktop.xml").write_text( + f""" + + + + + + + + + + + + + + + + + + + + +""", + encoding="utf-8", + ) + _write_thunar_defaults(xfce_conf_dir / "thunar.xml") + self._hide_xpra_desktop_entries(applications_dir) + self._hide_xfce_menu_entries(applications_dir) + self._prepare_desktop_url_bridge(session) + + base_args = ( + soffice, + "--norestore", + "--nofirststartwizard", + "--nolockcheck", + f"-env:UserInstallation=file://{session.profile_dir}", + ) + office_launchers = ( + ("LibreOffice Writer", "libreoffice-writer", "--writer", "Office;WordProcessor;"), + ("LibreOffice Calc", "libreoffice-calc", "--calc", "Office;Spreadsheet;"), + ("LibreOffice Impress", "libreoffice-impress", "--impress", "Office;Presentation;"), + ) + for name, icon, mode, categories in office_launchers: + _write_desktop_launcher( + desktop_dir / f"{name}.desktop", + name=name, + exec_line=_desktop_exec(*base_args, mode), + icon=icon, + categories=categories, + try_exec=soffice, + working_dir=workdir_home, + ) + + terminal = shutil.which("xfce4-terminal") or "xfce4-terminal" + settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager" + desktop_apps = ( + { + "filename": "Terminal.desktop", + "name": "Terminal", + "exec": _desktop_exec(terminal, f"--working-directory={workdir_home}"), + "try_exec": terminal, + "icon": _desktop_icon( + "/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png", + "/usr/share/icons/hicolor/scalable/apps/org.xfce.terminal.svg", + "org.xfce.terminal", + "utilities-terminal", + ), + "categories": "System;TerminalEmulator;", + }, + { + "filename": "Settings.desktop", + "name": "Settings", + "exec": _desktop_exec(settings), + "try_exec": settings, + "icon": _desktop_icon( + "/usr/share/icons/hicolor/128x128/apps/org.xfce.settings.manager.png", + "/usr/share/icons/hicolor/scalable/apps/org.xfce.settings.manager.svg", + "org.xfce.settings.manager", + "preferences-system", + ), + "categories": "Settings;DesktopSettings;", + }, + ) + for app in desktop_apps: + _write_desktop_launcher( + desktop_dir / str(app["filename"]), + name=str(app["name"]), + exec_line=str(app["exec"]), + icon=str(app["icon"]), + categories=str(app["categories"]), + try_exec=str(app["try_exec"]), + ) + _ensure_desktop_folder_link(desktop_dir, "Workdir", workdir_home) + for label, target_parts in DESKTOP_FOLDER_LINKS: + _ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts))) + + self._trust_desktop_launchers(session, desktop_dir) + self._prepare_xfce_panel_config(session) + self._prepare_xfce_profile_autostart(session) + + def _prepare_desktop_url_bridge(self, session: DesktopSession) -> None: + desktop_dir = session.profile_dir / "Desktop" + config_dir = session.profile_dir / ".config" + data_dir = session.profile_dir / ".local" / "share" + applications_dir = data_dir / "applications" + desktop_dir.mkdir(parents=True, exist_ok=True) + config_dir.mkdir(parents=True, exist_ok=True) + applications_dir.mkdir(parents=True, exist_ok=True) + + browser_bridge = _write_url_bridge_script(session) + editor_bridge = _write_editor_bridge_script(session) + shutdown_bridge = _write_shutdown_bridge_script(session) + helpers_rc = config_dir / "xfce4" / "helpers.rc" + helpers_rc.parent.mkdir(parents=True, exist_ok=True) + helpers_rc.write_text( + "\n".join( + [ + "TerminalEmulator=xfce4-terminal", + "FileManager=thunar", + "WebBrowser=agent-zero-browser", + "", + ], + ), + encoding="utf-8", + ) + _write_xfce_browser_helper( + config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop", + browser_bridge, + ) + _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID, EDITOR_HANDLER_DESKTOP_ID) + _write_mimeapps_defaults( + data_dir / "applications" / "mimeapps.list", + URL_HANDLER_DESKTOP_ID, + EDITOR_HANDLER_DESKTOP_ID, + ) + _write_desktop_launcher( + applications_dir / URL_HANDLER_DESKTOP_ID, + name="Agent Zero Browser", + exec_line=_desktop_exec(browser_bridge, "%U"), + icon="web-browser", + categories="Network;WebBrowser;", + try_exec=str(browser_bridge), + mime_types=_url_handler_mime_types(), + no_display=True, + ) + _write_desktop_launcher( + applications_dir / EDITOR_HANDLER_DESKTOP_ID, + name="Agent Zero Editor", + exec_line=_desktop_exec(editor_bridge, "%F"), + icon="accessories-text-editor", + categories="Utility;TextEditor;", + try_exec=str(editor_bridge), + mime_types=_editor_text_handler_mime_types(), + ) + _write_desktop_launcher( + applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID, + name="Shutdown Desktop", + exec_line=_desktop_exec(shutdown_bridge), + icon="system-shutdown", + categories="System;", + try_exec=str(shutdown_bridge), + no_display=True, + ) + _write_desktop_launcher( + config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID, + name="Shutdown Desktop", + exec_line=_desktop_exec(shutdown_bridge), + icon="system-shutdown", + categories="System;", + try_exec=str(shutdown_bridge), + ) + _write_desktop_launcher( + desktop_dir / "Browser.desktop", + name="Browser", + exec_line=_desktop_exec(browser_bridge), + icon="web-browser", + categories="Network;WebBrowser;", + try_exec=str(browser_bridge), + ) + _write_desktop_launcher( + desktop_dir / "Editor.desktop", + name="Editor", + exec_line=_desktop_exec(editor_bridge), + icon="accessories-text-editor", + categories="Utility;TextEditor;", + try_exec=str(editor_bridge), + ) + self._trust_desktop_launchers(session, desktop_dir) + + def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None: + for filename in HIDDEN_XPRA_DESKTOP_ENTRIES: + _write_hidden_application_entry(applications_dir / filename, "Xpra") + + def _hide_xfce_menu_entries(self, applications_dir: Path) -> None: + for filename, name in HIDDEN_XFCE_MENU_ENTRIES: + _write_hidden_application_entry(applications_dir / filename, name) + + def _prepare_xfce_panel_config(self, session: DesktopSession) -> None: + panel_xml = ( + session.profile_dir + / ".config" + / "xfce4" + / "xfconf" + / "xfce-perchannel-xml" + / "xfce4-panel.xml" + ) + panel_xml.parent.mkdir(parents=True, exist_ok=True) + + root = ET.Element("channel", {"name": "xfce4-panel", "version": "1.0"}) + ET.SubElement(root, "property", {"name": "configver", "type": "int", "value": "2"}) + + panels = ET.SubElement(root, "property", {"name": "panels", "type": "array"}) + ET.SubElement(panels, "value", {"type": "int", "value": "1"}) + panel = ET.SubElement(panels, "property", {"name": "panel-1", "type": "empty"}) + for name, prop_type, value in ( + ("position", "string", "p=6;x=0;y=0"), + ("length", "uint", "100"), + ("position-locked", "bool", "true"), + ("size", "uint", "24"), + ("mode", "uint", "0"), + ("autohide-behavior", "uint", "0"), + ("disable-struts", "bool", "false"), + ("nrows", "uint", "1"), + ): + ET.SubElement(panel, "property", {"name": name, "type": prop_type, "value": value}) + plugin_ids = ET.SubElement(panel, "property", {"name": "plugin-ids", "type": "array"}) + for plugin_id in ("1", "2", "3", "4", "5", "6", "7", "8", "9"): + ET.SubElement(plugin_ids, "value", {"type": "int", "value": plugin_id}) + + plugins = ET.SubElement(root, "property", {"name": "plugins", "type": "empty"}) + ET.SubElement(plugins, "property", {"name": "plugin-1", "type": "string", "value": "applicationsmenu"}) + ET.SubElement(plugins, "property", {"name": "plugin-2", "type": "string", "value": "tasklist"}) + tasklist = _xfce_property(plugins, "plugin-2", "string", "tasklist") + ET.SubElement(tasklist, "property", {"name": "flat-buttons", "type": "bool", "value": "true"}) + ET.SubElement(tasklist, "property", {"name": "show-handle", "type": "bool", "value": "false"}) + ET.SubElement(tasklist, "property", {"name": "show-labels", "type": "bool", "value": "true"}) + separator = ET.SubElement(plugins, "property", {"name": "plugin-3", "type": "string", "value": "separator"}) + ET.SubElement(separator, "property", {"name": "expand", "type": "bool", "value": "true"}) + ET.SubElement(separator, "property", {"name": "style", "type": "uint", "value": "0"}) + ET.SubElement(plugins, "property", {"name": "plugin-4", "type": "string", "value": "pager"}) + ET.SubElement(plugins, "property", {"name": "plugin-5", "type": "string", "value": "systray"}) + ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"}) + ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"}) + ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"}) + shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"}) + shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"}) + ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID}) + + tree = ET.ElementTree(root) + try: + ET.indent(tree, space=" ") + except AttributeError: + pass + tree.write(panel_xml, encoding="utf-8", xml_declaration=True) + + def _prepare_xfce_profile_autostart(self, session: DesktopSession) -> None: + script = session.profile_dir / "prepare-xfce-profile.sh" + script.write_text( + """#!/bin/sh +set -eu +export HOME="${HOME:-%s}" +export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}" +export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" +export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}" +mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" +if command -v xfconf-query >/dev/null 2>&1; then + xfconf-query -c thunar -p /last-show-hidden -n -t bool -s true >/dev/null 2>&1 || true + xfconf-query -c xfce4-desktop -p /desktop-icons/style -n -t int -s 2 >/dev/null 2>&1 || true + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-home -n -t bool -s false >/dev/null 2>&1 || true + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true +fi +for launcher in "$HOME"/Desktop/*.desktop; do + [ -f "$launcher" ] || continue + chmod +x "$launcher" 2>/dev/null || true + if command -v gio >/dev/null 2>&1; then + checksum="$(sha256sum "$launcher" 2>/dev/null | cut -d " " -f 1)" + gio set "$launcher" metadata::trusted true >/dev/null 2>&1 || true + if [ -n "$checksum" ]; then + gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" >/dev/null 2>&1 || true + fi + fi +done +if command -v xfdesktop >/dev/null 2>&1; then + timeout 4 xfdesktop --reload >/dev/null 2>&1 || true +fi +""" % str(session.profile_dir), + encoding="utf-8", + ) + try: + script.chmod(0o700) + except OSError: + pass + + autostart_dir = session.profile_dir / ".config" / "autostart" + autostart_dir.mkdir(parents=True, exist_ok=True) + autostart = autostart_dir / "agent-zero-desktop.desktop" + autostart.write_text( + "\n".join( + [ + "[Desktop Entry]", + "Type=Application", + "Name=Agent Zero desktop profile", + f"Exec={script}", + "Terminal=false", + "OnlyShowIn=XFCE;", + "X-GNOME-Autostart-enabled=true", + "", + ], + ), + encoding="utf-8", + ) + + def _prepare_xfce_launcher(self, session: DesktopSession) -> Path: + launcher = session.profile_dir / "start-xfce.sh" + launcher.write_text( + "\n".join( + [ + "#!/bin/sh", + 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"', + 'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"', + 'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"', + 'export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"', + 'export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"', + 'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"', + ( + "exec dbus-launch --exit-with-session sh -c " + f"'\"{session.profile_dir / 'prepare-xfce-profile.sh'}\" >/dev/null 2>&1 || true; exec xfce4-session'" + ), + "", + ], + ), + encoding="utf-8", + ) + try: + launcher.chmod(0o700) + except OSError: + pass + return launcher + + def _prepare_root_window(self, session: DesktopSession) -> None: + xsetroot = shutil.which("xsetroot") + if not xsetroot: + return + subprocess.run( + [xsetroot, "-solid", "#20242a"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + env=self._display_env(session), + ) + + def _fit_office_window( + self, + session: DesktopSession, + *, + process: subprocess.Popen[Any] | None = None, + ) -> None: + virtual_desktop.fit_window_until( + display=session.display, + width=session.width, + height=session.height, + window_class="libreoffice", + keys=("Escape",), + settle_seconds=4, + timeout_seconds=10, + process=process, + xauthority=self._xauthority(session), + home=str(session.profile_dir), + ) + self._dismiss_blocking_dialogs(session) + + def _set_display_size(self, session: DesktopSession, width: int, height: int) -> dict[str, Any]: + result = virtual_desktop.resize_display( + display=session.display, + width=width, + height=height, + max_width=MAX_SCREEN_WIDTH, + max_height=MAX_SCREEN_HEIGHT, + window_class="", + keys=(), + xauthority=self._xauthority(session), + home=str(session.profile_dir), + ) + if result.get("ok"): + session.width = int(result["width"]) + session.height = int(result["height"]) + return result + + def _dismiss_blocking_dialogs(self, session: DesktopSession) -> None: + virtual_desktop.close_windows( + display=session.display, + names=BLOCKING_DIALOG_TITLES, + xauthority=self._xauthority(session), + home=str(session.profile_dir), + ) + + def _refresh_xfce_desktop(self, session: DesktopSession) -> None: + xfdesktop = shutil.which("xfdesktop") + if not xfdesktop: + return + env = self._xfce_process_env(session, "xfdesktop") + try: + subprocess.run( + [xfdesktop, "--reload"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=4, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + return + + def _trust_desktop_launchers(self, session: DesktopSession, desktop_dir: Path) -> None: + gio = shutil.which("gio") + if not gio: + return + env = self._xfce_process_env(session, "xfdesktop") + for launcher in desktop_dir.glob("*.desktop"): + try: + launcher.chmod(0o755) + checksum = hashlib.sha256(launcher.read_bytes()).hexdigest() + except OSError: + continue + for command in ( + [gio, "set", str(launcher), "metadata::trusted", "true"], + [gio, "set", "-t", "string", str(launcher), "metadata::xfce-exe-checksum", checksum], + ): + try: + subprocess.run( + command, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=4, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + continue + + def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]: + env = self._display_env(session) + proc = Path("/proc") + for candidate in proc.iterdir(): + if not candidate.name.isdigit(): + continue + try: + if (candidate / "comm").read_text(encoding="utf-8").strip() != command_name: + continue + process_env = self._read_process_env(candidate) + except OSError: + continue + if process_env.get("HOME") != str(session.profile_dir): + continue + if process_env.get("DISPLAY") != f":{session.display}": + continue + for key, value in process_env.items(): + if ( + key in {"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "HOME", "XAUTHORITY"} + or key.startswith("XDG_") + ): + env[key] = value + break + return env + + def _read_process_env(self, proc_dir: Path) -> dict[str, str]: + raw = (proc_dir / "environ").read_bytes() + env: dict[str, str] = {} + for item in raw.split(b"\0"): + if not item or b"=" not in item: + continue + key, value = item.split(b"=", 1) + env[key.decode("utf-8", errors="ignore")] = value.decode("utf-8", errors="ignore") + return env + + def _session_env(self, session: DesktopSession) -> dict[str, str]: + env = { + **os.environ, + "HOME": str(session.profile_dir), + "LANG": os.environ.get("LANG") or "C.UTF-8", + "TZ": session.timezone or Localization.get().get_timezone(), + } + browser_bridge = _url_bridge_script_path(session) + if browser_bridge.exists(): + env["BROWSER"] = str(browser_bridge) + env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime")) + runtime_dir = Path(env["XDG_RUNTIME_DIR"]) + runtime_dir.mkdir(parents=True, exist_ok=True) + try: + runtime_dir.chmod(0o700) + except OSError: + pass + return env + + def _display_env(self, session: DesktopSession) -> dict[str, str]: + env = { + **self._session_env(session), + "DISPLAY": f":{session.display}", + "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gtk3", + } + xauthority = self._xauthority(session) + if xauthority: + env["XAUTHORITY"] = xauthority + return env + + def _xauthority(self, session: DesktopSession) -> str: + path = session.profile_dir / ".Xauthority" + return str(path) if path.exists() else "" + + def _allocate_endpoint_locked(self) -> tuple[int, int]: + used_displays = {session.display for session in self._sessions.values()} + used_ports = {session.xpra_port for session in self._sessions.values()} + for offset in range(MAX_SESSIONS): + display = DISPLAY_BASE + offset + port = XPRA_PORT_BASE + offset + if display in used_displays or port in used_ports: + continue + if _port_is_free(port): + return display, port + raise RuntimeError("No LibreOffice desktop slots are available.") + + def _find_by_file_id_locked(self, file_id: str) -> DesktopSession | None: + for session in self._sessions.values(): + if session.file_id == file_id and session.alive(): + return session + return None + + def _find_by_file_id(self, file_id: str) -> DesktopSession | None: + with self._lock: + return self._find_by_file_id_locked(str(file_id or "").strip()) + + def _reap_dead_locked(self) -> None: + for session_id, session in list(self._sessions.items()): + if not session.alive(): + self._terminate_session(session) + virtual_desktop.unregister_session(session.token) + self._sessions.pop(session_id, None) + self._remove_manifest(session_id) + + def _wait_for_display(self, session: DesktopSession) -> None: + deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS + while time.time() < deadline: + process = session.processes.get("xvfb") or session.processes.get("xpra") + if process and process.poll() is not None: + raise RuntimeError("The LibreOffice X display exited before it was ready.") + try: + if virtual_desktop.current_display_size( + session.display, + xauthority=self._xauthority(session), + home=str(session.profile_dir), + ): + return + except (OSError, subprocess.TimeoutExpired): + pass + time.sleep(0.1) + raise TimeoutError("Timed out waiting for the LibreOffice X display.") + + def _wait_for_xfce(self, session: DesktopSession) -> None: + deadline = time.time() + STARTUP_GRACE_SECONDS + while time.time() < deadline: + process = session.processes.get("xfce") + if process and process.poll() is not None: + raise RuntimeError("The XFCE desktop session exited before it was ready.") + if virtual_desktop.has_window( + display=session.display, + name="xfce4-panel", + xauthority=self._xauthority(session), + home=str(session.profile_dir), + ): + return + time.sleep(0.25) + + def _write_manifest(self, session: DesktopSession) -> None: + SESSION_DIR.mkdir(parents=True, exist_ok=True) + pids = dict(session.process_ids) + pids.update({name: process.pid for name, process in session.processes.items()}) + payload = { + "session_id": session.session_id, + "file_id": session.file_id, + "extension": session.extension, + "path": session.path, + "title": session.title, + "display": session.display, + "xpra_port": session.xpra_port, + "profile_dir": str(session.profile_dir), + "width": session.width, + "height": session.height, + "started_at": session.started_at, + "timezone": session.timezone, + "owner_pid": os.getpid(), + "pids": pids, + } + manifest = SESSION_DIR / f"{session.session_id}.json" + tmp = manifest.with_name(f".{manifest.name}.{uuid.uuid4().hex}.tmp") + try: + tmp.write_text(json.dumps(payload), encoding="utf-8") + os.replace(tmp, manifest) + finally: + tmp.unlink(missing_ok=True) + + def _remove_manifest(self, session_id: str) -> None: + (SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True) + + def _terminate_session(self, session: DesktopSession, *, include_rehydrated: bool = False) -> None: + process_names = [name for name in session.processes if name.startswith("soffice")] + process_names.extend(["xfce", "xpra", "xvfb"]) + terminated_pids: set[int] = set() + for name in process_names: + process = session.processes.get(name) + if not process: + continue + if process.pid: + terminated_pids.add(process.pid) + _terminate_process(process) + if session.owns_processes or include_rehydrated: + for name, pid in session.process_ids.items(): + if pid in terminated_pids: + continue + if name.startswith("soffice") or name in {"xfce", "xpra", "xvfb"}: + _kill_pid(pid) + self._remove_stale_lock_file(session) + + def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None: + path = Path(path or session.path) + if not path.name: + return + lock_file = path.with_name(f".~lock.{path.name}#") + try: + lock_file.unlink(missing_ok=True) + except OSError: + pass + + +def collect_desktop_status() -> dict[str, Any]: + desktop = virtual_desktop.collect_status() + binaries = { + **desktop["binaries"], + "soffice": libreoffice.find_soffice(), + "thunar": shutil.which("thunar") or "", + "xfce4-terminal": shutil.which("xfce4-terminal") or "", + "xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "", + "gio": shutil.which("gio") or "", + } + missing = [ + name + for name in ( + "soffice", + "thunar", + "xfce4-terminal", + "xfce4-settings-manager", + "gio", + ) + if not binaries[name] + ] + missing.extend( + name + for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool") + if not binaries.get(name) + ) + if not desktop.get("xpra_html_root"): + missing.append("xpra-html5") + if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False: + missing.append("xpra-x11") + healthy = not missing + preparation = _runtime_preparation_status() + installing = bool(preparation.get("preparing")) and not healthy + return { + "ok": True, + "healthy": healthy, + "state": "healthy" if healthy else "installing" if installing else "missing", + "installing": installing, + "missing": missing, + "preparation": preparation, + "binaries": binaries, + "xpra_html_root": str(desktop.get("xpra_html_root") or ""), + "message": ( + "Agent Zero Desktop sessions are available." + if healthy + else RUNTIME_INSTALL_MESSAGE + if installing + else f"Agent Zero Desktop sessions need: {', '.join(missing)}." + ), + } + + +def _state_path_from_retired_root(path: Path) -> Path: + try: + relative = path.resolve(strict=False).relative_to( + RETIRED_STATE_DIR.resolve(strict=False) + ) + except ValueError: + return path + return STATE_DIR / relative + + +def _runtime_preparation_status() -> dict[str, Any]: + try: + from plugins._desktop import hooks + + return hooks.runtime_preparation_status() + except Exception: + return {"preparing": False, "active_count": 0, "started_at": 0.0, "completed_at": 0.0} + + +def cleanup_stale_runtime_state() -> dict[str, Any]: + killed: list[int] = [] + errors: list[str] = [] + seen_dirs: set[Path] = set() + for session_dir in (SESSION_DIR, *LEGACY_SESSION_DIRS): + if session_dir in seen_dirs: + continue + seen_dirs.add(session_dir) + if session_dir.exists(): + for manifest in session_dir.glob("*.json"): + try: + payload = json.loads(manifest.read_text(encoding="utf-8")) + owner_pid = _coerce_pid(payload.get("owner_pid")) + if owner_pid and _pid_is_running(owner_pid): + continue + for pid in dict(payload.get("pids") or {}).values(): + pid_int = _coerce_pid(pid) + if not pid_int: + continue + if _kill_pid(pid_int): + killed.append(pid_int) + manifest.unlink(missing_ok=True) + except Exception as exc: + errors.append(str(exc)) + return {"ok": not errors, "killed": killed, "errors": errors} + + +def get_manager() -> DesktopSessionManager: + global _manager + try: + return _manager + except NameError: + _manager = DesktopSessionManager() + atexit.register(_manager.shutdown) + return _manager + + +def _xpra_url(token: str) -> str: + return virtual_desktop.session_url(token, title="Desktop") + + +def _xvfb_command(xvfb: str, session: DesktopSession) -> list[str]: + return [ + xvfb, + f":{session.display}", + "-screen", + "0", + f"{MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24", + "+extension", + "GLX", + "+extension", + "RANDR", + "+extension", + "RENDER", + "+extension", + "Composite", + "-extension", + "DOUBLE-BUFFER", + "-nolisten", + "tcp", + "-noreset", + "-ac", + ] + + +def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]: + return [ + xpra, + "shadow", + f":{session.display}", + "--daemon=no", + "--mdns=no", + "--html=on", + "--tray=no", + "--system-tray=no", + "--notifications=no", + "--clipboard=yes", + "--clipboard-direction=both", + "--file-transfer=yes", + "--open-files=no", + "--open-url=no", + "--printing=yes", + "--audio=no", + "--speaker=off", + "--microphone=off", + "--encoding=jpeg", + "--quality=85", + "--speed=80", + f"--bind-tcp=127.0.0.1:{session.xpra_port}", + "--resize-display=yes", + f"--log-dir={session.profile_dir}", + "--log-file=xpra.log", + ] + + +def _desktop_exec(*args: str | Path) -> str: + return " ".join(_desktop_exec_arg(str(arg)) for arg in args if str(arg)) + + +def _desktop_icon(*candidates: str) -> str: + for candidate in candidates: + if candidate.startswith("/") and Path(candidate).exists(): + return candidate + return next( + (candidate for candidate in candidates if not candidate.startswith("/")), + candidates[-1], + ) + + +def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) -> None: + target.mkdir(parents=True, exist_ok=True) + link = desktop_dir / label + try: + if link.is_symlink() or link.is_file(): + link.unlink() + if not link.exists(): + link.symlink_to(target, target_is_directory=True) + except OSError: + return + + +def _url_bridge_dir(session: DesktopSession) -> Path: + return session.profile_dir / ".agent-zero" + + +def _url_bridge_script_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "open-url" + + +def _editor_bridge_script_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "open-editor" + + +def _url_bridge_queue_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "browser-url-intents.jsonl" + + +def _url_bridge_lock_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "browser-url-intents.lock" + + +def _shutdown_request_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "shutdown-request.json" + + +def _shutdown_arm_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "shutdown-request.arm.json" + + +def _shutdown_lock_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "shutdown-request.lock" + + +def _write_url_bridge_script(session: DesktopSession) -> Path: + bridge_dir = _url_bridge_dir(session) + bridge_dir.mkdir(parents=True, exist_ok=True) + script = _url_bridge_script_path(session) + queue = _url_bridge_queue_path(session) + lock = _url_bridge_lock_path(session) + script.write_text( + f"""#!/usr/bin/env python3 +import fcntl +import json +import os +import sys +import time + +QUEUE_PATH = {str(queue)!r} +LOCK_PATH = {str(lock)!r} +MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH} + + +def main(): + urls = [str(arg or "").strip()[:MAX_URL_LENGTH] for arg in sys.argv[1:] if str(arg or "").strip()] + if not urls: + urls = [""] + os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True) + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file: + for url in urls: + queue_file.write(json.dumps({{ + "url": url, + "created_at": time.time(), + "source": "desktop", + }}, ensure_ascii=True) + "\\n") + queue_file.flush() + os.fsync(queue_file.fileno()) + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +if __name__ == "__main__": + main() +""", + encoding="utf-8", + ) + try: + script.chmod(0o755) + except OSError: + pass + return script + + +def _write_editor_bridge_script(session: DesktopSession) -> Path: + bridge_dir = _url_bridge_dir(session) + bridge_dir.mkdir(parents=True, exist_ok=True) + script = _editor_bridge_script_path(session) + queue = _url_bridge_queue_path(session) + lock = _url_bridge_lock_path(session) + script.write_text( + f"""#!/usr/bin/env python3 +import fcntl +import json +import os +import sys +import time +from urllib.parse import quote + +QUEUE_PATH = {str(queue)!r} +LOCK_PATH = {str(lock)!r} +MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH} + + +def editor_url(path): + path = str(path or "").strip() + if not path: + return "a0-editor://open" + return "a0-editor://open?path=" + quote(path[:MAX_URL_LENGTH], safe="/:") + + +def main(): + urls = [editor_url(arg) for arg in sys.argv[1:] if str(arg or "").strip()] + if not urls: + urls = [editor_url("")] + os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True) + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file: + for url in urls: + queue_file.write(json.dumps({{ + "url": url, + "created_at": time.time(), + "source": "desktop-editor", + }}, ensure_ascii=True) + "\\n") + queue_file.flush() + os.fsync(queue_file.fileno()) + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +if __name__ == "__main__": + main() +""", + encoding="utf-8", + ) + try: + script.chmod(0o755) + except OSError: + pass + return script + + +def _write_shutdown_bridge_script(session: DesktopSession) -> Path: + bridge_dir = _url_bridge_dir(session) + bridge_dir.mkdir(parents=True, exist_ok=True) + script = bridge_dir / "shutdown-desktop" + request = _shutdown_request_path(session) + arm = _shutdown_arm_path(session) + lock = _shutdown_lock_path(session) + script.write_text( + f"""#!/usr/bin/env python3 +import fcntl +import json +import os +import shutil +import subprocess +import time + +REQUEST_PATH = {str(request)!r} +ARM_PATH = {str(arm)!r} +LOCK_PATH = {str(lock)!r} +CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS} + + +def notify(message, timeout=None): + if not os.environ.get("DISPLAY"): + return + xmessage = shutil.which("xmessage") + if not xmessage: + return + try: + subprocess.Popen( + [ + xmessage, + "-buttons", + "", + "-timeout", + str(timeout or CONFIRM_SECONDS), + "-center", + message, + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError: + pass + + +def read_arm(now): + try: + with open(ARM_PATH, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError): + return None + try: + created_at = float(payload.get("created_at")) + except (TypeError, ValueError): + return None + if now - created_at > CONFIRM_SECONDS: + return None + return created_at + + +def write_json_atomic(path, payload): + tmp_path = path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=True) + handle.write("\\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, path) + + +def main(): + os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True) + now = time.time() + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + armed_at = read_arm(now) + if armed_at is None: + write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}}) + notify( + f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.", + CONFIRM_SECONDS, + ) + return + try: + os.unlink(ARM_PATH) + except OSError: + pass + payload = {{ + "created_at": now, + "armed_at": armed_at, + "source": "tray", + }} + write_json_atomic(REQUEST_PATH, payload) + notify("Shutting down Agent Zero Desktop.", 2) + + +if __name__ == "__main__": + main() +""", + encoding="utf-8", + ) + try: + script.chmod(0o755) + except OSError: + pass + return script + + +def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]: + queue = _url_bridge_queue_path(session) + lock = _url_bridge_lock_path(session) + if not queue.exists(): + return [] + lock.parent.mkdir(parents=True, exist_ok=True) + try: + with open(lock, "a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + raw = queue.read_text(encoding="utf-8") + queue.write_text("", encoding="utf-8") + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + except OSError: + return [] + + intents: list[dict[str, Any]] = [] + for line in raw.splitlines(): + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + url = str(payload.get("url") or "").strip() + if len(url) > URL_INTENT_MAX_LENGTH: + url = url[:URL_INTENT_MAX_LENGTH] + created_at = payload.get("created_at") + try: + created_at = float(created_at) + except (TypeError, ValueError): + created_at = time.time() + intents.append( + { + "url": url, + "created_at": created_at, + "source": str(payload.get("source") or "desktop"), + }, + ) + if len(intents) >= URL_INTENT_MAX_ITEMS: + break + return intents + + +def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None: + request = _shutdown_request_path(session) + if not request.exists(): + return None + try: + raw = request.read_text(encoding="utf-8") + request.unlink(missing_ok=True) + except OSError: + return None + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + created_at = payload.get("created_at") + try: + created_at = float(created_at) + except (TypeError, ValueError): + created_at = time.time() + return { + "created_at": created_at, + "source": str(payload.get("source") or "tray"), + } + + +def _clear_shutdown_request(session: DesktopSession) -> None: + request = _shutdown_request_path(session) + arm = _shutdown_arm_path(session) + lock = _shutdown_lock_path(session) + request.unlink(missing_ok=True) + request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True) + arm.unlink(missing_ok=True) + arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True) + lock.unlink(missing_ok=True) + + +def _remove_system_manifest() -> None: + (SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True) + + +def _url_handler_mime_types() -> tuple[str, ...]: + return ( + "x-scheme-handler/http", + "x-scheme-handler/https", + "text/html", + "application/xhtml+xml", + ) + + +def _editor_text_handler_mime_types() -> tuple[str, ...]: + return ( + "text/markdown", + "text/x-markdown", + "text/plain", + ) + + +def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None: + url_associations = ";".join([url_desktop_id, ""]) + # LibreOffice Writer stays the default so double-clicks keep the user (and + # agents) inside the Desktop; the Agent Zero Editor remains an "Open With" + # option. When Writer's desktop entry is missing, xdg falls back through + # the added associations to the editor. + text_associations = ";".join([WRITER_HANDLER_DESKTOP_ID, editor_desktop_id, ""]) + lines = [ + "[Default Applications]", + *(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()), + *(f"{mime_type}={WRITER_HANDLER_DESKTOP_ID}" for mime_type in _editor_text_handler_mime_types()), + "", + "[Added Associations]", + *(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()), + *(f"{mime_type}={text_associations}" for mime_type in _editor_text_handler_mime_types()), + "", + ] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def _write_xfce_browser_helper(path: Path, bridge_script: Path) -> None: + command = _desktop_exec(bridge_script) + command_with_parameter = _desktop_exec(bridge_script, "%s") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join( + [ + "[Desktop Entry]", + "NoDisplay=true", + "Version=1.0", + "Type=X-XFCE-Helper", + "X-XFCE-Category=WebBrowser", + f"X-XFCE-Commands={command}", + f"X-XFCE-CommandsWithParameter={command_with_parameter}", + "Icon=web-browser", + "Name=Agent Zero Browser", + "", + ], + ), + encoding="utf-8", + ) + + +def _remove_path_if_owned(path: Path) -> None: + try: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + except OSError: + return + + +def _desktop_exec_arg(value: str) -> str: + if not any(char.isspace() or char in '"\\' for char in value): + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _xml_attr(value: str) -> str: + return ( + str(value) + .replace("&", "&") + .replace('"', """) + .replace("<", "<") + .replace(">", ">") + ) + + +def _oor(name: str) -> str: + return f"{{{OOR_NS}}}{name}" + + +def _file_uri(path: str | Path) -> str: + return Path(path).resolve(strict=False).as_uri() + + +def _write_libreoffice_registry_defaults(registry: Path, workdir: str | Path) -> None: + Path(workdir).mkdir(parents=True, exist_ok=True) + ET.register_namespace("oor", OOR_NS) + ET.register_namespace("xs", XS_NS) + ET.register_namespace("xsi", XSI_NS) + root = _read_libreoffice_registry(registry) + workdir_uri = _file_uri(workdir) + for path, prop, value in ( + ("/org.openoffice.Office.Common/Misc", "FirstRun", "false"), + ("/org.openoffice.Setup/Office", "ooSetupInstCompleted", "true"), + ("/org.openoffice.Setup/Office", "MigrationCompleted", "true"), + ("/org.openoffice.Setup/Office", "OfficeRestartInProgress", "false"), + ("/org.openoffice.Setup/L10N", "ooLocale", "en-US"), + ("/org.openoffice.Office.Paths/Variables", "Work", workdir_uri), + ( + "/org.openoffice.Office.Paths/Paths/org.openoffice.Office.Paths:NamedPath['Work']", + "WritePath", + workdir_uri, + ), + ): + _set_registry_prop(root, path, prop, value) + registry.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(registry, encoding="utf-8", xml_declaration=True) + + +def _read_libreoffice_registry(registry: Path) -> ET.Element: + if registry.exists(): + try: + return ET.parse(registry).getroot() + except ET.ParseError: + pass + return ET.Element( + _oor("items"), + { + "xmlns:xs": XS_NS, + "xmlns:xsi": XSI_NS, + }, + ) + + +def _set_registry_prop(root: ET.Element, item_path: str, prop_name: str, value: str) -> None: + item = _find_registry_item(root, item_path) + if item is None: + item = ET.SubElement(root, "item", {_oor("path"): item_path}) + prop = next((child for child in item.findall("prop") if child.get(_oor("name")) == prop_name), None) + if prop is None: + prop = ET.SubElement(item, "prop", {_oor("name"): prop_name, _oor("op"): "fuse"}) + else: + prop.set(_oor("op"), "fuse") + value_node = prop.find("value") + if value_node is None: + value_node = ET.SubElement(prop, "value") + value_node.text = str(value) + + +def _find_registry_item(root: ET.Element, item_path: str) -> ET.Element | None: + for item in root.findall("item"): + if item.get(_oor("path")) == item_path: + return item + return None + + +def _write_desktop_launcher( + path: Path, + *, + name: str, + exec_line: str, + icon: str, + categories: str, + try_exec: str = "", + working_dir: str | Path | None = None, + mime_types: tuple[str, ...] = (), + no_display: bool = False, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "[Desktop Entry]", + "Version=1.0", + "Type=Application", + f"Name={name}", + f"Exec={exec_line}", + ] + if try_exec: + lines.append(f"TryExec={try_exec}") + if working_dir: + lines.append(f"Path={working_dir}") + if mime_types: + lines.append(f"MimeType={';'.join(mime_types)};") + if no_display: + lines.append("NoDisplay=true") + lines.extend( + [ + f"Icon={icon}", + "Terminal=false", + f"Categories={categories}", + "StartupNotify=true", + "X-XFCE-Trusted=true", + "", + ], + ) + path.write_text("\n".join(lines), encoding="utf-8") + try: + path.chmod(0o755) + except OSError: + pass + + +def _write_hidden_application_entry(path: Path, name: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join( + [ + "[Desktop Entry]", + "Type=Application", + f"Name={name}", + "NoDisplay=true", + "Hidden=true", + "", + ], + ), + encoding="utf-8", + ) + + +def _write_thunar_defaults(path: Path) -> None: + root = _read_xfce_channel(path, "thunar") + if _find_xfce_property(root, "last-view") is None: + _xfce_property(root, "last-view", "string", "ThunarIconView") + _xfce_property(root, "last-show-hidden", "bool", "true") + _write_xfce_channel(path, root) + + +def _read_xfce_channel(path: Path, channel_name: str) -> ET.Element: + if path.exists(): + try: + root = ET.parse(path).getroot() + if root.tag == "channel" and root.get("name") == channel_name: + root.set("version", root.get("version") or "1.0") + return root + except (ET.ParseError, OSError): + pass + return ET.Element("channel", {"name": channel_name, "version": "1.0"}) + + +def _write_xfce_channel(path: Path, root: ET.Element) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tree = ET.ElementTree(root) + try: + ET.indent(tree, space=" ") + except AttributeError: + pass + tree.write(path, encoding="utf-8", xml_declaration=True) + + +def _find_xfce_property(parent: ET.Element, name: str) -> ET.Element | None: + return next((child for child in parent.findall("property") if child.get("name") == name), None) + + +def _install_desktop_readme(desktop_dir: Path) -> None: + if not DESKTOP_README_SOURCE.exists(): + return + target = desktop_dir / "README.md" + try: + content = DESKTOP_README_SOURCE.read_text(encoding="utf-8") + if target.exists() and target.read_text(encoding="utf-8") == content: + return + target.write_text(content, encoding="utf-8") + target.chmod(0o644) + except OSError: + return + + +def _xfce_property(parent: ET.Element, name: str, property_type: str, value: str | None = None) -> ET.Element: + for child in parent.findall("property"): + if child.get("name") == name: + child.set("type", property_type) + if value is None: + child.attrib.pop("value", None) + else: + child.set("value", value) + return child + attributes = {"name": name, "type": property_type} + if value is not None: + attributes["value"] = value + return ET.SubElement(parent, "property", attributes) + + +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]: + return { + "file_id": doc["file_id"], + "path": document_store.display_path(doc["path"]), + "basename": doc["basename"], + "extension": doc["extension"], + "size": doc["size"], + "version": document_store.item_version(doc), + "last_modified": doc["last_modified"], + } + + +def _require_binary(name: str) -> str: + found = shutil.which(name) + if not found: + raise RuntimeError(f"{name} is required for official LibreOffice desktop sessions.") + return found + + +def _running(process: subprocess.Popen[Any] | None) -> bool: + return bool(process and process.poll() is None) + + +def _wait_for_port( + host: str, + port: int, + timeout: float = 15.0, + process: subprocess.Popen[Any] | None = None, +) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if process and process.poll() is not None: + raise RuntimeError(f"Xpra exited before port {port} was ready.") + try: + with socket.create_connection((host, port), timeout=0.2): + return + except OSError: + time.sleep(0.1) + raise TimeoutError(f"Timed out waiting for Xpra port {port}.") + + +def _port_is_free(port: int) -> bool: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def _port_is_accepting(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=0.2): + return True + except OSError: + return False + + +def _terminate_process(process: subprocess.Popen[Any]) -> None: + if process.poll() is not None: + return + try: + process.terminate() + process.wait(timeout=2) + return + except Exception: + pass + try: + process.kill() + process.wait(timeout=2) + except Exception: + pass + + +def _kill_pid(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 15) + return True + except ProcessLookupError: + return False + except PermissionError: + return False + + +def _coerce_pid(value: Any) -> int: + try: + pid = int(value) + except (TypeError, ValueError): + return 0 + return pid if pid > 0 else 0 + + +def _pid_is_running(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True diff --git a/plugins/_desktop/helpers/desktop_state.py b/plugins/_desktop/helpers/desktop_state.py new file mode 100644 index 0000000000000000000000000000000000000000..50bba6538801fe98a94c2eb79ce8329af2710884 --- /dev/null +++ b/plugins/_desktop/helpers/desktop_state.py @@ -0,0 +1,893 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +SESSION_ID = "agent-zero-desktop" +PLUGIN_NAME = "_desktop" +BASE_DIR = Path(os.environ.get("A0_BASE_DIR") or ("/a0" if Path("/a0").exists() else PROJECT_ROOT)) +STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME +RETIRED_STATE_DIR = BASE_DIR / "usr" / PLUGIN_NAME +SESSION_DIR = STATE_DIR / "sessions" +PROFILE_DIR = STATE_DIR / "profiles" +SCREENSHOT_DIR = Path(os.environ.get("A0_DESKTOP_SCREENSHOT_DIR") or BASE_DIR / "tmp" / "desktop" / "screenshots") +RECENT_SCREENSHOT_SECONDS = 600 +_SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+") +_SCREENSHOT_SUFFIXES = {".png", ".jpg", ".jpeg", ".xwd"} + + +def session_manifest_path(session_id: str = SESSION_ID) -> Path: + return Path(os.environ.get("A0_DESKTOP_MANIFEST") or SESSION_DIR / f"{session_id}.json") + + +def context_screenshot_dir(context_id: str = "") -> Path: + return SCREENSHOT_DIR / _safe_context_id(context_id) + + +def chat_screenshot_dir(context_id: str = "") -> Path: + return BASE_DIR / "usr" / "chats" / _safe_context_id(context_id) / "screenshots" / "desktop" + + +def normalize_a0_path(path: str | Path) -> str: + candidate = Path(path) + try: + relative = candidate.resolve(strict=False).relative_to(BASE_DIR.resolve(strict=False)) + except ValueError: + return str(candidate) + return "/a0/" + str(relative).replace(os.sep, "/") + + +def _safe_context_id(context_id: str = "") -> str: + raw = str(context_id or os.environ.get("A0_DESKTOP_CONTEXT_ID") or "default") + return _SAFE_CONTEXT_RE.sub("_", raw).strip("._") or "default" + + +def session_manifest_exists(session_id: str = SESSION_ID) -> bool: + return session_manifest_path(session_id).exists() + + +def collect_state( + *, + include_screenshot: bool = False, + screenshot_path: str | Path | None = None, + context_id: str = "", + screenshot_transport: str = "ephemeral", +) -> dict[str, Any]: + errors: list[str] = [] + env_info = resolve_environment(errors=errors) + display = env_info["display"] + profile_dir = env_info["profile_dir"] + env = display_env(display=display, profile_dir=profile_dir) + + capabilities = collect_capabilities() + for name in ("xdotool", "xrandr", "xwininfo", "xprop"): + if not capabilities.get(name): + errors.append(f"{name} is not installed; install Desktop runtime dependencies through the _desktop plugin hook.") + + size = collect_display_size(env, capabilities, errors) + pointer = collect_pointer(env, capabilities, errors) + active_window = collect_active_window(env, capabilities, errors) + windows = collect_windows(env, capabilities, errors) + screenshot = latest_screenshot(context_id=context_id) + + if include_screenshot: + screenshot = capture_screenshot( + env, + capabilities, + path=screenshot_path, + errors=errors, + context_id=context_id, + transport=screenshot_transport, + ) + + return stable_state( + context_id=context_id, + display=display, + profile_dir=profile_dir, + size=size, + pointer=pointer, + active_window=active_window, + windows=windows, + screenshot=screenshot, + capabilities=capabilities, + errors=errors, + ) + + +def capture_screenshot( + env: dict[str, str] | None = None, + capabilities: dict[str, str] | None = None, + *, + path: str | Path | None = None, + errors: list[str] | None = None, + context_id: str = "", + transport: str = "ephemeral", +) -> dict[str, Any]: + local_errors = errors if errors is not None else [] + capabilities = capabilities or collect_capabilities() + if not env: + env_errors: list[str] = [] + env_info = resolve_environment(errors=env_errors) + local_errors.extend(env_errors) + env = display_env(display=env_info["display"], profile_dir=env_info["profile_dir"]) + + xwd = capabilities.get("xwd") or shutil.which("xwd") or "" + if not xwd: + message = "xwd is not installed; install x11-apps through the _desktop plugin hook." + local_errors.append(message) + return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message} + + explicit_path = path is not None and str(path).strip() != "" + transport_mode = str(transport or "").strip().lower() + chat_scoped = bool(not explicit_path and transport_mode == "path" and str(context_id or "").strip()) + ephemeral_ref = not explicit_path and transport_mode != "path" + screenshot_dir = chat_screenshot_dir(context_id) if chat_scoped else context_screenshot_dir(context_id) + if not explicit_path and not chat_scoped: + prune_context_screenshots(context_id=context_id) + screenshot_dir.mkdir(parents=True, exist_ok=True) + timestamp = time.strftime("%Y%m%d-%H%M%S") + millis = int((time.time() % 1) * 1000) + target = Path(path) if explicit_path else screenshot_dir / f"desktop-{timestamp}-{millis:03d}.png" + target.parent.mkdir(parents=True, exist_ok=True) + raw_path = target.with_suffix(".xwd") + safe_context = _safe_context_id(context_id) + + result = run([xwd, "-root", "-silent", "-out", str(raw_path)], env=env, timeout=8) + if result.returncode != 0: + detail = command_output(result) or "xwd screenshot capture failed." + local_errors.append(detail) + raw_path.unlink(missing_ok=True) + return {"ok": False, "path": "", "format": "", "captured_at": "", "error": detail} + + if target.suffix.lower() == ".xwd": + if not explicit_path and not chat_scoped: + prune_context_screenshots(context_id=context_id, keep_path=raw_path) + return { + "ok": True, + "path": str(raw_path), + "a0_path": normalize_a0_path(raw_path), + "format": "xwd", + "captured_at": iso_now(), + "recent": True, + "ephemeral": not explicit_path and not chat_scoped, + "chat_scoped": chat_scoped, + "context_id": safe_context, + "error": "", + } + + try: + from PIL import Image + + with Image.open(raw_path) as image: + image.save(target) + width = int(image.width) + height = int(image.height) + raw_path.unlink(missing_ok=True) + if ephemeral_ref: + return ephemeral_screenshot_result( + target, + context_id=context_id, + image_format=target.suffix.lower().lstrip(".") or "png", + width=width, + height=height, + ) + if not explicit_path and not chat_scoped: + prune_context_screenshots(context_id=context_id, keep_path=target) + return { + "ok": True, + "path": str(target), + "a0_path": normalize_a0_path(target), + "format": target.suffix.lower().lstrip(".") or "png", + "width": width, + "height": height, + "captured_at": iso_now(), + "recent": True, + "ephemeral": not explicit_path and not chat_scoped, + "chat_scoped": chat_scoped, + "context_id": safe_context, + "error": "", + } + except Exception as exc: + try: + converted = convert_xwd_to_image(raw_path, target) + raw_path.unlink(missing_ok=True) + if ephemeral_ref: + return ephemeral_screenshot_result( + target, + context_id=context_id, + image_format=target.suffix.lower().lstrip(".") or "png", + width=converted["width"], + height=converted["height"], + ) + if not explicit_path and not chat_scoped: + prune_context_screenshots(context_id=context_id, keep_path=target) + return { + "ok": True, + "path": str(target), + "a0_path": normalize_a0_path(target), + "format": target.suffix.lower().lstrip(".") or "png", + "width": converted["width"], + "height": converted["height"], + "captured_at": iso_now(), + "recent": True, + "ephemeral": not explicit_path and not chat_scoped, + "chat_scoped": chat_scoped, + "context_id": safe_context, + "error": "", + } + except Exception as fallback_exc: + message = f"Pillow could not convert the XWD screenshot: {exc}; fallback parser failed: {fallback_exc}" + local_errors.append(message) + if ephemeral_ref: + raw_path.unlink(missing_ok=True) + target.unlink(missing_ok=True) + return { + "ok": False, + "path": "", + "format": "", + "captured_at": iso_now(), + "recent": False, + "ephemeral": True, + "context_id": safe_context, + "error": message, + } + return { + "ok": True, + "path": str(raw_path), + "a0_path": normalize_a0_path(raw_path), + "format": "xwd", + "captured_at": iso_now(), + "recent": True, + "ephemeral": not explicit_path and not chat_scoped, + "chat_scoped": chat_scoped, + "context_id": safe_context, + "error": message, + } + + +def convert_xwd_to_image(raw_path: Path, target: Path) -> dict[str, int]: + from PIL import Image + + data = raw_path.read_bytes() + header, _ = parse_xwd_header(data) + width = header["pixmap_width"] + height = header["pixmap_height"] + bytes_per_line = header["bytes_per_line"] + bits_per_pixel = header["bits_per_pixel"] + color_table_size = header["ncolors"] * 12 + pixel_offset = header["header_size"] + color_table_size + if width <= 0 or height <= 0 or bytes_per_line <= 0: + raise ValueError("invalid XWD dimensions") + pixel_size = height * bytes_per_line + if pixel_offset + pixel_size > len(data): + raise ValueError("truncated XWD pixel data") + + if (header["red_mask"], header["green_mask"], header["blue_mask"]) != ( + 0x00FF0000, + 0x0000FF00, + 0x000000FF, + ): + raise ValueError("unsupported XWD visual masks") + raw_mode = { + (24, 0): "BGR", + (24, 1): "RGB", + (32, 0): "BGRX", + (32, 1): "XRGB", + }.get((bits_per_pixel, header["byte_order"])) + if not raw_mode: + raise ValueError(f"unsupported XWD pixel layout: {bits_per_pixel} bpp") + + image = Image.frombytes( + "RGB", + (width, height), + data[pixel_offset : pixel_offset + pixel_size], + "raw", + raw_mode, + bytes_per_line, + 1, + ) + image.save(target) + return {"width": width, "height": height} + + +def parse_xwd_header(data: bytes) -> tuple[dict[str, int], str]: + if len(data) < 100: + raise ValueError("XWD header is too short") + field_names = ( + "header_size", + "file_version", + "pixmap_format", + "pixmap_depth", + "pixmap_width", + "pixmap_height", + "xoffset", + "byte_order", + "bitmap_unit", + "bitmap_bit_order", + "bitmap_pad", + "bits_per_pixel", + "bytes_per_line", + "visual_class", + "red_mask", + "green_mask", + "blue_mask", + "bits_per_rgb", + "colormap_entries", + "ncolors", + "window_width", + "window_height", + "window_x", + "window_y", + "window_bdrwidth", + ) + for endian in ("big", "little"): + values = [int.from_bytes(data[index : index + 4], endian, signed=False) for index in range(0, 100, 4)] + header = dict(zip(field_names, values, strict=True)) + if 100 <= header["header_size"] <= len(data) and header["file_version"] == 7: + return header, endian + raise ValueError("unsupported XWD header") + + +def resolve_environment(*, errors: list[str] | None = None, session_id: str = SESSION_ID) -> dict[str, str]: + local_errors = errors if errors is not None else [] + manifest = session_manifest_path(session_id) + payload: dict[str, Any] = {} + if manifest.exists(): + try: + payload = json.loads(manifest.read_text(encoding="utf-8")) + except Exception as exc: + local_errors.append(f"Desktop session manifest is unreadable: {exc}") + elif not (os.environ.get("A0_DESKTOP_DISPLAY") or os.environ.get("DISPLAY")): + local_errors.append(f"Desktop session manifest not found at {manifest}; open the Desktop canvas before GUI control.") + + display_value = str( + os.environ.get("A0_DESKTOP_DISPLAY") + or payload.get("display") + or os.environ.get("DISPLAY") + or "" + ).strip() + if display_value.startswith(":"): + display = display_value + elif display_value: + display = f":{display_value}" + else: + display = "" + local_errors.append("Desktop DISPLAY is unavailable; the persistent Desktop session is not running.") + + profile_dir = _state_path_from_retired_root( + Path( + os.environ.get("A0_DESKTOP_PROFILE") + or os.environ.get("A0_DESKTOP_HOME") + or payload.get("profile_dir") + or os.environ.get("HOME") + or PROFILE_DIR / session_id + ) + ) + + return { + "display": display, + "profile_dir": str(profile_dir), + "manifest": str(manifest), + } + + +def _state_path_from_retired_root(path: Path) -> Path: + try: + relative = path.resolve(strict=False).relative_to( + RETIRED_STATE_DIR.resolve(strict=False) + ) + except ValueError: + return path + return STATE_DIR / relative + + +def display_env(*, display: str, profile_dir: str) -> dict[str, str]: + env = { + **os.environ, + "HOME": profile_dir, + "XDG_CONFIG_HOME": os.environ.get("XDG_CONFIG_HOME") or str(Path(profile_dir) / ".config"), + "XDG_DATA_HOME": os.environ.get("XDG_DATA_HOME") or str(Path(profile_dir) / ".local" / "share"), + "XDG_CACHE_HOME": os.environ.get("XDG_CACHE_HOME") or str(Path(profile_dir) / ".cache"), + "XDG_CURRENT_DESKTOP": os.environ.get("XDG_CURRENT_DESKTOP") or "XFCE", + } + if display: + env["DISPLAY"] = display + xauthority = os.environ.get("A0_DESKTOP_XAUTHORITY") or str(Path(profile_dir) / ".Xauthority") + if Path(xauthority).exists(): + env["XAUTHORITY"] = xauthority + return env + + +def collect_capabilities() -> dict[str, str]: + return { + name: shutil.which(name) or "" + for name in ( + "xdotool", + "xrandr", + "xwininfo", + "xprop", + "xwd", + "xclip", + ) + } + + +def collect_display_size(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]: + if not capabilities.get("xrandr"): + return {"width": 0, "height": 0} + result = run([capabilities["xrandr"], "-q"], env=env, timeout=4) + if result.returncode != 0: + errors.append(command_output(result) or "xrandr could not read the Desktop display.") + return {"width": 0, "height": 0} + match = re.search(r"\bcurrent\s+(\d+)\s+x\s+(\d+)", result.stdout) + if not match: + errors.append("xrandr output did not include the current Desktop size.") + return {"width": 0, "height": 0} + return {"width": int(match.group(1)), "height": int(match.group(2))} + + +def collect_pointer(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]: + if not capabilities.get("xdotool"): + return {"x": 0, "y": 0, "screen": 0, "window": 0} + result = run([capabilities["xdotool"], "getmouselocation", "--shell"], env=env, timeout=3) + if result.returncode != 0: + errors.append(command_output(result) or "xdotool could not read the pointer location.") + return {"x": 0, "y": 0, "screen": 0, "window": 0} + values = parse_shell_values(result.stdout) + return { + "x": int_value(values.get("X")), + "y": int_value(values.get("Y")), + "screen": int_value(values.get("SCREEN")), + "window": int_value(values.get("WINDOW")), + } + + +def collect_active_window(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, Any] | None: + if not capabilities.get("xdotool"): + return None + result = run([capabilities["xdotool"], "getactivewindow"], env=env, timeout=3) + if result.returncode != 0: + return None + window_id = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" + if not window_id: + return None + return collect_window(env, capabilities, window_id, errors) + + +def collect_windows(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> list[dict[str, Any]]: + if not capabilities.get("xdotool"): + return [] + result = run([capabilities["xdotool"], "search", "--onlyvisible", "--name", "."], env=env, timeout=4) + if result.returncode != 0: + detail = command_output(result) + if detail: + errors.append(detail) + return [] + windows: list[dict[str, Any]] = [] + seen: set[str] = set() + for window_id in result.stdout.splitlines(): + window_id = window_id.strip() + if not window_id or window_id in seen: + continue + seen.add(window_id) + windows.append(collect_window(env, capabilities, window_id, errors)) + return windows + + +def collect_window( + env: dict[str, str], + capabilities: dict[str, str], + window_id: str, + errors: list[str], +) -> dict[str, Any]: + props = collect_window_props(env, capabilities, window_id) + geometry = collect_window_geometry(env, capabilities, window_id) + return { + "id": str(window_id), + "title": props.get("title", ""), + "class": props.get("class", ""), + "name": props.get("name", ""), + "pid": int_value(props.get("pid")), + "geometry": geometry, + } + + +def collect_window_geometry(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, int]: + geometry = {"x": 0, "y": 0, "width": 0, "height": 0} + if not capabilities.get("xwininfo"): + return geometry + result = run([capabilities["xwininfo"], "-id", str(window_id)], env=env, timeout=3) + if result.returncode != 0: + return geometry + patterns = { + "x": r"Absolute upper-left X:\s*(-?\d+)", + "y": r"Absolute upper-left Y:\s*(-?\d+)", + "width": r"Width:\s*(\d+)", + "height": r"Height:\s*(\d+)", + } + for key, pattern in patterns.items(): + match = re.search(pattern, result.stdout) + if match: + geometry[key] = int(match.group(1)) + return geometry + + +def collect_window_props(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, str]: + props = {"title": "", "class": "", "name": "", "pid": ""} + xdotool = capabilities.get("xdotool") + if xdotool: + result = run([xdotool, "getwindowname", str(window_id)], env=env, timeout=3) + if result.returncode == 0: + props["title"] = result.stdout.strip() + xprop = capabilities.get("xprop") + if not xprop: + return props + result = run([xprop, "-id", str(window_id), "WM_CLASS", "WM_NAME", "_NET_WM_NAME", "_NET_WM_PID"], env=env, timeout=3) + if result.returncode != 0: + return props + parsed = parse_xprop(result.stdout) + title = parsed.get("_NET_WM_NAME") or parsed.get("WM_NAME") or props["title"] + props["title"] = title + props["class"] = parsed.get("WM_CLASS_CLASS", "") + props["name"] = parsed.get("WM_CLASS_NAME", "") + props["pid"] = parsed.get("_NET_WM_PID", "") + return props + + +def parse_xprop(output: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in output.splitlines(): + if "=" not in line: + continue + key, raw_value = line.split("=", 1) + key = key.strip().split("(", 1)[0] + raw_value = raw_value.strip() + quoted = re.findall(r'"([^"]*)"', raw_value) + if key == "WM_CLASS" and quoted: + values["WM_CLASS_NAME"] = quoted[0] + values["WM_CLASS_CLASS"] = quoted[-1] + continue + if quoted: + values[key] = quoted[-1] + continue + match = re.search(r"-?\d+", raw_value) + values[key] = match.group(0) if match else raw_value + return values + + +def latest_screenshot(*, context_id: str = "") -> dict[str, Any]: + chat_dir = chat_screenshot_dir(context_id) + chat_latest = _latest_screenshot_from_dir( + chat_dir, + context_id=context_id, + ephemeral=False, + chat_scoped=True, + prune_older=False, + ) + if chat_latest.get("ok"): + return chat_latest + + prune_context_screenshots(context_id=context_id, max_age_seconds=RECENT_SCREENSHOT_SECONDS) + screenshot_dir = context_screenshot_dir(context_id) + return _latest_screenshot_from_dir( + screenshot_dir, + context_id=context_id, + ephemeral=True, + chat_scoped=False, + prune_older=True, + ) + + +def _latest_screenshot_from_dir( + screenshot_dir: Path, + *, + context_id: str = "", + ephemeral: bool, + chat_scoped: bool, + prune_older: bool, +) -> dict[str, Any]: + if not screenshot_dir.exists(): + return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False} + candidates = [ + path + for path in screenshot_dir.iterdir() + if path.is_file() and path.suffix.lower() in _SCREENSHOT_SUFFIXES + ] + if not candidates: + return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False} + latest = max(candidates, key=lambda item: item.stat().st_mtime) + if prune_older: + for candidate in candidates: + if candidate != latest: + candidate.unlink(missing_ok=True) + age = max(0.0, time.time() - latest.stat().st_mtime) + return { + "ok": True, + "path": str(latest), + "a0_path": normalize_a0_path(latest), + "format": latest.suffix.lower().lstrip("."), + "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(latest.stat().st_mtime)), + "recent": age <= RECENT_SCREENSHOT_SECONDS, + "ephemeral": ephemeral, + "chat_scoped": chat_scoped, + "context_id": _safe_context_id(context_id), + } + + +def stable_state( + *, + display: str, + profile_dir: str, + context_id: str = "", + size: dict[str, int] | None = None, + pointer: dict[str, int] | None = None, + active_window: dict[str, Any] | None = None, + windows: list[dict[str, Any]] | None = None, + screenshot: dict[str, Any] | None = None, + capabilities: dict[str, str] | None = None, + errors: list[str] | None = None, +) -> dict[str, Any]: + clean_errors = [str(error) for error in errors or [] if str(error)] + return { + "ok": not clean_errors, + "context_id": _safe_context_id(context_id), + "display": display, + "profile_dir": profile_dir, + "size": size or {"width": 0, "height": 0}, + "pointer": pointer or {"x": 0, "y": 0, "screen": 0, "window": 0}, + "active_window": active_window, + "windows": windows or [], + "screenshot": screenshot or {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}, + "capabilities": capabilities or collect_capabilities(), + "errors": clean_errors, + } + + +def compact_prompt_context(state: dict[str, Any] | None = None) -> str: + state = state if state is not None else collect_state(include_screenshot=False) + if not state.get("display"): + return "" + lines = ["[DESKTOP STATE]"] + size = state.get("size") or {} + pointer = state.get("pointer") or {} + lines.append( + f"- display={state.get('display', '')} size={size.get('width', 0)}x{size.get('height', 0)} " + f"pointer={pointer.get('x', 0)},{pointer.get('y', 0)}" + ) + active = state.get("active_window") or {} + if active: + lines.append( + f"- active={active.get('title', '') or ''} " + f"class={active.get('class', '') or active.get('name', '')}" + ) + visible = [] + for window in state.get("windows") or []: + title = window.get("title") or "" + window_class = window.get("class") or window.get("name") or "" + visible.append(f"{title} ({window_class})" if window_class else title) + if len(visible) >= 5: + break + if visible: + lines.append("- visible=" + "; ".join(visible)) + screenshot = state.get("screenshot") or {} + if screenshot.get("recent") and screenshot.get("path"): + ephemeral = " ephemeral" if screenshot.get("ephemeral") else "" + screenshot_ref = screenshot.get("a0_path") or screenshot["path"] + lines.append(f"- recent_screenshot={screenshot_ref}{ephemeral}") + context_id = str(state.get("context_id") or "").strip() + if context_id: + lines.append(f"- screenshot_context={context_id}") + context_arg = f" --context-id {context_id}" if context_id else "" + lines.append( + "- next=plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh state --json" + f"{context_arg} for structured checks; use observe --json --screenshot" + f"{context_arg} " + "before coordinate or visual-OCR actions; prefer sequence/focus/key/paste/save/app-native helpers first." + ) + lines.append( + "- verify=for terminal/CLI-agent output, use the screenshot path from a fresh final " + "observe --json --screenshot captured after the response appears." + ) + if state.get("errors"): + lines.append("- errors=" + "; ".join(str(item) for item in state["errors"][:2])) + return "\n".join(lines) + + +def parse_shell_values(output: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in output.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"') + return values + + +def int_value(value: Any, default: int = 0) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return default + + +def run(command: list[str], *, env: dict[str, str], timeout: float) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + except OSError as exc: + return subprocess.CompletedProcess(command, 127, "", str(exc)) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") + stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") + return subprocess.CompletedProcess(command, 124, stdout, stderr or "command timed out") + + +def command_output(result: subprocess.CompletedProcess[str]) -> str: + return (result.stderr or result.stdout or "").strip() + + +def image_width(path: Path) -> int: + try: + from PIL import Image + + with Image.open(path) as image: + return int(image.width) + except Exception: + return 0 + + +def image_height(path: Path) -> int: + try: + from PIL import Image + + with Image.open(path) as image: + return int(image.height) + except Exception: + return 0 + + +def ephemeral_screenshot_result( + path: Path, + *, + context_id: str = "", + image_format: str = "png", + width: int = 0, + height: int = 0, +) -> dict[str, Any]: + from helpers import ephemeral_images + + mime = "image/jpeg" if image_format.lower() in {"jpg", "jpeg"} else "image/png" + safe_context = _safe_context_id(context_id) + ref = ephemeral_images.put_image_bytes( + context_id=str(context_id or "").strip(), + mime=mime, + payload=path.read_bytes(), + name=path.name, + ) + path.unlink(missing_ok=True) + prune_context_screenshots(context_id=context_id) + return { + "ok": True, + "path": "", + "format": image_format, + "mime": mime, + "width": width, + "height": height, + "captured_at": iso_now(), + "recent": True, + "ephemeral": True, + "ephemeral_ref": ref, + "context_id": safe_context, + "vision_load": { + "tool_name": "vision_load", + "tool_args": {"paths": [ref]}, + }, + "error": "", + } + + +def prune_context_screenshots( + *, + context_id: str = "", + keep_path: Path | None = None, + max_age_seconds: float | None = None, +) -> None: + screenshot_dir = context_screenshot_dir(context_id) + if not screenshot_dir.exists(): + return + keep = keep_path.resolve(strict=False) if keep_path else None + now = time.time() + for candidate in screenshot_dir.iterdir(): + if not candidate.is_file() or candidate.suffix.lower() not in _SCREENSHOT_SUFFIXES: + continue + if keep is not None and candidate.resolve(strict=False) == keep: + continue + if max_age_seconds is not None: + try: + if now - candidate.stat().st_mtime <= max_age_seconds: + continue + except OSError: + pass + candidate.unlink(missing_ok=True) + try: + screenshot_dir.rmdir() + except OSError: + pass + + +def iso_now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Observe the Agent Zero persistent Linux Desktop state.") + subparsers = parser.add_subparsers(dest="command") + + state_parser = subparsers.add_parser("state") + state_parser.add_argument("--json", action="store_true") + state_parser.add_argument("--screenshot", action="store_true") + state_parser.add_argument("--context-id", default="") + + observe_parser = subparsers.add_parser("observe") + observe_parser.add_argument("--json", action="store_true") + observe_parser.add_argument("--screenshot", action="store_true") + observe_parser.add_argument("--context-id", default="") + + screenshot_parser = subparsers.add_parser("screenshot") + screenshot_parser.add_argument("path", nargs="?") + screenshot_parser.add_argument("--json", action="store_true") + screenshot_parser.add_argument("--context-id", default="") + + args = parser.parse_args(argv) + command = args.command or "state" + if command in {"state", "observe"}: + payload = collect_state( + include_screenshot=bool(args.screenshot), + context_id=str(args.context_id or ""), + screenshot_transport="path", + ) + print(json.dumps(payload, sort_keys=True)) + return 0 if payload.get("ok") else 1 + + if command == "screenshot": + errors: list[str] = [] + env_info = resolve_environment(errors=errors) + payload = capture_screenshot( + display_env(display=env_info["display"], profile_dir=env_info["profile_dir"]), + collect_capabilities(), + path=args.path, + errors=errors, + context_id=str(args.context_id or ""), + transport="path", + ) + if args.json: + print(json.dumps(payload, sort_keys=True)) + else: + print(payload.get("path") or payload.get("error") or "") + return 0 if payload.get("ok") else 1 + + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/_desktop/helpers/prompt_context.py b/plugins/_desktop/helpers/prompt_context.py new file mode 100644 index 0000000000000000000000000000000000000000..9350f4262299e3d128e341867a3616638a6bb996 --- /dev/null +++ b/plugins/_desktop/helpers/prompt_context.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from plugins._desktop.helpers import desktop_state + + +def build_context(context_id: str = "") -> str: + if not desktop_state.session_manifest_exists(): + return "" + try: + return desktop_state.compact_prompt_context( + desktop_state.collect_state(include_screenshot=False, context_id=context_id), + ) + except Exception as exc: + return ( + "[DESKTOP STATE]\n" + f"- unavailable={exc}\n" + "- next=Open the Desktop surface manually, then run plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json." + ) diff --git a/plugins/_desktop/hooks.py b/plugins/_desktop/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..89a6390dfe2c01219a59de1ff177936c211ddf2a --- /dev/null +++ b/plugins/_desktop/hooks.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import threading +import time +import urllib.request +from pathlib import Path +from typing import Any + +from helpers import files, state_migration, system_packages + +LIBREOFFICE_RUNTIME_PACKAGES = ( + "libreoffice-core", + "libreoffice-writer", + "libreoffice-calc", + "libreoffice-impress", + "libreoffice-gtk3", + "python3-uno", +) +XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources") +XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc") +XPRA_KEY_URL = "https://xpra.org/xpra.asc" +XPRA_VERSION = "6.5.2-r0-1" +GTK_RUNTIME_PACKAGE = "gir1.2-gtk-3.0" +KALI_ROLLING_SOURCE = "deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware\n" +XPRA_VERSIONED_RUNTIME_PACKAGES = frozenset( + { + "xpra-common", + "xpra-server", + "xpra-client", + "xpra-client-gtk3", + "xpra-x11", + } +) +RUNTIME_PACKAGES = ( + *LIBREOFFICE_RUNTIME_PACKAGES, + GTK_RUNTIME_PACKAGE, + "xpra-common", + "xpra-server", + "xpra-client", + "xpra-client-gtk3", + "xpra-x11", + "xpra-html5", + "xfce4-session", + "xfwm4", + "xfce4-panel", + "xfdesktop4", + "xfce4-settings", + "thunar", + "gvfs", + "libglib2.0-bin", + "xfce4-terminal", + "x11-xserver-utils", + "x11-utils", + "x11-apps", + "xdotool", + "xclip", + "xauth", + "dbus-x11", + "python3-pil", + "fonts-dejavu", + "fonts-liberation", + "fonts-crosextra-caladea", + "fonts-crosextra-carlito", + "fonts-noto-core", + "fonts-noto-cjk", + "fonts-noto-color-emoji", +) +RETIRED_RUNTIME_PACKAGES = ( + "firefox-esr", +) +PLUGIN_NAME = "_desktop" +STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME)) +RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME)) +_preparation_lock = threading.RLock() +_preparation_state: dict[str, Any] = { + "preparing": False, + "active_count": 0, + "started_at": 0.0, + "completed_at": 0.0, + "result": None, + "error": "", +} + + +def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]: + """Prepare the Linux Desktop runtime and reap stale Desktop sessions.""" + + _begin_runtime_preparation() + result: dict[str, Any] | None = None + error = "" + try: + installed: list[str] = [] + removed: list[str] = [] + migrated: list[str] = [] + warnings: list[str] = [] + errors: list[str] = [] + + _migrate_retired_plugin_state(migrated, warnings, errors) + _remove_persisted_screenshots(warnings, errors) + + retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES) + if retired_packages: + _purge_packages(removed, errors, installed_packages=retired_packages) + + _ensure_runtime_dependencies(installed, errors) + _cleanup_desktop_sessions(errors) + + result = { + "ok": not errors, + "skipped": False, + "removed": removed, + "installed": installed, + "migrated": migrated, + "warnings": warnings, + "errors": errors, + } + return result + except Exception as exc: + error = str(exc) + raise + finally: + _finish_runtime_preparation(result=result, error=error) + + +def runtime_preparation_status() -> dict[str, Any]: + with _preparation_lock: + return { + "preparing": bool(_preparation_state["preparing"]), + "active_count": int(_preparation_state["active_count"]), + "started_at": float(_preparation_state["started_at"]), + "completed_at": float(_preparation_state["completed_at"]), + "result": _preparation_state["result"], + "error": str(_preparation_state["error"]), + } + + +def _migrate_retired_plugin_state( + migrated: list[str], + warnings: list[str], + errors: list[str], +) -> None: + state_migration.migrate_retired_state_tree( + source=RETIRED_STATE_DIR, + destination=STATE_DIR, + owner="Desktop", + migrated=migrated, + warnings=warnings, + errors=errors, + ) + + +def _remove_persisted_screenshots( + warnings: list[str], + errors: list[str], +) -> None: + screenshots_dir = STATE_DIR / "screenshots" + if not screenshots_dir.exists(): + return + try: + shutil.rmtree(screenshots_dir) + warnings.append(f"Removed retired persistent Desktop screenshots: {screenshots_dir}") + except Exception as exc: + errors.append(f"Failed to remove retired persistent Desktop screenshots at {screenshots_dir}: {exc}") + + +def _begin_runtime_preparation() -> None: + with _preparation_lock: + if not _preparation_state["active_count"]: + _preparation_state["preparing"] = True + _preparation_state["started_at"] = time.time() + _preparation_state["completed_at"] = 0.0 + _preparation_state["result"] = None + _preparation_state["error"] = "" + _preparation_state["active_count"] = int(_preparation_state["active_count"]) + 1 + + +def _finish_runtime_preparation( + *, + result: dict[str, Any] | None = None, + error: str = "", +) -> None: + with _preparation_lock: + active_count = max(0, int(_preparation_state["active_count"]) - 1) + _preparation_state["active_count"] = active_count + if result is not None: + _preparation_state["result"] = result + if error: + _preparation_state["error"] = error + if active_count: + return + _preparation_state["preparing"] = False + _preparation_state["completed_at"] = time.time() + + +def _installed_packages(packages: tuple[str, ...]) -> list[str]: + if not shutil.which("dpkg-query"): + return [] + return [package for package in packages if _package_installed(package)] + + +def _package_installed(package: str) -> bool: + result = subprocess.run( + ["dpkg-query", "-W", "-f=${Status}", package], + check=False, + text=True, + capture_output=True, + timeout=8, + ) + return result.returncode == 0 and "install ok installed" in result.stdout + + +def _package_version(package: str) -> str: + result = subprocess.run( + ["dpkg-query", "-W", "-f=${Version}", package], + check=False, + text=True, + capture_output=True, + timeout=8, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + +def _purge_packages( + removed: list[str], + errors: list[str], + *, + installed_packages: list[str] | None = None, +) -> None: + if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"): + return + installed = installed_packages if installed_packages is not None else [] + if not installed: + return + result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180) + if result.returncode == 0: + removed.extend(installed) + return + errors.append((result.stderr or result.stdout or "apt-get purge failed").strip()) + + +def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None: + if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"): + return + if not _ensure_kali_gtk_runtime(installed, errors): + return + missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)] + if not missing: + return + + if not _apt_update(errors): + return + + required_xpra_missing = [package for package in missing if package.startswith("xpra")] + if required_xpra_missing and not _package_candidates_available(required_xpra_missing): + previous_error_count = len(errors) + _ensure_xpra_repository(installed, errors) + if len(errors) > previous_error_count or not _apt_update(errors): + return + missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)] + if not missing: + return + + if missing: + _install_runtime_packages(missing, installed, errors) + + +def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool: + if ( + GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES + or _package_installed(GTK_RUNTIME_PACKAGE) + or _read_os_release().get("ID") != "kali" + ): + return True + + with tempfile.TemporaryDirectory(prefix="a0-desktop-apt-") as directory: + source = Path(directory) / "kali-rolling.list" + source.write_text(KALI_ROLLING_SOURCE, encoding="utf-8") + options = [ + "-o", + f"Dir::Etc::sourcelist={source}", + "-o", + "Dir::Etc::sourceparts=-", + ] + result = _run_apt_command(["apt-get", *options, "update"], timeout=300) + if result.returncode == 0: + result = _run_apt_command( + [ + "apt-get", + *options, + "install", + "-y", + "--no-install-recommends", + GTK_RUNTIME_PACKAGE, + ], + timeout=900, + ) + if result.returncode == 0: + installed.append(GTK_RUNTIME_PACKAGE) + return True + errors.append((result.stderr or result.stdout or "GTK runtime install failed").strip()) + return False + + +def _install_runtime_packages( + packages: list[str], + installed: list[str], + errors: list[str], +) -> bool: + xpra_version = "" + if any(package in XPRA_VERSIONED_RUNTIME_PACKAGES for package in packages): + xpra_version = _package_version("xpra-common") or XPRA_VERSION + package_specs = [ + f"{package}={xpra_version}" if package in XPRA_VERSIONED_RUNTIME_PACKAGES else package + for package in packages + ] + result = _run_apt_command( + ["apt-get", "install", "-y", "--no-install-recommends", *package_specs], + timeout=900, + ) + if result.returncode == 0: + installed.extend(packages) + return True + output = (result.stderr or result.stdout or "apt-get install failed").strip() + errors.append(output) + return False + + +def _apt_update(errors: list[str]) -> bool: + result = _run_apt_command(["apt-get", "update"], timeout=300) + if result.returncode == 0: + return True + errors.append((result.stderr or result.stdout or "apt-get update failed").strip()) + return False + + +def _package_candidate_available(package: str) -> bool: + if not shutil.which("apt-cache"): + return True + result = subprocess.run( + ["apt-cache", "policy", package], + check=False, + text=True, + capture_output=True, + timeout=15, + ) + if result.returncode != 0: + return True + if not result.stdout.strip(): + return False + return "Candidate: (none)" not in result.stdout + + +def _package_candidates_available(packages: list[str]) -> bool: + return all(_package_candidate_available(package) for package in packages) + + +def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None: + if not _package_installed("ca-certificates"): + result = _run_apt_command( + ["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"], + timeout=180, + ) + if result.returncode != 0: + errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip()) + return + installed.append("ca-certificates") + + try: + key = _download(XPRA_KEY_URL) + XPRA_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True) + if not XPRA_KEYRING_FILE.exists() or XPRA_KEYRING_FILE.read_bytes() != key: + XPRA_KEYRING_FILE.write_bytes(key) + + XPRA_SOURCE_FILE.parent.mkdir(parents=True, exist_ok=True) + source = _xpra_repository_source() + if not XPRA_SOURCE_FILE.exists() or XPRA_SOURCE_FILE.read_text(encoding="utf-8") != source: + XPRA_SOURCE_FILE.write_text(source, encoding="utf-8") + except Exception as exc: + errors.append(f"Xpra repository setup failed: {exc}") + + +def _download(url: str) -> bytes: + with urllib.request.urlopen(url, timeout=45) as response: + return response.read() + + +def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: + return system_packages.run_apt_with_retries( + lambda: subprocess.run( + command, + check=False, + text=True, + capture_output=True, + timeout=timeout, + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"}, + ) + ) + + +def _xpra_repository_source() -> str: + os_release = _read_os_release() + os_id = os_release.get("ID", "") + codename = os_release.get("VERSION_CODENAME", "") + arch = _dpkg_architecture() + + uri = "https://xpra.org" + suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie" + + return ( + f"Types: deb\n" + f"URIs: {uri}\n" + f"Suites: {suite}\n" + f"Components: main\n" + f"Signed-By: {XPRA_KEYRING_FILE}\n" + f"Architectures: {arch}\n" + ) + + +def _read_os_release() -> dict[str, str]: + path = Path("/etc/os-release") + if not path.exists(): + return {} + values: dict[str, str] = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key] = value.strip().strip('"') + return values + + +def _dpkg_architecture() -> str: + result = subprocess.run( + ["dpkg", "--print-architecture"], + check=False, + text=True, + capture_output=True, + timeout=8, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return "amd64" + + +def _cleanup_desktop_sessions(errors: list[str]) -> None: + try: + from plugins._desktop.helpers import desktop_session + + result = desktop_session.cleanup_stale_runtime_state() + errors.extend(str(item) for item in result.get("errors") or []) + except Exception as exc: + errors.append(f"Desktop cleanup failed: {exc}") diff --git a/plugins/_desktop/plugin.yaml b/plugins/_desktop/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..768ac49c61a7b5c4cdb3506acba1754d7c32cd43 --- /dev/null +++ b/plugins/_desktop/plugin.yaml @@ -0,0 +1,5 @@ +name: _desktop +title: Desktop +description: Owns the Agent Zero Linux Desktop runtime, Xpra/Xfce sessions, and live Desktop surface. +version: 1.0.0 +always_enabled: false diff --git a/plugins/_desktop/prompts/agent.extras.desktop_state.md b/plugins/_desktop/prompts/agent.extras.desktop_state.md new file mode 100644 index 0000000000000000000000000000000000000000..0de0fa8d79e7c9ee2345e9fc098bd65fcbe13b21 --- /dev/null +++ b/plugins/_desktop/prompts/agent.extras.desktop_state.md @@ -0,0 +1 @@ +{{desktop_state}} diff --git a/plugins/_desktop/skills/linux-desktop/SKILL.md b/plugins/_desktop/skills/linux-desktop/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..9ede17908a64bef2fdbeaba6592de22604e88ddb --- /dev/null +++ b/plugins/_desktop/skills/linux-desktop/SKILL.md @@ -0,0 +1,202 @@ +--- +name: linux-desktop +description: Use only for Agent Zero's built-in Docker/Xpra Linux Desktop, XFCE apps, LibreOffice GUI apps, file manager, terminal, or visual workflows inside the Agent Zero runtime. Not for A0 CLI /computer-use or computer_use_remote host control. +version: "0.3.0" +author: "Agent Zero Core Team" +tags: ["linux", "desktop", "xfce", "libreoffice", "gui", "files", "terminal"] +triggers: + - "Agent Zero Desktop" + - "built-in Linux Desktop" + - "Xpra Desktop" + - "XFCE Desktop" + - "LibreOffice GUI in Desktop" + - "Desktop file manager" + - "Docker desktop terminal" +allowed_tools: + - office_artifact + - code_execution_tool +--- + +# Linux Desktop Interface + +## Boundary With Host Computer Use + +This skill is only for the built-in Desktop surface running inside Agent Zero's Docker/runtime Xpra session. It does not control the user's host OS, real monitor, local Ubuntu/Wayland session, macOS desktop, or Windows desktop. + +If the user asks for `/computer-use`, "my computer", "host machine", "local desktop", screenshots of the user's screen, hiding/minimizing a window on their screen, or any connected A0 CLI Computer Use task, do not use this skill, `desktopctl.sh`, Xpra, `code_execution_tool`, or Docker shell commands. Load `host-computer-use` and use `computer_use_remote`; if unavailable, tell the user to enable `/computer-use on`. + +`desktopctl.sh` only targets the internal Agent Zero Xpra display. It cannot observe or control the user's connected host screen. + +Use the Desktop as a full Linux GUI when the user explicitly needs a visual workflow, an installed desktop app, or manual layout polish that is awkward through structured file edits alone. Agent Zero may warm the persistent Desktop runtime during initial startup, but visible Desktop surface use remains opt-in. The Desktop is opt-in at the UI level: do not open a surface just because the user asks for a document. Use structured tools first for deterministic content changes, then use the Desktop for inspection, GUI-only actions, and final visual confirmation. + +## Operating Model + +The Desktop is a structured-first X11 control surface. Use this decision hierarchy: + +1. Prefer structured tools such as `office_artifact` for deterministic Office file creation, reads, and edits. +2. Prefer structured Desktop state and window commands: `check`, `state --json`, `windows`, `active-window`, `geometry`, and `wait-window`. +3. Prefer app-native helpers for visible live edits, such as `desktopctl.sh calc-set-cell` for Calc/UNO spreadsheet changes. +4. Prefer launcher commands, window focus, keyboard shortcuts, menus, paste, and save commands. Batch multi-step actions with `sequence`/`batch` so one helper process drives the flow. +5. Use screenshots for visual inspection, OCR, coordinate work, and final evidence when the task depends on pixels. Do not take screenshots merely to learn window titles or readiness that structured state already reports. +6. Use coordinate clicks only as a last resort, and only after a fresh screenshot observation. +7. After any GUI action, verify through Desktop state, active window titles, screenshots when visually necessary, saved file state, or exported output. +8. For terminal or CLI-agent work, prefer deterministic command output or saved transcripts. When exact visible terminal text matters, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Agent-facing Desktop screenshots are ephemeral refs; `desktopctl` shell observations with `--context-id` return chat-scoped screenshot paths. Do not report from an earlier screenshot path. + +Keep these standing rules: + +1. Treat Markdown as first-class, but use the separate Editor surface for it. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and let the user open it in Editor instead of Desktop. +2. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit OOXML compatibility. +3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation. +4. Never open the Desktop surface automatically from a tool result if the user has not opened it. Offer an explicit Open in Desktop action instead. +5. Launch common apps from the Desktop icons, the header buttons, or `/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh`. +6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version. +7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success. If exact terminal text matters, load or inspect the screenshot path returned by the final observation, not a screenshot captured before the text appeared. + +## Control Flow + +Use the helper script when the Desktop is already open and you need reliable app launches, clicks, keystrokes, or window checks from the agent shell. In the live Agent Zero runtime, prefer the absolute path so the command works from any current directory: + +```bash +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +$DESKTOP check +$DESKTOP state --json +$DESKTOP observe --json +$DESKTOP launch calc +$DESKTOP wait-window LibreOffice +$DESKTOP windows LibreOffice +$DESKTOP focus LibreOffice +$DESKTOP key ctrl+s +``` + +The script targets the persistent `agent-zero-desktop` X display, sets `DISPLAY`, `XAUTHORITY`, and `HOME` to the XFCE profile, then uses `xdotool` for input. Startup normally prepares this session. If `check` fails during explicit Desktop work, report that the Desktop runtime is not ready instead of installing packages ad hoc. + +If `state --json` or `observe --json` shows a reachable display and visible Desktop/window entries, the Desktop is usable even when `active_window` is `null`; a bare XFCE desktop can have no active application window. Treat missing display or unavailable `xdotool` as blockers and stop with the specific readiness message instead of repeating clicks or inventing a fallback. Use `observe --json --screenshot` only when you need pixels, and treat unavailable `xwd` as a screenshot blocker rather than a general Desktop blocker. Shell screenshots captured with `--context-id` live in the owning chat's screenshot folder; screenshots without a chat context remain temporary. + +For direct app launches without coordinates: + +```bash +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +$DESKTOP launch writer +$DESKTOP launch calc +$DESKTOP launch impress +$DESKTOP launch terminal +$DESKTOP launch settings +$DESKTOP open-path /a0/usr/workdir +$DESKTOP focus "LibreOffice" +$DESKTOP paste-text "Text to insert" +$DESKTOP key ctrl+s +``` + +For multi-step window or terminal actions, batch commands through one helper process: + +```bash +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +$DESKTOP sequence - <<'EOF' +focus Terminal +paste-text echo ready +key Return +state --json +EOF +``` + +Use one command per line. `paste-text` joins the remaining words on that line, so it is suitable for ordinary command text; for complex multiline payloads, write the payload to a file or invoke `paste-text` directly. + +For live spreadsheet coworking, use the Calc helper instead of hand-written UNO snippets: + +```bash +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +$DESKTOP calc-set-cell /a0/usr/workdir/example.xlsx Sheet1 B2 "Cowork verified live" +``` + +This opens the workbook in the visible Desktop Calc session if needed, changes the cell through LibreOffice, saves the workbook, and verifies the `.xlsx` on disk. Because the edit happens through the running LibreOffice session, the user can see the sheet update without refreshing the Desktop surface. + +For coordinate actions, clicks are explicitly last resort. First try `launch`, `open-path`, `wait-window`, `focus`, `key`, `paste-text`, `save`, or an app-native helper. If a coordinate action is still necessary, base it on a fresh screenshot observation and verify immediately afterward: + +```bash +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +$DESKTOP observe --json --screenshot +$DESKTOP click 120 180 +$DESKTOP dblclick 120 180 +$DESKTOP right-click 120 180 +$DESKTOP drag 120 180 400 180 +$DESKTOP scroll down 3 +$DESKTOP type "Text to enter" +$DESKTOP observe --json +``` + +When browser automation is available, the higher-level QA flow is: + +1. Open `http://127.0.0.1:32080`. +2. Open the Desktop surface from the UI or with `Alpine.store("rightCanvas").open("desktop")`. +3. Use browser mouse events into the Xpra iframe for real user-path testing. +4. Cross-check with `desktopctl.sh location` and `desktopctl.sh windows PATTERN`. +5. Capture the browser screenshot as visual evidence. + +## Terminal And CLI Agent Verification + +Terminal apps are visual state, not structured logs. When the task depends on exact terminal output, follow this stricter loop: + +1. Run `desktopctl.sh state --json` or `desktopctl.sh windows Terminal` before acting to confirm the target window. Add `observe --json --screenshot` only if the current prompt or screen contents must be read visually. +2. Use `sequence`, `focus`, `paste-text` or `type`, and `key Return` to drive the terminal. Prefer CLI-native commands and keyboard input over clicks. +3. Wait until the CLI has visibly produced a response or returned to an input prompt. +4. If exact visible text matters and no deterministic transcript was saved, run a new final `desktopctl.sh observe --json --screenshot`. +5. Verify exact text only from the screenshot path returned by that final observation, or from a newer screenshot. Never use an earlier screenshot path as final evidence. +6. If the final screenshot is cropped, stale, or unreadable, capture another screenshot or report the result as unverified with that specific reason. + +For nested CLI agents, a successful proof requires both the input prompt and the nested agent's visible response in the final screenshot, or another deterministic saved transcript produced by the CLI itself. + +Guard the boundary between the shell and the target CLI carefully: + +- A shell prompt such as `root@...#` means the target CLI is not currently receiving chat input. Never paste natural-language text into that shell prompt unless it is deliberately quoted as an argument to a shell command. +- If launching a CLI by name can fail, use a shell-safe fallback command and wait for the CLI's own prompt before sending the user's natural-language message. +- After a launch failure such as `command not found`, do not continue by sending the chat message. Start the fallback CLI command or report the blocker. +- A prompt like `>`, `Implement {feature}`, or a CLI-specific input box inside the terminal is different from a shell prompt. Only then should `paste-text "natural language"` followed by `key Return` be used as chat input. + +Example for a nested CLI-agent smoke test: + +```bash +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +$DESKTOP sequence - <<'EOF' +focus Terminal +paste-text TARGET_CLI="example-cli-agent"; FALLBACK_CMD=""; if command -v "$TARGET_CLI" >/dev/null 2>&1; then "$TARGET_CLI"; elif [ -n "$FALLBACK_CMD" ]; then sh -lc "$FALLBACK_CMD"; else echo "CLI agent not found: $TARGET_CLI"; fi +key Return +EOF +$DESKTOP observe --json --screenshot +# Verify the screenshot shows the target CLI prompt, not a shell prompt, before sending natural language: +$DESKTOP sequence - <<'EOF' +paste-text Reply with exactly the requested smoke-test token. +key Return +EOF +$DESKTOP observe --json --screenshot +``` + +## Desktop Locations + +The Desktop exposes stable folders for common user work: + +- `Workdir` -> configured Agent Zero workdir (default `/a0/usr/workdir`) +- `Projects` -> `/a0/usr/projects` +- `Skills` -> `/a0/usr/skills` +- `Agents` -> `/a0/usr/agents` +- `Downloads` -> `/a0/usr/downloads` + +Use these folders when the user asks to inspect or manipulate project files, skills, agent profiles, or downloaded artifacts from the GUI. + +## App Map + +- `LibreOffice Writer`: ODT word processing and DOCX compatibility layout. +- `LibreOffice Calc`: ODS spreadsheets, formulas, tables, charts, and XLSX compatibility. +- `LibreOffice Impress`: ODP presentations, slide polish, and PPTX compatibility. +- `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`). +- `Terminal`: shell work inside the Agent Zero runtime. +- `Settings`: XFCE system settings. + +## Practical Rules + +- Keep installs inside normal plugin hook or image install flows; do not install packages ad hoc just to complete one desktop action. +- The persistent Desktop can be running in the background while the canvas stays closed; that is expected and still respects user ownership of the visible UI. +- Do not treat closing a document tab as closing the whole Desktop. The Desktop is persistent while Agent Zero is running. +- Save before sync or final verification when the GUI app has edited a file. +- If a GUI action is flaky, switch to structured file editing for content and return to the Desktop only for visual confirmation. +- For live Calc edits that the user should see immediately, prefer `desktopctl.sh calc-set-cell FILE SHEET CELL VALUE`. +- For enterprise workflows, leave printing available. diff --git a/plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py b/plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py new file mode 100644 index 0000000000000000000000000000000000000000..852588bbc0f625679bdb1fd79b46e79bb4fd0e5b --- /dev/null +++ b/plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Set a Calc cell through the visible LibreOffice desktop session.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import time +import zipfile +from pathlib import Path +import xml.etree.ElementTree as ET + +for candidate in ("/usr/lib/python3/dist-packages", "/usr/lib/libreoffice/program"): + if candidate not in sys.path: + sys.path.insert(0, candidate) + +try: + import uno +except Exception as exc: # pragma: no cover - exercised in the container runtime. + raise SystemExit(f"LibreOffice UNO is not available: {exc}") from exc + + +UNO_URL = "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext" +OOXML_MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +OOXML_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +REL_PACKAGE_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +NS = {"m": OOXML_MAIN_NS, "r": OOXML_REL_NS, "rel": REL_PACKAGE_NS} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Open an XLSX in visible LibreOffice Calc, edit one cell, save, and verify." + ) + parser.add_argument("file") + parser.add_argument("sheet") + parser.add_argument("cell") + parser.add_argument("value", nargs=argparse.REMAINDER) + args = parser.parse_args() + if not args.value: + parser.error("value is required") + args.value = " ".join(args.value) + return args + + +def run_quiet(command: list[str], *, timeout: float | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=timeout, + ) + + +def dismiss_document_in_use_dialogs() -> None: + xdotool = shutil.which("xdotool") + if not xdotool: + return + found = subprocess.run( + [xdotool, "search", "--onlyvisible", "--name", "Document in Use"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + for window_id in found.stdout.splitlines(): + if window_id.strip(): + run_quiet([xdotool, "windowclose", window_id.strip()], timeout=2) + + +def connect_once(): + local_ctx = uno.getComponentContext() + resolver = local_ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.bridge.UnoUrlResolver", + local_ctx, + ) + return resolver.resolve(os.environ.get("A0_DESKTOP_UNO_URL", UNO_URL)) + + +def desktop_from_context(ctx): + return ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) + + +def component_url(component) -> str: + try: + return getattr(component, "URL", "") or "" + except Exception: + return "" + + +def component_title(component) -> str: + try: + return getattr(component, "Title", "") or "" + except Exception: + return "" + + +def component_has_sheets(component) -> bool: + try: + component.getSheets() + return True + except Exception: + return False + + +def url_to_path(url: str) -> str: + if not url: + return "" + try: + return os.path.realpath(uno.fileUrlToSystemPath(url)) + except Exception: + return "" + + +def find_open_document(desktop, path: Path): + target_path = os.path.realpath(str(path)) + target_name = path.name + try: + enum = desktop.getComponents().createEnumeration() + except Exception: + return None + while enum.hasMoreElements(): + component = enum.nextElement() + if not component_has_sheets(component): + continue + if url_to_path(component_url(component)) == target_path: + return component + if target_name and target_name in component_title(component): + return component + return None + + +def libreoffice_command(path: Path) -> list[str]: + soffice = os.environ.get("SOFFICE") or shutil.which("soffice") or "soffice" + home = os.environ.get("HOME", "") + user_installation = f"-env:UserInstallation=file://{home}" if home else "" + command = [ + soffice, + "--norestore", + "--nofirststartwizard", + "--nolockcheck", + "--accept=socket,host=localhost,port=2002;urp;StarOffice.ComponentContext", + "--calc", + str(path), + ] + if user_installation: + command.insert(4, user_installation) + return command + + +def launch_visible_calc(path: Path) -> None: + subprocess.Popen( + libreoffice_command(path), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +def wait_for_document(path: Path, timeout_seconds: float): + started = False + last_error: Exception | None = None + deadline = time.time() + timeout_seconds + while time.time() < deadline: + dismiss_document_in_use_dialogs() + try: + ctx = connect_once() + desktop = desktop_from_context(ctx) + document = find_open_document(desktop, path) + if document is not None: + return document + if not started: + launch_visible_calc(path) + started = True + except Exception as exc: + last_error = exc + if not started: + launch_visible_calc(path) + started = True + time.sleep(0.5) + detail = f": {last_error!r}" if last_error else "" + raise RuntimeError(f"Timed out waiting for LibreOffice Calc to open {path}{detail}") + + +def focus_document_window(document) -> None: + try: + document.CurrentController.Frame.ContainerWindow.setFocus() + except Exception: + pass + + +def sheet_path_for_name(zip_file: zipfile.ZipFile, sheet_name: str) -> str: + workbook = ET.fromstring(zip_file.read("xl/workbook.xml")) + rel_id = None + sheets = workbook.find("m:sheets", NS) + if sheets is None: + raise RuntimeError("Workbook has no sheets collection") + for sheet in sheets: + if sheet.attrib.get("name") == sheet_name: + rel_id = sheet.attrib.get(f"{{{OOXML_REL_NS}}}id") + break + if not rel_id: + raise RuntimeError(f"Sheet not found on disk: {sheet_name}") + rels = ET.fromstring(zip_file.read("xl/_rels/workbook.xml.rels")) + target = None + for rel in rels: + if rel.attrib.get("Id") == rel_id: + target = rel.attrib.get("Target") + break + if not target: + raise RuntimeError(f"Sheet relationship not found on disk: {sheet_name}") + target = target.lstrip("/") + if target.startswith("xl/"): + return target + return f"xl/{target}" + + +def shared_strings(zip_file: zipfile.ZipFile) -> list[str]: + if "xl/sharedStrings.xml" not in zip_file.namelist(): + return [] + root = ET.fromstring(zip_file.read("xl/sharedStrings.xml")) + values: list[str] = [] + for item in root.findall("m:si", NS): + parts = [ + text_node.text or "" + for text_node in item.iter(f"{{{OOXML_MAIN_NS}}}t") + ] + values.append("".join(parts)) + return values + + +def read_xlsx_cell(path: Path, sheet_name: str, cell_name: str) -> str | None: + with zipfile.ZipFile(path) as zip_file: + sheet_path = sheet_path_for_name(zip_file, sheet_name) + sheet = ET.fromstring(zip_file.read(sheet_path)) + strings = shared_strings(zip_file) + for cell in sheet.iter(f"{{{OOXML_MAIN_NS}}}c"): + if cell.attrib.get("r") != cell_name: + continue + cell_type = cell.attrib.get("t") + if cell_type == "inlineStr": + return "".join( + text_node.text or "" + for text_node in cell.iter(f"{{{OOXML_MAIN_NS}}}t") + ) + value_node = cell.find("m:v", NS) + raw_value = "" if value_node is None or value_node.text is None else value_node.text + if cell_type == "s" and raw_value != "": + return strings[int(raw_value)] + return raw_value + return None + + +def wait_for_disk_value(path: Path, sheet: str, cell: str, expected: str) -> str | None: + latest = None + for _ in range(24): + latest = read_xlsx_cell(path, sheet, cell) + if latest == expected: + return latest + time.sleep(0.25) + return latest + + +def is_disposed_error(exc: BaseException) -> bool: + return "DisposedException" in type(exc).__name__ or "DisposedException" in str(exc) + + +def edit_visible_calc_cell(path: Path, sheet_name: str, cell_name: str, value: str) -> tuple[str, str, str | None]: + last_error: Exception | None = None + for _ in range(4): + document = wait_for_document( + path, + timeout_seconds=float(os.environ.get("A0_CALC_OPEN_TIMEOUT", "30")), + ) + try: + focus_document_window(document) + sheets = document.getSheets() + if not sheets.hasByName(sheet_name): + raise SystemExit(f"Sheet not found: {sheet_name}") + sheet = sheets.getByName(sheet_name) + target_cell = sheet.getCellRangeByName(cell_name) + before = target_cell.getString() + target_cell.setString(value) + document.store() + after = target_cell.getString() + disk_value = wait_for_disk_value(path, sheet_name, cell_name, value) + return before, after, disk_value + except Exception as exc: + if not is_disposed_error(exc): + raise + last_error = exc + time.sleep(1) + raise RuntimeError(f"LibreOffice UNO bridge stayed disposed while editing {path}: {last_error!r}") + + +def main() -> int: + args = parse_args() + path = Path(args.file).expanduser().resolve() + if not path.exists(): + raise SystemExit(f"Workbook not found: {path}") + + before, after, disk_value = edit_visible_calc_cell(path, args.sheet, args.cell, args.value) + if after != args.value or disk_value != args.value: + raise SystemExit( + f"Calc edit verification failed: before={before!r} after={after!r} disk={disk_value!r}" + ) + print(f"updated {path} {args.sheet}!{args.cell}: {before!r} -> {after!r}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh b/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh new file mode 100644 index 0000000000000000000000000000000000000000..555ae0d0c8cb3e2ad934d6907e4f21a82e31d60d --- /dev/null +++ b/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh @@ -0,0 +1,459 @@ +#!/usr/bin/env bash +set -euo pipefail + +SESSION="${A0_DESKTOP_SESSION:-agent-zero-desktop}" +BASE_DIR="${A0_BASE_DIR:-/a0}" +PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/plugins/_desktop/profiles/$SESSION}" +MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/plugins/_desktop/sessions/$SESSION.json}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_STATE_HELPER="$SCRIPT_DIR/../../../helpers/desktop_state.py" +DESKTOP_STATE_PYTHON="${A0_DESKTOP_STATE_PYTHON:-$(command -v /usr/bin/python3 || command -v python3 || true)}" + +display_from_manifest() { + if [ ! -f "$MANIFEST" ] || ! command -v python3 >/dev/null 2>&1; then + return 0 + fi + python3 - "$MANIFEST" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], "r", encoding="utf-8") as handle: + value = json.load(handle).get("display", "") +except Exception: + value = "" +if value != "": + print(value) +PY +} + +DISPLAY_VALUE="${A0_DESKTOP_DISPLAY:-$(display_from_manifest || true)}" +DISPLAY_VALUE="${DISPLAY_VALUE:-120}" +case "$DISPLAY_VALUE" in + :*) export DISPLAY="$DISPLAY_VALUE" ;; + *) export DISPLAY=":$DISPLAY_VALUE" ;; +esac + +export XAUTHORITY="${A0_DESKTOP_XAUTHORITY:-$PROFILE_DIR/.Xauthority}" +export HOME="${A0_DESKTOP_HOME:-$PROFILE_DIR}" +export A0_DESKTOP_SESSION="$SESSION" +export A0_DESKTOP_MANIFEST="$MANIFEST" +export A0_DESKTOP_PROFILE="$PROFILE_DIR" +export A0_DESKTOP_DISPLAY="$DISPLAY" +export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}" + +command_name="${1:-help}" +shift || true + +usage() { + cat <<'EOF' +Usage: desktopctl.sh [args] + +Commands: + env Print the X11 environment used for the Desktop. + check Verify that xdotool can reach the Desktop display. + state --json [--screenshot] [--context-id ID] + Return structured Desktop state as JSON. + observe --json [--screenshot] [--context-id ID] + Return structured state, optionally with a fresh screenshot. + screenshot [PATH] [--context-id ID] + Capture the Desktop to PATH, or to the chat screenshot directory when context-id is set. + active-window Print the active window name. + geometry PATTERN Print the first matching visible window geometry. + wait-window PATTERN Wait for a visible matching window and print its id. + location Print the current X pointer location. + windows [PATTERN] List visible window names matching PATTERN. + focus PATTERN Focus the first visible window matching PATTERN. + scroll DIRECTION [UNITS] Scroll up, down, left, or right; UNITS defaults to 5 clicks. + drag X1 Y1 X2 Y2 Drag from X1,Y1 to X2,Y2 in Desktop coordinates. + right-click X Y Move and right-click at X,Y in Desktop coordinates. + paste-text TEXT Put TEXT on the Desktop clipboard and paste it with an app-native shortcut. + sequence FILE|- Run a newline-delimited command sequence in this process. + batch FILE|- Alias for sequence. + key KEY... Send one or more xdotool key names. + type TEXT Type text into the focused window. + click X Y Move and click at X,Y in Desktop coordinates. + dblclick X Y Move and double-click at X,Y in Desktop coordinates. + launch APP Launch writer, calc, impress, terminal, settings, or workdir. + open-path [PATH] Open PATH in Thunar, defaulting to /a0/usr/workdir. + calc-set-cell FILE SHEET CELL VALUE + Open FILE in visible Calc, set SHEET!CELL, save, and verify. + save Send Ctrl+S to the focused app. +EOF +} + +require_xdotool() { + if ! command -v xdotool >/dev/null 2>&1; then + echo "xdotool is required for Desktop control." >&2 + exit 2 + fi +} + +ensure_display() { + require_xdotool + if ! xdotool getmouselocation >/dev/null 2>&1; then + echo "Desktop X display is not reachable. Open the Desktop surface first." >&2 + exit 2 + fi +} + +desktop_state() { + if [ ! -f "$DESKTOP_STATE_HELPER" ]; then + echo "Desktop state helper not found: $DESKTOP_STATE_HELPER" >&2 + exit 2 + fi + "$DESKTOP_STATE_PYTHON" "$DESKTOP_STATE_HELPER" "$@" +} + +run_detached() { + ( "$@" >/tmp/a0-desktopctl.log 2>&1 & ) +} + +close_blocking_dialogs() { + require_xdotool + for title in "Document in Use"; do + window_ids="$(xdotool search --onlyvisible --name "$title" 2>/dev/null || true)" + printf '%s\n' "$window_ids" | while read -r window_id; do + [ -n "$window_id" ] && xdotool windowclose "$window_id" >/dev/null 2>&1 || true + done + done +} + +first_window() { + pattern="$1" + xdotool search --onlyvisible --name "$pattern" 2>/dev/null | head -n 1 || true +} + +active_window_id() { + xdotool getactivewindow 2>/dev/null || true +} + +active_window_class() { + window_id="$(active_window_id)" + if [ -z "$window_id" ]; then + return 0 + fi + if command -v xprop >/dev/null 2>&1; then + xprop -id "$window_id" WM_CLASS 2>/dev/null | awk -F'"' '/WM_CLASS/ { print $(NF - 1); exit }' + fi +} + +active_window_class_lower() { + active_window_class | tr '[:upper:]' '[:lower:]' +} + +active_window_is_terminal() { + window_class="$(active_window_class_lower)" + case "$window_class" in + *terminal*|xterm|uxterm|rxvt|urxvt|kitty|alacritty|wezterm|konsole) + return 0 + ;; + *) + return 1 + ;; + esac +} + +paste_key_for_active_window() { + printf '%s\n' "${A0_DESKTOP_PASTE_KEY:-ctrl+v}" +} + +window_geometry() { + window_id="$1" + if command -v xwininfo >/dev/null 2>&1; then + xwininfo -id "$window_id" 2>/dev/null | awk ' + /Absolute upper-left X:/ { x=$4 } + /Absolute upper-left Y:/ { y=$4 } + /Width:/ { w=$2 } + /Height:/ { h=$2 } + END { if (w != "") printf "X=%s\nY=%s\nWIDTH=%s\nHEIGHT=%s\n", x, y, w, h } + ' + else + xdotool getwindowgeometry --shell "$window_id" + fi +} + +wait_window() { + pattern="$1" + timeout="${2:-15}" + end=$((SECONDS + timeout)) + while [ "$SECONDS" -le "$end" ]; do + window_id="$(first_window "$pattern")" + if [ -n "$window_id" ]; then + printf '%s\n' "$window_id" + return 0 + fi + sleep 0.25 + done + echo "Timed out waiting for visible window: $pattern" >&2 + return 1 +} + +scroll_desktop() { + direction="$1" + units="${2:-5}" + case "$direction" in + up) button=4 ;; + down) button=5 ;; + left) button=6 ;; + right) button=7 ;; + *) + echo "scroll direction must be up, down, left, or right." >&2 + exit 2 + ;; + esac + xdotool click --repeat "$units" "$button" +} + +paste_text() { + text="$*" + if active_window_is_terminal; then + xdotool type --delay "${A0_DESKTOP_PASTE_TYPE_DELAY_MS:-${A0_DESKTOP_TYPE_DELAY_MS:-4}}" -- "$text" + return + fi + if command -v xclip >/dev/null 2>&1; then + printf '%s' "$text" | xclip -selection clipboard + xdotool key --clearmodifiers "$(paste_key_for_active_window)" + return + fi + xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text" +} + +run_sequence_line() { + line="$1" + [ -z "$line" ] && return 0 + case "$line" in + \#*) return 0 ;; + esac + # shellcheck disable=SC2086 + dispatch_command $line +} + +run_sequence() { + source_file="$1" + if [ "$source_file" = "-" ]; then + while IFS= read -r line; do + run_sequence_line "$line" + done + return + fi + if [ ! -f "$source_file" ]; then + echo "sequence requires an existing FILE or - for stdin." >&2 + exit 2 + fi + while IFS= read -r line || [ -n "$line" ]; do + run_sequence_line "$line" + done < "$source_file" +} + +launch_app() { + app="${1:-}" + soffice="${SOFFICE:-$(command -v soffice || true)}" + soffice="${soffice:-soffice}" + case "$app" in + writer) + run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --writer + ;; + calc|spreadsheet) + run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --calc + ;; + impress|presentation) + run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --impress + ;; + terminal) + run_detached xfce4-terminal --working-directory=/a0/usr/workdir + ;; + settings) + run_detached xfce4-settings-manager + ;; + workdir|files|file-manager) + run_detached thunar /a0/usr/workdir + ;; + *) + echo "Unknown app: ${app:-}" >&2 + echo "Expected: writer, calc, impress, terminal, settings, or workdir." >&2 + exit 2 + ;; + esac +} + +dispatch_command() { + local command_name="${1:-help}" + shift || true + + case "$command_name" in + help|-h|--help) + usage + ;; + env) + printf 'export DISPLAY=%q\n' "$DISPLAY" + printf 'export XAUTHORITY=%q\n' "$XAUTHORITY" + printf 'export HOME=%q\n' "$HOME" + ;; + check) + ensure_display + xdotool getmouselocation --shell + ;; + state) + if [ "${1:-}" != "--json" ]; then + echo "state currently requires --json." >&2 + exit 2 + fi + shift + desktop_state state --json "$@" + ;; + observe) + if [ "${1:-}" != "--json" ]; then + echo "observe currently requires --json." >&2 + exit 2 + fi + shift + desktop_state observe --json "$@" + ;; + screenshot) + if [ "${1:-}" = "--json" ]; then + shift + desktop_state screenshot --json "$@" + elif [ "$#" -gt 0 ]; then + desktop_state screenshot "$@" + else + desktop_state screenshot + fi + ;; + active-window) + ensure_display + window_id="$(active_window_id)" + if [ -z "$window_id" ]; then + echo "No active window." >&2 + exit 1 + fi + xdotool getwindowname "$window_id" + ;; + geometry) + ensure_display + pattern="${1:?geometry requires a window name pattern}" + window_id="$(first_window "$pattern")" + if [ -z "$window_id" ]; then + echo "No visible window matched: $pattern" >&2 + exit 1 + fi + window_geometry "$window_id" + ;; + wait-window) + ensure_display + pattern="${1:?wait-window requires a window name pattern}" + wait_window "$pattern" "${2:-15}" + ;; + location) + ensure_display + xdotool getmouselocation --shell + ;; + windows) + ensure_display + pattern="${1:-.}" + xdotool search --onlyvisible --name "$pattern" getwindowname %@ 2>/dev/null || true + ;; + focus) + ensure_display + pattern="${1:?focus requires a window name pattern}" + window_id="$(first_window "$pattern")" + if [ -z "$window_id" ]; then + echo "No visible window matched: $pattern" >&2 + exit 1 + fi + xdotool windowactivate --sync "$window_id" + ;; + scroll) + ensure_display + scroll_desktop "${1:?scroll requires DIRECTION}" "${2:-5}" + ;; + drag) + ensure_display + x1="${1:?drag requires X1}" + y1="${2:?drag requires Y1}" + x2="${3:?drag requires X2}" + y2="${4:?drag requires Y2}" + xdotool mousemove --sync "$x1" "$y1" mousedown 1 mousemove --sync "$x2" "$y2" mouseup 1 + ;; + right-click) + ensure_display + x="${1:?right-click requires X}" + y="${2:?right-click requires Y}" + xdotool mousemove --sync "$x" "$y" click 3 + ;; + paste-text) + ensure_display + if [ "$#" -eq 0 ]; then + echo "paste-text requires TEXT." >&2 + exit 2 + fi + paste_text "$@" + ;; + sequence|batch) + source_file="${1:?sequence requires FILE or -}" + run_sequence "$source_file" + ;; + key) + ensure_display + if [ "$#" -eq 0 ]; then + echo "key requires at least one xdotool key name." >&2 + exit 2 + fi + xdotool key --clearmodifiers "$@" + ;; + type) + ensure_display + text="$*" + xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text" + ;; + click) + ensure_display + x="${1:?click requires X}" + y="${2:?click requires Y}" + xdotool mousemove --sync "$x" "$y" click 1 + ;; + dblclick) + ensure_display + x="${1:?dblclick requires X}" + y="${2:?dblclick requires Y}" + xdotool mousemove --sync "$x" "$y" click --repeat 2 --delay "${A0_DESKTOP_DBLCLICK_DELAY_MS:-150}" 1 + ;; + launch) + ensure_display + launch_app "${1:-}" + ;; + open-path) + ensure_display + path="${1:-/a0/usr/workdir}" + run_detached thunar "$path" + ;; + calc-set-cell) + ensure_display + file="${1:?calc-set-cell requires FILE}" + sheet="${2:?calc-set-cell requires SHEET}" + cell="${3:?calc-set-cell requires CELL}" + shift 3 + if [ "$#" -eq 0 ]; then + echo "calc-set-cell requires VALUE." >&2 + exit 2 + fi + close_blocking_dialogs + export PYTHONPATH="${PYTHONPATH:-/usr/lib/python3/dist-packages:/usr/lib/libreoffice/program:}" + python3 "$SCRIPT_DIR/calc_set_cell.py" "$file" "$sheet" "$cell" "$@" + ;; + save) + ensure_display + xdotool key --clearmodifiers ctrl+s + ;; + *) + echo "Unknown command: $command_name" >&2 + usage >&2 + exit 2 + ;; + esac +} + +dispatch_command "$command_name" "$@" diff --git a/plugins/_desktop/webui/desktop-panel.html b/plugins/_desktop/webui/desktop-panel.html new file mode 100644 index 0000000000000000000000000000000000000000..6e1f55de97c7fd0e8089777c81248ba396c22d5e --- /dev/null +++ b/plugins/_desktop/webui/desktop-panel.html @@ -0,0 +1,734 @@ + + + + + +
+ +
+ + + + diff --git a/plugins/_desktop/webui/desktop-store.js b/plugins/_desktop/webui/desktop-store.js new file mode 100644 index 0000000000000000000000000000000000000000..ed0446de9dd33ec738aa3039a7f8aeba5e477e51 --- /dev/null +++ b/plugins/_desktop/webui/desktop-store.js @@ -0,0 +1,2729 @@ +import { createStore } from "/js/AlpineStore.js"; +import { callJsonApi } from "/js/api.js"; +import { getNamespacedClient } from "/js/websocket.js"; +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; +import { handleUrlIntent, placeSurfaceModalHeaderAction } from "/js/surfaces.js"; + +const officeSocket = getNamespacedClient("/ws"); +officeSocket.addHandlers(["ws_webui"]); + +const SAVE_MESSAGE_MS = 1800; +const INPUT_PUSH_DELAY_MS = 650; +const DESKTOP_HEARTBEAT_MS = 3500; +const DESKTOP_RESIZE_DELAY_MS = 80; +const DESKTOP_START_MESSAGE = "Starting Agent Zero Desktop environment"; +const DESKTOP_RUNTIME_INSTALL_MESSAGE = "Installing Agent Zero Desktop runtime dependencies. This can take a few minutes after an update."; +const DESKTOP_RUNTIME_INSTALL_POLL_MS = 4000; +const DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS = 10 * 60 * 1000; +const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220; +const XPRA_DESKTOP_PRIME_ATTEMPTS = 120; +const SYSTEM_DESKTOP_FILE_ID = "system-desktop"; +const URL_INTENT_PANEL_TIMEOUT_MS = 5000; +const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.desktop.shutdown"; +const MAX_HISTORY = 80; + +function currentContextId() { + try { + return globalThis.getContext?.() || ""; + } catch { + return ""; + } +} + +function basename(path = "") { + const value = String(path || "").split("?")[0].split("#")[0]; + return value.split("/").filter(Boolean).pop() || "Untitled"; +} + +function extensionOf(path = "") { + const name = basename(path).toLowerCase(); + const index = name.lastIndexOf("."); + return index >= 0 ? name.slice(index + 1) : ""; +} + +function isOfficialExtension(extension = "") { + return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(String(extension || "").toLowerCase()); +} + +function parentPath(path = "") { + const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index <= 0) return "/"; + return normalized.slice(0, index); +} + +function uniqueTabId(session = {}) { + return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`); +} + +function editorContainsFocus(element) { + const active = document.activeElement; + return Boolean(element && active && (element === active || element.contains(active))); +} + +function isEditableInputTarget(target) { + const element = target?.nodeType === 1 ? target : target?.parentElement; + const editable = element?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox']"); + if (!editable) return false; + if (editable.tagName !== "INPUT") return true; + const type = String(editable.getAttribute("type") || "text").toLowerCase(); + return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type); +} + +function normalizeModalPath(path = "") { + return String(path || "").replace(/^\/+/, ""); +} + +function isModalPathOpen(path = "") { + const normalized = normalizeModalPath(path); + return Boolean( + globalThis.isModalOpen?.(path) + || globalThis.isModalOpen?.(`/${normalized}`) + || globalThis.isModalOpen?.(normalized) + ); +} + +function waitForElementByPredicate(predicate, timeoutMs = URL_INTENT_PANEL_TIMEOUT_MS) { + const found = predicate(); + if (found) return Promise.resolve(found); + return new Promise((resolve) => { + const timeout = globalThis.setTimeout(() => { + observer.disconnect(); + resolve(predicate()); + }, timeoutMs); + const observer = new MutationObserver(() => { + const element = predicate(); + if (!element) return; + globalThis.clearTimeout(timeout); + observer.disconnect(); + resolve(element); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); +} + +function browserPanelForMode(mode = "modal") { + const panels = Array.from(document.querySelectorAll(".browser-panel")); + if (mode === "canvas") { + return panels.find((panel) => panel.closest?.('[data-surface-id="browser"]')) || null; + } + return panels.find((panel) => panel.closest?.(".modal")) || null; +} + +function placeCaretAtEnd(element) { + if (!element) return; + if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") { + const length = element.value?.length || 0; + element.selectionStart = length; + element.selectionEnd = length; + return; + } + const selection = globalThis.getSelection?.(); + const range = document.createRange?.(); + if (!selection || !range) return; + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +} + +function sleep(ms) { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +} + +function normalizeDocument(doc = {}) { + const path = doc.path || ""; + const extension = String(doc.extension || extensionOf(path)).toLowerCase(); + return { + ...doc, + extension, + title: doc.title || doc.basename || basename(path), + basename: doc.basename || basename(path), + path, + }; +} + +function normalizeSession(payload = {}) { + const document = normalizeDocument(payload.document || payload); + const extension = String(payload.extension || document.extension || "").toLowerCase(); + return { + ...payload, + document, + extension, + file_id: payload.file_id || document.file_id || "", + path: document.path || payload.path || "", + title: payload.title || document.title || document.basename || basename(document.path), + tab_id: uniqueTabId(payload), + text: String(payload.text || ""), + desktop: payload.desktop || null, + desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "", + dirty: false, + }; +} + +async function callOffice(action, payload = {}) { + return await callJsonApi("/plugins/_office/office_session", { + action, + ctxid: currentContextId(), + ...payload, + }); +} + +async function callDesktop(action, payload = {}) { + return await callJsonApi("/plugins/_desktop/desktop_session", { + action, + ctxid: currentContextId(), + ...payload, + }); +} + +async function requestOffice(eventType, payload = {}, timeoutMs = 5000) { + const response = await officeSocket.request(eventType, { + ctxid: currentContextId(), + ...payload, + }, { timeoutMs }); + const results = Array.isArray(response?.results) ? response.results : []; + const first = results.find((item) => item?.ok === true && isOfficeSocketData(item?.data)) + || results.find((item) => item?.ok === true); + if (!first) { + const error = results.find((item) => item?.error)?.error; + throw new Error(error?.error || error?.code || `${eventType} failed`); + } + if (first.data?.office_error) { + const error = first.data.office_error; + throw new Error(error.error || error.code || `${eventType} failed`); + } + return first.data || {}; +} + +function isOfficeSocketData(data) { + if (!data || typeof data !== "object") return false; + return ( + Object.prototype.hasOwnProperty.call(data, "office_error") + || Object.prototype.hasOwnProperty.call(data, "ok") + || Object.prototype.hasOwnProperty.call(data, "session_id") + || Object.prototype.hasOwnProperty.call(data, "document") + || Object.prototype.hasOwnProperty.call(data, "desktop") + || Object.prototype.hasOwnProperty.call(data, "closed") + ); +} + +const model = { + status: null, + tabs: [], + activeTabId: "", + session: null, + loading: false, + saving: false, + dirty: false, + error: "", + message: "", + editorText: "", + _root: null, + _mode: "canvas", + _saveMessageTimer: null, + _inputTimer: null, + _history: [], + _historyIndex: -1, + _pendingFocus: false, + _pendingFocusEnd: true, + _focusAttempts: 0, + _floatingCleanup: null, + _desktopHeartbeatTimer: null, + _desktopHeartbeatSessionId: "", + _desktopHeartbeatTabId: "", + _desktopHeartbeatMisses: 0, + _desktopResizeCleanup: null, + _desktopResizeTarget: null, + _desktopResizeTimer: null, + _desktopResizeKey: "", + _desktopResizePendingKey: "", + _desktopResizeSuspended: false, + _desktopResizePending: false, + _desktopViewportSyncTimers: [], + _desktopHostVisible: false, + _desktopPrimeTimer: null, + _desktopPrimeAttempts: 0, + _desktopKeyboardActive: false, + _desktopFocusInProgress: false, + _desktopBridgeReady: false, + _desktopKeyboardCaptureState: { ready: false, active: false, capture: false, focused: false }, + _desktopLastState: null, + _desktopKeyboardCleanup: null, + _desktopClipboardCleanup: null, + _desktopStarting: null, + _desktopUrlIntentBusy: false, + _desktopUrlIntentQueue: [], + _desktopFrame: null, + _desktopFrameHost: null, + _desktopFrameLoadHandler: null, + _desktopKeepaliveHost: null, + _desktopDisplaySizes: {}, + _desktopIntentionalShutdown: false, + + async init(element = null) { + this.restoreDesktopShutdownState(); + return await this.onMount(element, { mode: "canvas" }); + }, + + async onMount(element = null, options = {}) { + if (element) this._root = element; + this._mode = options?.mode === "modal" ? "modal" : "canvas"; + if (this._mode === "modal") { + this._desktopHostVisible = true; + this.setupFloatingModal(element); + await this.onOpen({ source: "modal" }); + return; + } + this.queueRender(); + }, + + async onOpen(payload = {}) { + this.restoreDesktopShutdownState(); + await this.refresh(); + if (payload?.path || payload?.file_id) { + await this.openSession({ + path: payload.path || "", + file_id: payload.file_id || "", + refresh: payload.refresh === true, + source: payload.source || "", + }); + } else if (this._desktopIntentionalShutdown) { + this.session = null; + this.activeTabId = ""; + this.editorText = ""; + this.dirty = false; + } else { + await this.ensureDesktopSession({ select: !this.session }); + } + this.restoreDesktopFrames(); + this.requestDesktopViewportSync({ force: true }); + }, + + beforeHostHidden(options = {}) { + this._desktopHostVisible = false; + this.flushInput(); + this.clearDesktopViewportSyncTimers(); + this.stopDesktopMonitor(); + this.stopDesktopKeyboardBridge(); + this.stopDesktopClipboardBridge(); + this.unloadDesktopFrames(); + }, + + cleanup() { + const wasModal = this._mode === "modal"; + this.flushInput(); + this.stopDesktopMonitor(); + this.stopDesktopResizeObserver(); + this.clearDesktopViewportSyncTimers(); + this.stopXpraDesktopPrime(); + this.stopDesktopKeyboardBridge(); + this.stopDesktopClipboardBridge(); + if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive(); + this._floatingCleanup?.(); + this._floatingCleanup = null; + if (wasModal) { + this._root = null; + this._mode = "canvas"; + this._desktopHostVisible = false; + } + }, + + async refresh() { + try { + const status = await callDesktop("status"); + this.status = status || {}; + this.error = ""; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } + }, + + restoreDesktopShutdownState() { + try { + this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1"; + } catch { + this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown); + } + }, + + persistDesktopShutdownState() { + try { + if (this._desktopIntentionalShutdown) { + localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1"); + } else { + localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY); + } + } catch { + // Shutdown state is still correct for this page even without storage. + } + }, + + setDesktopIntentionalShutdown(value) { + this._desktopIntentionalShutdown = Boolean(value); + this.persistDesktopShutdownState(); + }, + + isDesktopShutdown() { + return Boolean(this._desktopIntentionalShutdown); + }, + + shouldShowDesktopEmptyState() { + return Boolean(this._desktopIntentionalShutdown && !this.session); + }, + + async restartDesktopSession() { + this.error = ""; + const session = await this.ensureDesktopSession({ + force: true, + restart: true, + select: true, + message: "Restarting Agent Zero Desktop environment", + }); + if (!session) { + this.setDesktopIntentionalShutdown(true); + return null; + } + this.restoreDesktopFrames(); + this.requestDesktopViewportSync({ force: true }); + return session; + }, + + async shutdownDesktop(options = {}) { + this.loading = options.progress !== false; + this.message = this.loading ? "Shutting down Desktop" : this.message; + this.error = ""; + try { + const response = await callDesktop("shutdown", { + save_first: options.saveFirst !== false, + source: options.source || "ui", + }); + await this.handleIntentionalDesktopShutdown(response); + return response; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + return null; + } finally { + if (options.progress !== false) { + this.loading = false; + if (this.message === "Shutting down Desktop") this.message = ""; + } + } + }, + + async handleIntentionalDesktopShutdown(response = {}) { + this.setDesktopIntentionalShutdown(true); + this.stopDesktopMonitor(); + this.stopDesktopResizeObserver(); + this.clearDesktopViewportSyncTimers(); + this.stopXpraDesktopPrime(); + this.stopDesktopKeyboardBridge(); + this.stopDesktopClipboardBridge(); + this.destroyDesktopFrame(); + const activeTabId = this.activeTabId; + this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab)); + if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) { + this.session = null; + this.activeTabId = ""; + this.editorText = ""; + this.dirty = false; + this.resetHistory(""); + } + this._desktopStarting = null; + this._desktopHeartbeatMisses = 0; + this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down"; + await this.refresh(); + }, + + async ensureDesktopSession(options = {}) { + if (this._desktopIntentionalShutdown && options.restart !== true) { + return null; + } + if (options.restart === true) { + this.setDesktopIntentionalShutdown(false); + this.destroyDesktopFrame(); + } + const existing = this.tabs.find((tab) => this.isDesktopSession(tab)); + if (existing && !options.force) { + if (options.select) this.selectTab(existing.tab_id, { focus: false }); + this.updateDesktopMonitor(); + return existing; + } + const showProgress = options.progress !== false; + const progressMessage = String(options.message || DESKTOP_START_MESSAGE); + if (this._desktopStarting) { + if (showProgress) { + this.loading = true; + this.message = progressMessage; + } + return await this._desktopStarting; + } + + this._desktopStarting = (async () => { + try { + if (showProgress) { + this.loading = true; + this.message = progressMessage; + this.error = ""; + } + const response = await this.openDesktopWhenRuntimeReady(showProgress); + if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened."); + this.setDesktopIntentionalShutdown(false); + const session = normalizeSession(response); + const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab)); + let desktopTabId = session.tab_id; + if (existingIndex >= 0) { + desktopTabId = this.tabs[existingIndex].tab_id; + this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId }); + } else { + this.tabs.unshift(session); + } + this.tabs = this.tabs.map((tab) => ( + this.hasOfficialOffice(tab) + ? { + ...tab, + desktop: session.desktop, + desktop_session_id: session.desktop_session_id, + session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id, + } + : tab + )); + if (options.select || !this.session) { + this.selectTab(desktopTabId, { focus: false }); + } else { + this.updateDesktopMonitor(); + } + this.restoreDesktopFrames(); + return { ...session, tab_id: desktopTabId }; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + return null; + } finally { + if (showProgress) { + this.loading = false; + if (this.message === progressMessage || this.message === DESKTOP_RUNTIME_INSTALL_MESSAGE) this.message = ""; + } + this._desktopStarting = null; + } + })(); + return await this._desktopStarting; + }, + + async openDesktopWhenRuntimeReady(showProgress = true) { + const startedAt = Date.now(); + let response = await callDesktop("desktop"); + while (response?.ok === false && this.isDesktopRuntimeInstalling(response)) { + if (showProgress) { + this.loading = true; + this.error = ""; + this.message = this.desktopRuntimeInstallMessage(response); + } + if (Date.now() - startedAt > DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS) { + return { + ...response, + error: "Agent Zero Desktop runtime installation is still running. Please try again in a moment.", + }; + } + await sleep(DESKTOP_RUNTIME_INSTALL_POLL_MS); + response = await callDesktop("desktop"); + } + return response; + }, + + isDesktopRuntimeInstalling(response = {}) { + const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {}; + return Boolean(status.installing || status.state === "installing" || status.preparation?.preparing); + }, + + desktopRuntimeInstallMessage(response = {}) { + const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {}; + return String(status.message || DESKTOP_RUNTIME_INSTALL_MESSAGE); + }, + + async create(kind = "document", format = "") { + const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "odt")).toLowerCase(); + const title = this.defaultTitle(kind, fmt); + this.loading = true; + this.error = ""; + try { + const response = await callOffice("create", { + kind, + format: fmt, + title, + open_in_desktop: isOfficialExtension(fmt), + }); + if (response?.ok === false) { + this.error = response.error || "Document could not be created."; + return null; + } + const session = normalizeSession(response); + this.installSession(session); + await this.refresh(); + return session; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + return null; + } finally { + this.loading = false; + } + }, + + async openFileBrowser() { + let workdirPath = "/a0/usr/workdir"; + try { + const response = await callJsonApi("settings_get", null); + workdirPath = response?.settings?.workdir_path || workdirPath; + } catch { + try { + const home = await callOffice("home"); + workdirPath = home?.path || workdirPath; + } catch { + // The file browser can still open with the static fallback. + } + } + await fileBrowserStore.open(workdirPath); + }, + + async openPath(path) { + return await this.openSession({ path: String(path || "") }); + }, + + async openSession(payload = {}) { + this.loading = true; + this.error = ""; + try { + const response = await callDesktop("open_document", payload); + if (response?.ok === false) { + this.error = response.error || "Document could not be opened."; + return null; + } + const session = normalizeSession(response); + this.installSession(session); + await this.refresh(); + return session; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + return null; + } finally { + this.loading = false; + } + }, + + installSession(session) { + if (this.isDesktopOfficeDocument(session)) { + this.installDesktopDocumentSession(session); + return; + } + const existingIndex = this.tabs.findIndex((tab) => ( + (session.file_id && tab.file_id === session.file_id) + || (session.path && tab.path === session.path) + )); + if (existingIndex >= 0) { + this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id }); + this.activeTabId = this.tabs[existingIndex].tab_id; + } else { + this.tabs.push(session); + this.activeTabId = session.tab_id; + } + this.selectTab(this.activeTabId); + }, + + installDesktopDocumentSession(session) { + this.setDesktopIntentionalShutdown(false); + this.tabs = this.tabs.filter((tab) => !this.isDesktopOfficeDocument(tab)); + let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab)); + if (!desktopTab) { + desktopTab = { + ...session, + tab_id: SYSTEM_DESKTOP_FILE_ID, + file_id: SYSTEM_DESKTOP_FILE_ID, + extension: "desktop", + title: "Desktop", + path: session.desktop?.desktop_path || "/desktop/session", + mode: "desktop", + document: { + file_id: SYSTEM_DESKTOP_FILE_ID, + path: session.desktop?.desktop_path || "/desktop/session", + basename: "Desktop", + title: "Desktop", + extension: "desktop", + }, + dirty: false, + }; + this.tabs.unshift(desktopTab); + } + const documentSession = { ...session, tab_id: session.tab_id || uniqueTabId(session) }; + const existingIndex = this.tabs.findIndex((tab) => ( + (documentSession.file_id && tab.file_id === documentSession.file_id) + || (documentSession.path && tab.path === documentSession.path) + )); + if (existingIndex >= 0) { + this.tabs.splice(existingIndex, 1, documentSession); + } else { + this.tabs.push(documentSession); + } + this.session = documentSession; + this.activeTabId = documentSession.tab_id; + this.editorText = ""; + this.dirty = false; + this.resetHistory(""); + this.queueRender({ focus: true }); + this.restoreDesktopFrames(); + this.requestDesktopViewportSync({ force: true }); + this.updateDesktopMonitor(); + }, + + selectTab(tabId, options = {}) { + const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null; + if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) { + this.moveDesktopFrameToKeepalive(); + } + this.session = tab; + this.activeTabId = tab?.tab_id || ""; + this.editorText = String(tab?.text || ""); + this.dirty = Boolean(tab?.dirty); + this.resetHistory(this.editorText); + this.queueRender({ focus: Boolean(tab) && options.focus !== false }); + if (this.hasOfficialOffice(tab)) { + this.restoreDesktopFrames(); + this.requestDesktopViewportSync({ force: true }); + } + this.updateDesktopMonitor(); + }, + + ensureActiveTab() { + if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return; + if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false }); + }, + + isActiveTab(tab) { + return Boolean(tab && tab.tab_id === this.activeTabId); + }, + + async closeTab(tabId) { + const tab = this.tabs.find((item) => item.tab_id === tabId); + if (!tab) return; + if (this.isDesktopSession(tab)) { + this.selectTab(tab.tab_id, { focus: false }); + return; + } + if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) { + const shouldSave = globalThis.confirm?.("Save changes?") ?? true; + if (shouldSave) await this.save(); + } + try { + if (this.hasOfficialOffice(tab)) { + await callDesktop("save", { + desktop_session_id: tab.desktop_session_id || tab.session_id, + file_id: tab.file_id || "", + }).catch(() => null); + } else if (tab.session_id) { + await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null); + } + await callOffice("close", { + session_id: tab.store_session_id || "", + file_id: tab.file_id || "", + }); + } catch (error) { + console.warn("Document close skipped", error); + } + this.tabs = this.tabs.filter((item) => item.tab_id !== tabId); + if (this.activeTabId === tabId) { + this.session = null; + this.activeTabId = ""; + this.editorText = ""; + this.dirty = false; + this.ensureActiveTab(); + } + this.updateDesktopMonitor(); + this.ensureActiveTab(); + await this.refresh(); + }, + + async closeActiveFile() { + if (!this.session || this.isDesktopSession() || this.loading) return; + await this.closeTab(this.session.tab_id); + }, + + async save() { + if (!this.session || this.saving) return; + if (this.isDesktopSession()) return; + if (this.hasOfficialOffice()) { + this.saving = true; + this.error = ""; + try { + const response = await callDesktop("save", { + desktop_session_id: this.session.desktop_session_id || this.session.session_id, + file_id: this.session.file_id || "", + }); + if (response?.ok === false) throw new Error(response.error || "Save failed."); + const document = normalizeDocument(response.document || this.session.document || {}); + const updated = { + ...this.session, + dirty: false, + document, + path: document.path || this.session.path, + file_id: document.file_id || this.session.file_id, + version: document.version || response.version || this.session.version, + }; + this.replaceActiveSession(updated); + this.dirty = false; + this.setMessage("Saved"); + await this.refresh(); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + this.saving = false; + } + return; + } + this.syncEditorText(); + this.saving = true; + this.error = ""; + try { + let response; + const payload = { session_id: this.session.session_id, text: this.editorText }; + try { + response = await requestOffice("office_save", payload, 10000); + } catch (_socketError) { + response = await callOffice("save", payload); + } + if (response?.ok === false) throw new Error(response.error || "Save failed."); + const document = normalizeDocument(response.document || this.session.document || {}); + const updated = { + ...this.session, + text: this.editorText, + dirty: false, + document, + path: document.path || this.session.path, + file_id: document.file_id || this.session.file_id, + version: document.version || response.version || this.session.version, + }; + this.replaceActiveSession(updated); + this.dirty = false; + this.setMessage("Saved"); + await this.refresh(); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + this.saving = false; + } + }, + + async renameActiveFile() { + if (!this.session || this.isDesktopSession() || this.saving) return; + + const session = this.session; + const path = session.path || session.document?.path || ""; + if (!path) { + this.error = "This document does not have a file path to rename."; + return; + } + const name = basename(path || session.title || ""); + const extension = extensionOf(name); + await fileBrowserStore.openRenameModal( + { + name, + path, + is_dir: false, + size: session.document?.size || 0, + modified: session.document?.last_modified || "", + type: "document", + }, + { + currentPath: parentPath(path), + validateName: (newName) => { + if (!extension) return true; + return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`; + }, + performRename: async ({ path: renamedPath }) => { + const payload = { + file_id: session.file_id || "", + path: renamedPath, + }; + if (this.isMarkdown(session)) { + this.syncEditorText(); + payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || ""; + } + return await callOffice("renamed", payload); + }, + onRenamed: async ({ path: renamedPath, response }) => { + await this.handleActiveFileRenamed(session, renamedPath, response); + }, + }, + ); + }, + + async handleActiveFileRenamed(session, renamedPath, renameResponse = null) { + const response = renameResponse || await callOffice("renamed", { + file_id: session.file_id || "", + path: renamedPath, + }); + if (response?.ok === false) throw new Error(response.error || "Rename failed."); + + const document = normalizeDocument(response.document || session.document || {}); + const updated = { + ...session, + document, + title: document.title || document.basename || basename(document.path), + path: document.path || renamedPath, + extension: document.extension || session.extension, + file_id: document.file_id || session.file_id, + version: document.version || response.version || session.version, + desktop: response.desktop?.desktop || session.desktop, + text: this.session?.tab_id === session.tab_id ? this.editorText : session.text, + dirty: false, + }; + this.replaceSession(session, updated); + this.dirty = false; + this.setMessage("Renamed"); + await this.refresh(); + }, + + replaceActiveSession(next) { + if (!this.session) return; + this.replaceSession(this.session, next); + }, + + replaceSession(previous, next) { + this.session = next; + const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id)); + if (index >= 0) this.tabs.splice(index, 1, next); + this.queueRender(); + this.updateDesktopMonitor(); + }, + + setMessage(value) { + this.message = value; + if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer); + this._saveMessageTimer = globalThis.setTimeout(() => { + this.message = ""; + this._saveMessageTimer = null; + }, SAVE_MESSAGE_MS); + }, + + resetHistory(text) { + this._history = [String(text || "")]; + this._historyIndex = 0; + }, + + pushHistory(text) { + const value = String(text || ""); + if (this._history[this._historyIndex] === value) return; + this._history = this._history.slice(0, this._historyIndex + 1); + this._history.push(value); + if (this._history.length > MAX_HISTORY) this._history.shift(); + this._historyIndex = this._history.length - 1; + }, + + undo() { + if (this._historyIndex <= 0) return; + this._historyIndex -= 1; + this.applyEditorText(this._history[this._historyIndex], true); + }, + + redo() { + if (this._historyIndex >= this._history.length - 1) return; + this._historyIndex += 1; + this.applyEditorText(this._history[this._historyIndex], true); + }, + + canUndo() { + return this._historyIndex > 0; + }, + + canRedo() { + return this._historyIndex < this._history.length - 1; + }, + + applyEditorText(text, markDirty = false) { + this.editorText = String(text || ""); + if (this.session) { + this.session.text = this.editorText; + this.session.dirty = markDirty || this.session.dirty; + } + if (markDirty) this.markDirty(); + this.queueRender({ force: true, focus: true }); + }, + + markDirty() { + this.dirty = true; + if (this.session) this.session.dirty = true; + }, + + onSourceInput() { + this.markDirty(); + this.pushHistory(this.editorText); + this.scheduleInputPush(); + }, + + syncEditorText() { + if (!this.session) return; + if (this.hasOfficialOffice()) return; + this.session.text = this.editorText; + }, + + scheduleInputPush() { + if (!this.session?.session_id) return; + if (this._inputTimer) globalThis.clearTimeout(this._inputTimer); + this._inputTimer = globalThis.setTimeout(() => { + this._inputTimer = null; + this.flushInput(); + }, INPUT_PUSH_DELAY_MS); + }, + + flushInput() { + if (!this.session?.session_id) return; + if (this.hasOfficialOffice()) return; + this.syncEditorText(); + requestOffice("office_input", { + session_id: this.session.session_id, + text: this.editorText, + }, 3000).catch(() => {}); + }, + + format(command) { + if (!this.session) return; + if (!this.isMarkdown()) return; + this.applySourceFormat(command); + }, + + applySourceFormat(command) { + const textarea = this._root?.querySelector?.("[data-office-source]"); + if (!textarea) return; + const start = textarea.selectionStart || 0; + const end = textarea.selectionEnd || start; + const selected = this.editorText.slice(start, end); + let replacement = selected; + if (command === "bold") replacement = `**${selected || "text"}**`; + if (command === "italic") replacement = `*${selected || "text"}*`; + if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n"); + if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n"); + if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |"; + if (replacement === selected) return; + this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`; + this.onSourceInput(); + globalThis.requestAnimationFrame?.(() => { + textarea.focus(); + textarea.selectionStart = start; + textarea.selectionEnd = start + replacement.length; + }); + }, + + queueRender(options = {}) { + const force = Boolean(options.force); + if (options.focus) { + this._pendingFocus = true; + this._pendingFocusEnd = options.end !== false; + this._focusAttempts = 0; + } + const render = () => { + if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) { + this._pendingFocus = false; + this._focusAttempts = 0; + } else if (this._pendingFocus && this._focusAttempts < 6) { + this._focusAttempts += 1; + globalThis.setTimeout(render, 45); + } + }; + if (globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame(render); + } else { + globalThis.setTimeout(render, 0); + } + }, + + focusEditor(options = {}) { + if (!this.session) return false; + if (this.hasOfficialOffice()) { + return this.focusDesktopFrame(this.desktopFrame(), { arm: true }); + } + const source = this._root?.querySelector?.("[data-office-source]"); + if (!this.isMarkdown() || !source) return false; + source.focus?.({ preventScroll: true }); + if (!editorContainsFocus(source)) return false; + if (options.end !== false) placeCaretAtEnd(source); + return true; + }, + + isMarkdown(tab = this.session) { + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); + return ext === "md"; + }, + + isBinaryOffice(tab = this.session) { + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); + return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(ext); + }, + + hasOfficialOffice(tab = this.session) { + return Boolean(tab?.desktop?.available && tab.desktop.url); + }, + + isDesktopSession(tab = this.session) { + return Boolean( + tab + && ( + tab.file_id === SYSTEM_DESKTOP_FILE_ID + || tab.extension === "desktop" + || tab.mode === "desktop" + ) + ); + }, + + isDesktopOfficeDocument(tab = this.session) { + return Boolean(tab && this.hasOfficialOffice(tab) && !this.isDesktopSession(tab) && this.isBinaryOffice(tab)); + }, + + hasActiveFile(tab = this.session) { + return Boolean(tab && !this.isDesktopSession(tab) && (this.isMarkdown(tab) || this.isDesktopOfficeDocument(tab))); + }, + + isVisibleOfficeTab(tab = {}) { + return Boolean(this.hasActiveFile(tab)); + }, + + visibleTabs() { + return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab)); + }, + + officialOfficeUrl(tab = this.session) { + const url = tab?.desktop?.url || ""; + if (!url) return ""; + try { + const parsed = new URL(url, window.location.href); + const secureContext = globalThis.isSecureContext === true; + parsed.searchParams.set("offscreen", secureContext ? "true" : "false"); + parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false"); + if (parsed.origin === window.location.origin) return `${parsed.pathname}${parsed.search}${parsed.hash}`; + return parsed.href; + } catch { + return url; + } + }, + + isDesktopHostVisible() { + if (this._mode === "modal") { + return Boolean(this._root?.isConnected && this._root.closest?.(".modal")); + } + const surface = this._root?.closest?.('[data-surface-id="desktop"]'); + return Boolean(surface?.classList?.contains("is-mounted") || surface?.classList?.contains("is-active")); + }, + + setDesktopHostVisible(visible) { + const next = Boolean(visible); + if (!next && this._mode === "modal") return; + if (this._desktopHostVisible === next) return; + this._desktopHostVisible = next; + if (next) { + this.afterDesktopHostShown({ source: "canvas-visibility" }); + } else { + this.beforeHostHidden({ reason: "hidden" }); + } + }, + + desktopFrames() { + const frames = []; + if (this._desktopFrame) frames.push(this._desktopFrame); + for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) { + if (!frames.includes(frame)) frames.push(frame); + } + return frames; + }, + + isUsableDesktopFrame(frame) { + if (!frame?.contentWindow) return false; + const rect = frame.getBoundingClientRect?.(); + return Boolean(rect && rect.width >= 120 && rect.height >= 80); + }, + + desktopFrame(preferred = null) { + if (this.isUsableDesktopFrame(preferred)) return preferred; + const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]"); + if (this.isUsableDesktopFrame(rootFrame)) return rootFrame; + const frames = this.desktopFrames(); + return frames + .filter((frame) => this.isUsableDesktopFrame(frame)) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height); + })[0] || null; + }, + + isUsableDesktopHost(host) { + if (!host?.appendChild) return false; + const rect = host.getBoundingClientRect?.(); + return Boolean(rect && rect.width >= 120 && rect.height >= 80); + }, + + desktopHost(preferred = null) { + if (preferred?.matches?.("[data-office-desktop-host]")) return preferred; + const rootHost = this._root?.querySelector?.("[data-office-desktop-host]"); + if (this.isUsableDesktopHost(rootHost)) return rootHost; + const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]")); + return hosts + .filter((host) => this.isUsableDesktopHost(host)) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height); + })[0] || rootHost || hosts[0] || null; + }, + + ensureDesktopKeepaliveHost() { + if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost; + const host = document.createElement("div"); + host.className = "office-desktop-keepalive"; + host.dataset.officeDesktopKeepalive = "true"; + Object.assign(host.style, { + position: "fixed", + left: "-10000px", + top: "-10000px", + width: "720px", + height: "480px", + overflow: "hidden", + pointerEvents: "none", + visibility: "hidden", + }); + document.body?.appendChild(host); + this._desktopKeepaliveHost = host; + return host; + }, + + rememberDesktopFrameSize() { + const frame = this._desktopFrame; + const rect = frame?.getBoundingClientRect?.(); + const hostRect = this._desktopFrameHost?.getBoundingClientRect?.(); + const width = Math.round(rect?.width || hostRect?.width || 720); + const height = Math.round(rect?.height || hostRect?.height || 480); + const keepalive = this.ensureDesktopKeepaliveHost(); + keepalive.style.width = `${Math.max(320, width)}px`; + keepalive.style.height = `${Math.max(220, height)}px`; + return keepalive; + }, + + ensureDesktopFrame() { + if (this._desktopFrame) return this._desktopFrame; + const frame = document.createElement("iframe"); + frame.className = "office-desktop-frame"; + frame.dataset.officeDesktopFrame = "true"; + frame.dataset.officePersistentDesktopFrame = "true"; + frame.setAttribute("tabindex", "0"); + frame.setAttribute("aria-label", "Desktop"); + frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay"); + this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event); + frame.addEventListener("load", this._desktopFrameLoadHandler); + this._desktopFrame = frame; + return frame; + }, + + desktopFrameSrcMatches(frame, url) { + const current = frame?.getAttribute?.("src") || frame?.src || ""; + if (!current && !url) return true; + try { + return new URL(current, window.location.href).href === new URL(url, window.location.href).href; + } catch { + return current === url; + } + }, + + attachDesktopFrame(host = null) { + if (!this.hasOfficialOffice()) return false; + const target = this.desktopHost(host); + if (!target) return false; + const frame = this.ensureDesktopFrame(); + if (frame.parentElement !== target) { + frame.parentElement?.removeAttribute?.("data-office-desktop-attached"); + target.appendChild(frame); + } + target.dataset.officeDesktopAttached = "true"; + if (this._desktopFrameHost !== target) this._desktopFrameHost = target; + const url = this.officialOfficeUrl(); + if (url && !this.desktopFrameSrcMatches(frame, url)) { + frame.setAttribute("src", url); + } + return true; + }, + + mountDesktopFrameHost(host = null) { + const attached = this.attachDesktopFrame(host); + if (attached && this.isDesktopHostVisible()) { + this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true }); + } + return attached; + }, + + moveDesktopFrameToKeepalive() { + const frame = this._desktopFrame; + if (!frame) return false; + const keepalive = this.rememberDesktopFrameSize(); + if (frame.parentElement !== keepalive) { + frame.parentElement?.removeAttribute?.("data-office-desktop-attached"); + keepalive.appendChild(frame); + } + this._desktopFrameHost = keepalive; + this._desktopKeyboardActive = false; + this.updateDesktopKeyboardCaptureState(frame); + return true; + }, + + destroyDesktopFrame() { + const frame = this._desktopFrame; + if (!frame) return; + if (this._desktopFrameLoadHandler) { + frame.removeEventListener("load", this._desktopFrameLoadHandler); + } + frame.setAttribute("src", "about:blank"); + frame.remove(); + this._desktopFrame = null; + this._desktopFrameHost = null; + this._desktopFrameLoadHandler = null; + this._desktopBridgeReady = false; + this.updateDesktopKeyboardCaptureState(); + this._desktopKeepaliveHost?.remove?.(); + this._desktopKeepaliveHost = null; + }, + + unloadDesktopFrames() { + this.stopDesktopResizeObserver(); + this.stopXpraDesktopPrime(); + this.moveDesktopFrameToKeepalive(); + }, + + restoreDesktopFrames() { + if (!this.isDesktopHostVisible()) return; + this.attachDesktopFrame(); + }, + + afterDesktopHostShown() { + if (!this.hasOfficialOffice()) return; + this._desktopHostVisible = true; + this._desktopResizeKey = ""; + this._desktopResizePendingKey = ""; + this._desktopResizeSuspended = false; + this._desktopResizePending = false; + this.restoreDesktopFrames(); + this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() }); + }, + + beforeDesktopHostHandoff() { + this.stopDesktopResizeObserver(); + this.clearDesktopViewportSyncTimers(); + this.stopXpraDesktopPrime(); + this._desktopResizeKey = ""; + this._desktopResizePendingKey = ""; + this._desktopResizeSuspended = true; + this._desktopResizePending = true; + }, + + cancelDesktopHostHandoff() { + this._desktopResizeSuspended = false; + this._desktopResizePending = false; + this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() }); + }, + + onDesktopFrameLoaded(event = null) { + if (event?.target?.getAttribute?.("src") === "about:blank") return; + if (!this.isDesktopHostVisible()) return; + this.error = ""; + this.queueDesktopFrameFocus(event?.target || null); + this.requestDesktopViewportSync({ force: true, frame: event?.target || null }); + }, + + queueDesktopFrameFocus(frame = null) { + for (const delay of [0, 80, 260]) { + globalThis.setTimeout(() => { + if (!this.hasOfficialOffice()) return; + if (isEditableInputTarget(document.activeElement)) return; + this.focusDesktopFrame(frame || this.desktopFrame(), { arm: true }); + }, delay); + } + }, + + focusDesktopFrame(frame = null, options = {}) { + if (this._desktopFocusInProgress) return false; + const target = this.desktopFrame(frame); + if (!target) return false; + if (options.arm !== false) this._desktopKeyboardActive = true; + this._desktopFocusInProgress = true; + try { + target.setAttribute("tabindex", "0"); + target.focus?.({ preventScroll: true }); + target.contentWindow?.focus?.(); + if (target.contentDocument?.body && !target.contentDocument.body.hasAttribute("tabindex")) { + target.contentDocument.body.tabIndex = -1; + } + target.contentDocument?.body?.focus?.({ preventScroll: true }); + if (target.contentWindow?.client) target.contentWindow.client.capture_keyboard = true; + } catch { + target.focus?.({ preventScroll: true }); + } finally { + this._desktopFocusInProgress = false; + } + const focused = Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.()); + this.updateDesktopKeyboardCaptureState(target); + return focused; + }, + + updateDesktopMonitor() { + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) { + this.stopDesktopMonitor(); + this.stopDesktopResizeObserver(); + this._desktopKeyboardActive = false; + this._desktopBridgeReady = false; + this.updateDesktopKeyboardCaptureState(); + return; + } + const sessionId = this.session?.desktop_session_id || this.session?.session_id || ""; + const tabId = this.session?.tab_id || ""; + if ( + sessionId + && tabId + && this._desktopHeartbeatTimer + && this._desktopHeartbeatSessionId === sessionId + && this._desktopHeartbeatTabId === tabId + ) return; + this.startDesktopMonitor(); + this.startDesktopResizeObserver(); + }, + + startDesktopResizeObserver() { + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) { + this.stopDesktopResizeObserver(); + return; + } + const frame = this.desktopFrame(); + const target = frame?.parentElement || frame; + if (!target) { + this.stopDesktopResizeObserver(); + return; + } + if (this._desktopResizeCleanup && this._desktopResizeTarget === target) return; + this.stopDesktopResizeObserver(); + + const resize = () => this.queueDesktopResize(); + const resizeStart = () => this.suspendDesktopResize(); + const resizeEnd = () => this.resumeDesktopResize(); + const cleanup = []; + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(resize); + observer.observe(target); + cleanup.push(() => observer.disconnect()); + } + globalThis.addEventListener?.("resize", resize); + cleanup.push(() => globalThis.removeEventListener?.("resize", resize)); + globalThis.addEventListener?.("right-canvas-resize-start", resizeStart); + cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart)); + globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd); + cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd)); + this._desktopResizeTarget = target; + this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry()); + resize(); + }, + + stopDesktopResizeObserver() { + if (this._desktopResizeTimer) { + globalThis.clearTimeout(this._desktopResizeTimer); + } + this._desktopResizeTimer = null; + this._desktopResizeCleanup?.(); + this._desktopResizeCleanup = null; + this._desktopResizeTarget = null; + this._desktopResizeKey = ""; + this._desktopResizePendingKey = ""; + this._desktopResizeSuspended = false; + this._desktopResizePending = false; + }, + + suspendDesktopResize() { + this._desktopResizeSuspended = true; + if (this._desktopResizeTimer) { + globalThis.clearTimeout(this._desktopResizeTimer); + this._desktopResizeTimer = null; + } + this._desktopResizePendingKey = ""; + }, + + resumeDesktopResize() { + const hadPendingResize = this._desktopResizePending; + this._desktopResizeSuspended = false; + this._desktopResizePending = false; + if (hadPendingResize || this.hasOfficialOffice()) { + this.queueDesktopResize({ force: true }); + } + }, + + shouldDeferDesktopResize() { + return Boolean( + this._desktopResizeSuspended + || document.body?.classList?.contains("right-canvas-resizing") + || document.querySelector?.(".modal-inner.office-modal.is-resizing") + ); + }, + + clearDesktopViewportSyncTimers() { + for (const timer of this._desktopViewportSyncTimers.splice(0)) { + globalThis.clearTimeout(timer); + } + }, + + requestDesktopViewportSync(options = {}) { + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; + if (options.force) this.clearDesktopViewportSyncTimers(); + const run = (force = false) => { + this.syncDesktopViewport({ ...options, force }); + }; + if (globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame(() => run(Boolean(options.force))); + } else { + globalThis.setTimeout(() => run(Boolean(options.force)), 0); + } + if (options.followup === false) return; + const timer = globalThis.setTimeout(() => { + this._desktopViewportSyncTimers = this._desktopViewportSyncTimers.filter((item) => item !== timer); + run(false); + }, options.force ? 260 : 180); + this._desktopViewportSyncTimers.push(timer); + }, + + syncDesktopViewport(options = {}) { + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return false; + const frame = this.desktopFrame(options.frame || null); + if (!frame) return false; + this.startDesktopResizeObserver(); + this.primeXpraDesktopFrame({ reset: true, frame }); + this.queueDesktopResize({ + force: Boolean(options.force), + serverResize: options.serverResize !== false, + frame, + }); + this.updateDesktopMonitor(); + return true; + }, + + primeXpraDesktopFrame(options = {}) { + if (options.reset) { + this.stopXpraDesktopPrime(); + this._desktopPrimeAttempts = 0; + } + if (this.applyXpraDesktopFrameMode(options.frame || null, options)) return; + if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return; + this._desktopPrimeAttempts += 1; + if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer); + this._desktopPrimeTimer = globalThis.setTimeout(() => { + this._desktopPrimeTimer = null; + this.primeXpraDesktopFrame(); + }, XPRA_DESKTOP_PRIME_INTERVAL_MS); + }, + + stopXpraDesktopPrime() { + if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer); + this._desktopPrimeTimer = null; + }, + + applyXpraDesktopFrameMode(preferredFrame = null, options = {}) { + const frame = this.desktopFrame(preferredFrame); + const remoteWindow = frame?.contentWindow; + if (!remoteWindow) return false; + const requestServerResize = options.requestServerResize === true; + const requestRefresh = options.requestRefresh !== false; + try { + const remoteDocument = frame.contentDocument || remoteWindow.document; + this.installXpraDesktopFrameCss(remoteDocument); + this.installXpraDesktopFramePatches(remoteWindow, remoteDocument); + const client = remoteWindow.client; + if (!client) return false; + this.installXpraDesktopClientPatches(remoteWindow, client); + this.installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client); + this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client); + this.installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client); + const container = client.container || remoteDocument?.querySelector?.("#screen"); + if (!container) return false; + + client.server_is_desktop = true; + client.server_resize_exact = true; + remoteDocument?.body?.classList?.add("desktop"); + + const windows = Object.values(client.id_to_window || {}); + if (!client.connected || !windows.length) return false; + + const token = options.token || this.session?.desktop?.token || ""; + const displaySize = options.displaySize || this.desktopDisplaySizeForToken(token); + const viewportWidth = Math.round(container.clientWidth || remoteWindow.innerWidth || 0); + const viewportHeight = Math.round(container.clientHeight || remoteWindow.innerHeight || 0); + const width = Math.round(displaySize?.width || viewportWidth || 0); + const height = Math.round(displaySize?.height || viewportHeight || 0); + if (width > 0 && height > 0) { + client.desktop_width = width; + client.desktop_height = height; + } + if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") { + client.desktop_width = 0; + client.desktop_height = 0; + client.__a0AllowScreenResize = true; + try { + client._screen_resized(new remoteWindow.Event("resize")); + } finally { + client.__a0AllowScreenResize = false; + } + } + + for (const xpraWindow of windows) { + this.normalizeXpraDesktopWindow(xpraWindow, width, height); + xpraWindow.screen_resized?.(); + this.normalizeXpraDesktopWindow(xpraWindow, width, height); + xpraWindow.updateCSSGeometry?.(); + this.fitXpraDesktopWindowElement(xpraWindow, width, height); + this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow); + if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid); + } + this.installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container); + return true; + } catch (error) { + console.warn("Xpra desktop viewport prime skipped", error); + return false; + } + }, + + desktopDisplaySizeForToken(token = "") { + const key = String(token || "").trim(); + const size = key ? this._desktopDisplaySizes?.[key] : null; + const width = Math.round(Number(size?.width || 0)); + const height = Math.round(Number(size?.height || 0)); + return width > 0 && height > 0 ? { width, height } : null; + }, + + rememberDesktopDisplaySize(token = "", width = 0, height = 0) { + const key = String(token || "").trim(); + const normalizedWidth = Math.round(Number(width || 0)); + const normalizedHeight = Math.round(Number(height || 0)); + if (!key || normalizedWidth <= 0 || normalizedHeight <= 0) return null; + this._desktopDisplaySizes = { + ...(this._desktopDisplaySizes || {}), + [key]: { width: normalizedWidth, height: normalizedHeight }, + }; + return this._desktopDisplaySizes[key]; + }, + + installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) { + if (!frame || !remoteWindow || !remoteDocument || !client) return null; + const store = this; + const finite = (value, fallback = 0) => { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; + }; + const metrics = () => { + const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1)); + const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1)); + const primaryWindow = Object.values(client.id_to_window || {})[0]; + const canvas = primaryWindow?.canvas; + const clientWidth = Math.max(1, finite(canvas?.clientWidth || canvas?.width || container?.clientWidth || remoteWindow.innerWidth, desktopWidth)); + const clientHeight = Math.max(1, finite(canvas?.clientHeight || canvas?.height || container?.clientHeight || remoteWindow.innerHeight, desktopHeight)); + return { + desktopWidth, + desktopHeight, + clientWidth, + clientHeight, + scaleX: clientWidth / desktopWidth, + scaleY: clientHeight / desktopHeight, + }; + }; + const bridge = frame.__agentZeroDesktopBridge || {}; + Object.assign(bridge, { + ready: true, + state: async (options = {}) => { + const result = await callDesktop("state", { + include_screenshot: options.includeScreenshot === true || options.include_screenshot === true, + }); + store._desktopLastState = result; + return result; + }, + focus: (options = {}) => store.focusDesktopFrame(frame, { ...options, arm: options.arm !== false }), + requestRefresh: () => { + for (const xpraWindow of Object.values(client.id_to_window || {})) { + if (xpraWindow?.wid != null) client.request_refresh?.(xpraWindow.wid); + } + return true; + }, + desktopToClient: (x, y) => { + const value = metrics(); + return { + x: Math.round(finite(x) * value.scaleX), + y: Math.round(finite(y) * value.scaleY), + scale_x: value.scaleX, + scale_y: value.scaleY, + }; + }, + clientToDesktop: (x, y) => { + const value = metrics(); + return { + x: Math.round(finite(x) / value.scaleX), + y: Math.round(finite(y) / value.scaleY), + scale_x: value.scaleX, + scale_y: value.scaleY, + }; + }, + diagnostics: () => store.desktopBridgeDiagnostics(frame), + }); + frame.agentZeroDesktop = bridge; + frame.__agentZeroDesktopBridge = bridge; + remoteWindow.agentZeroDesktop = bridge; + remoteWindow.__agentZeroDesktopBridge = bridge; + this._desktopBridgeReady = true; + this.updateDesktopKeyboardCaptureState(frame); + return bridge; + }, + + desktopBridgeDiagnostics(frame = null) { + return { + ready: this._desktopBridgeReady, + keyboard: this.updateDesktopKeyboardCaptureState(frame), + lastStateOk: this._desktopLastState?.ok ?? null, + }; + }, + + updateDesktopKeyboardCaptureState(frame = null) { + const target = this.desktopFrame(frame); + const client = target?.contentWindow?.client; + const state = { + ready: Boolean(target?.__agentZeroDesktopBridge || target?.contentWindow?.__agentZeroDesktopBridge), + active: Boolean(this._desktopKeyboardActive), + capture: Boolean(client?.capture_keyboard), + focused: Boolean(target && (document.activeElement === target || target.contentDocument?.hasFocus?.())), + }; + this._desktopKeyboardCaptureState = state; + return state; + }, + + normalizeXpraDesktopWindow(xpraWindow, width, height) { + if (!xpraWindow) return; + const normalizedWidth = Math.max(1, Math.round(Number(width || 0))); + const normalizedHeight = Math.max(1, Math.round(Number(height || 0))); + xpraWindow.x = 0; + xpraWindow.y = 0; + xpraWindow.w = normalizedWidth; + xpraWindow.h = normalizedHeight; + xpraWindow.resizable = false; + xpraWindow.decorations = false; + xpraWindow.decorated = false; + xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false }; + xpraWindow._set_decorated?.(false); + xpraWindow.configure_border_class?.(); + xpraWindow.leftoffset = 0; + xpraWindow.rightoffset = 0; + xpraWindow.topoffset = 0; + xpraWindow.bottomoffset = 0; + }, + + fitXpraDesktopWindowElement(xpraWindow, width, height) { + const normalizedWidth = Math.max(1, Math.round(Number(width || 0))); + const normalizedHeight = Math.max(1, Math.round(Number(height || 0))); + const cssWidth = `${normalizedWidth}px`; + const cssHeight = `${normalizedHeight}px`; + const windowElement = xpraWindow?.div; + const canvas = xpraWindow?.canvas; + windowElement?.style?.setProperty("left", "0px", "important"); + windowElement?.style?.setProperty("top", "0px", "important"); + windowElement?.style?.setProperty("position", "absolute", "important"); + windowElement?.style?.setProperty("width", cssWidth, "important"); + windowElement?.style?.setProperty("height", cssHeight, "important"); + windowElement?.style?.setProperty("transform", "none", "important"); + windowElement?.style?.setProperty("margin", "0", "important"); + canvas?.style?.setProperty("width", cssWidth, "important"); + canvas?.style?.setProperty("height", cssHeight, "important"); + canvas?.style?.setProperty("display", "block", "important"); + canvas?.style?.setProperty("margin", "0", "important"); + if (canvas) { + if (canvas.width !== normalizedWidth) canvas.width = normalizedWidth; + if (canvas.height !== normalizedHeight) canvas.height = normalizedHeight; + canvas.setAttribute("width", String(normalizedWidth)); + canvas.setAttribute("height", String(normalizedHeight)); + } + }, + + installXpraDesktopWheelBridge(remoteWindow, xpraWindow) { + const canvas = xpraWindow?.canvas; + if (!remoteWindow || !canvas || canvas.__a0XpraWheelBridgeInstalled) return; + if (typeof xpraWindow.mouse_scroll_cb !== "function") return; + canvas.__a0XpraWheelBridgeInstalled = true; + canvas.addEventListener("wheel", (event) => { + event.stopImmediatePropagation?.(); + event.stopPropagation?.(); + event.preventDefault?.(); + const normalizedEvent = this.xpraDesktopWheelEvent(remoteWindow, canvas, event); + xpraWindow.mouse_scroll_cb(normalizedEvent, xpraWindow); + }, { passive: false, capture: true }); + }, + + xpraDesktopWheelEvent(remoteWindow, canvas, event) { + const finite = (value, fallback = 0) => { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; + }; + const deltaMode = finite(event.deltaMode, 0); + const lineHeight = 16; + const pageHeight = Math.max(1, remoteWindow.innerHeight || canvas.clientHeight || 800); + const deltaScale = deltaMode === 1 ? lineHeight : deltaMode === 2 ? pageHeight : 1; + const deltaX = finite(event.deltaX) * deltaScale; + const deltaY = finite(event.deltaY) * deltaScale; + const deltaZ = finite(event.deltaZ) * deltaScale; + const wheelDeltaX = finite(event.wheelDeltaX, -deltaX); + const wheelDeltaY = finite(event.wheelDeltaY, -deltaY); + const wheelDelta = finite(event.wheelDelta, wheelDeltaY || wheelDeltaX); + const getModifierState = (key) => { + if (typeof event.getModifierState === "function") return event.getModifierState(key); + const normalizedKey = String(key || "").toLowerCase(); + if (normalizedKey === "alt") return Boolean(event.altKey); + if (normalizedKey === "control") return Boolean(event.ctrlKey); + if (normalizedKey === "meta") return Boolean(event.metaKey); + if (normalizedKey === "shift") return Boolean(event.shiftKey); + return false; + }; + const normalizedEvent = Object.create(event); + Object.defineProperties(normalizedEvent, { + target: { value: event.target || canvas }, + currentTarget: { value: canvas }, + clientX: { value: finite(event.clientX) }, + clientY: { value: finite(event.clientY) }, + pageX: { value: finite(event.pageX, finite(event.clientX)) }, + pageY: { value: finite(event.pageY, finite(event.clientY)) }, + screenX: { value: finite(event.screenX) }, + screenY: { value: finite(event.screenY) }, + offsetX: { value: finite(event.offsetX) }, + offsetY: { value: finite(event.offsetY) }, + movementX: { value: finite(event.movementX) }, + movementY: { value: finite(event.movementY) }, + button: { value: finite(event.button) }, + buttons: { value: finite(event.buttons) }, + which: { value: finite(event.which) }, + detail: { value: finite(event.detail) }, + deltaX: { value: deltaX }, + deltaY: { value: deltaY }, + deltaZ: { value: deltaZ }, + deltaMode: { value: 0 }, + wheelDeltaX: { value: wheelDeltaX }, + wheelDeltaY: { value: wheelDeltaY }, + wheelDelta: { value: wheelDelta }, + altKey: { value: Boolean(event.altKey) }, + ctrlKey: { value: Boolean(event.ctrlKey) }, + metaKey: { value: Boolean(event.metaKey) }, + shiftKey: { value: Boolean(event.shiftKey) }, + getModifierState: { value: getModifierState }, + preventDefault: { value: () => event.preventDefault?.() }, + stopPropagation: { value: () => event.stopPropagation?.() }, + stopImmediatePropagation: { value: () => event.stopImmediatePropagation?.() }, + }); + return normalizedEvent; + }, + + installXpraDesktopFrameCss(remoteDocument) { + if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return; + const style = remoteDocument.createElement("style"); + style.id = "a0-xpra-desktop-frame-css"; + style.textContent = ` + html, body, #screen { + width: 100% !important; + height: 100% !important; + overflow: auto !important; + } + #float_menu, + .windowhead, + .windowbuttons { + display: none !important; + } + #shadow_pointer { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + } + .window, + .window.border, + .window.desktop, + .undecorated, + .undecorated.border, + .undecorated.desktop { + left: 0 !important; + top: 0 !important; + position: absolute !important; + width: 100% !important; + height: 100% !important; + transform: none !important; + margin: 0 !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + } + .window canvas, + .undecorated canvas { + display: block !important; + width: 100% !important; + height: 100% !important; + margin: 0 !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + } + `; + remoteDocument.head?.appendChild(style); + }, + + installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client) { + if (!remoteWindow || !remoteDocument || !client) return; + const hideShadowPointer = () => { + const pointer = remoteDocument.getElementById?.("shadow_pointer"); + pointer?.style?.setProperty("display", "none", "important"); + pointer?.style?.setProperty("visibility", "hidden", "important"); + pointer?.style?.setProperty("opacity", "0", "important"); + }; + hideShadowPointer(); + + const pointerPacket = remoteWindow.PACKET_TYPES?.pointer_position || "pointer-position"; + if (!client.__a0XpraDesktopCursorPatched) { + if (typeof client._process_pointer_position === "function") { + client.__a0OriginalProcessPointerPosition = client._process_pointer_position; + } + client._process_pointer_position = function patchedProcessPointerPosition(packet) { + hideShadowPointer(); + this.__a0LastPointerPosition = packet; + return false; + }; + client.__a0XpraDesktopCursorPatched = true; + } + if (client.packet_handlers && pointerPacket) { + client.packet_handlers[pointerPacket] = client._process_pointer_position; + } + }, + + installXpraDesktopFramePatches(remoteWindow, remoteDocument) { + if (!remoteWindow || !remoteDocument) return; + remoteWindow.__a0XpraDesktopFramePatches ||= {}; + const patches = remoteWindow.__a0XpraDesktopFramePatches; + const isBenignXpraWarning = (args = []) => { + const text = Array.from(args || []).map((value) => String(value || "")).join(" "); + return text.includes("window does not fit in canvas, offsets") + || (text.includes("decode error packet") && text.includes("not found")); + }; + if (!patches.consoleWarn && typeof remoteWindow.console?.warn === "function") { + const originalConsoleWarn = remoteWindow.console.warn.bind(remoteWindow.console); + remoteWindow.console.warn = function patchedConsoleWarn(...args) { + if (isBenignXpraWarning(args)) return undefined; + return originalConsoleWarn(...args); + }; + patches.consoleWarn = true; + } + if (!patches.noWindowList && typeof remoteWindow.noWindowList === "function") { + const originalNoWindowList = remoteWindow.noWindowList; + remoteWindow.noWindowList = function patchedNoWindowList(...args) { + if (!remoteDocument.querySelector("#open_windows")) return undefined; + return originalNoWindowList.apply(this, args); + }; + patches.noWindowList = true; + } + if (!patches.addWindowListItem && typeof remoteWindow.addWindowListItem === "function") { + const originalAddWindowListItem = remoteWindow.addWindowListItem; + remoteWindow.addWindowListItem = function patchedAddWindowListItem(...args) { + if (!remoteDocument.querySelector("#open_windows_list")) return undefined; + return originalAddWindowListItem.apply(this, args); + }; + patches.addWindowListItem = true; + } + }, + + installXpraDesktopClientPatches(remoteWindow, client) { + if (!remoteWindow || !client) return; + if (!client.__a0XpraOffsetWarnPatched && typeof client.warn === "function") { + const originalClientWarn = client.warn.bind(client); + client.warn = function patchedClientWarn(...args) { + const text = Array.from(args || []).map((value) => String(value || "")).join(" "); + if ( + text.includes("window does not fit in canvas, offsets") + || (text.includes("decode error packet") && text.includes("not found")) + ) { + return undefined; + } + return originalClientWarn(...args); + }; + client.__a0XpraOffsetWarnPatched = true; + } + if (client.__a0XpraDesktopClientPatched) return; + if (typeof client._screen_resized === "function") { + const originalScreenResized = client._screen_resized.bind(client); + client.__a0OriginalScreenResized = originalScreenResized; + client._screen_resized = function patchedScreenResized(event) { + if (client.__a0AllowScreenResize === true) return originalScreenResized(event); + return false; + }; + } + client.__a0XpraDesktopClientPatched = true; + }, + + installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client) { + if (!frame || !remoteWindow || !remoteDocument || !client) return; + this.ensureDesktopClipboardBridge(); + if (remoteWindow.__a0XpraDesktopClipboardBridgeInstalled) return; + + const onPaste = (event) => { + this.handleDesktopPasteEvent(event, frame, remoteWindow, client); + }; + const onKeydown = (event) => { + if (this.isDesktopPasteShortcut(event)) { + void this.syncHostClipboardToDesktop(frame); + } + }; + remoteWindow.addEventListener("paste", onPaste, true); + remoteDocument.addEventListener("paste", onPaste, true); + remoteWindow.addEventListener("keydown", onKeydown, true); + remoteDocument.addEventListener("keydown", onKeydown, true); + remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = true; + remoteWindow.__a0XpraDesktopClipboardBridgeCleanup = () => { + remoteWindow.removeEventListener("paste", onPaste, true); + remoteDocument.removeEventListener("paste", onPaste, true); + remoteWindow.removeEventListener("keydown", onKeydown, true); + remoteDocument.removeEventListener("keydown", onKeydown, true); + remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = false; + }; + }, + + ensureDesktopClipboardBridge() { + if (this._desktopClipboardCleanup) return; + + const onPaste = (event) => { + if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return; + if (isEditableInputTarget(event.target)) return; + const frame = this.desktopFrame(); + const remoteWindow = frame?.contentWindow; + const client = remoteWindow?.client; + if (!frame || !remoteWindow || !client) return; + this.handleDesktopPasteEvent(event, frame, remoteWindow, client); + }; + + document.addEventListener("paste", onPaste, true); + this._desktopClipboardCleanup = () => { + document.removeEventListener("paste", onPaste, true); + this._desktopClipboardCleanup = null; + }; + }, + + stopDesktopClipboardBridge() { + this._desktopClipboardCleanup?.(); + }, + + handleDesktopPasteEvent(event, frame, remoteWindow, client) { + const text = this.desktopClipboardTextFromEvent(event); + if (!text) return false; + if (!this.syncXpraClipboardText(client, text, remoteWindow)) return false; + event.preventDefault?.(); + event.stopImmediatePropagation?.(); + event.stopPropagation?.(); + this.focusDesktopFrame(frame, { arm: true }); + return true; + }, + + desktopClipboardTextFromEvent(event) { + const data = (event?.originalEvent || event)?.clipboardData; + if (!data?.getData) return ""; + for (const type of ["text/plain", "text", "Text", "STRING", "UTF8_STRING"]) { + const value = data.getData(type); + if (value) return value; + } + return ""; + }, + + syncXpraClipboardText(client, text, remoteWindow = null) { + const value = String(text ?? ""); + if (!client || !value || typeof client.send_clipboard_token !== "function") return false; + const textPlain = remoteWindow?.TEXT_PLAIN || "text/plain"; + const utf8String = remoteWindow?.UTF8_STRING || "UTF8_STRING"; + const utilities = remoteWindow?.Utilities; + const payload = utilities?.StringToUint8 ? utilities.StringToUint8(value) : value; + client.clipboard_enabled = true; + client.clipboard_direction = "both"; + client.clipboard_buffer = value; + client.clipboard_pending = false; + client.send_clipboard_token(payload, [textPlain, utf8String, "TEXT", "STRING"]); + return true; + }, + + async syncHostClipboardToDesktop(frame = null) { + const target = this.desktopFrame(frame); + const remoteWindow = target?.contentWindow; + const client = remoteWindow?.client; + if (!client || !navigator.clipboard?.readText) return false; + try { + const text = await navigator.clipboard.readText(); + return this.syncXpraClipboardText(client, text, remoteWindow); + } catch { + return false; + } + }, + + isDesktopPasteShortcut(event) { + const key = String(event?.key || "").toLowerCase(); + return key === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey; + }, + + installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) { + if (!frame || !remoteWindow || !remoteDocument || !client) return; + this.ensureDesktopKeyboardBridge(); + frame.setAttribute("tabindex", "0"); + if (remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled) return; + + const activate = () => { + if (this._desktopFocusInProgress) return; + this.focusDesktopFrame(frame, { arm: true }); + }; + const events = ["pointerdown", "mousedown", "touchstart", "focusin"]; + for (const eventName of events) { + remoteDocument.addEventListener(eventName, activate, true); + } + remoteWindow.addEventListener("focus", activate, true); + remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = true; + remoteWindow.__a0XpraDesktopKeyboardBridgeCleanup = () => { + for (const eventName of events) { + remoteDocument.removeEventListener(eventName, activate, true); + } + remoteWindow.removeEventListener("focus", activate, true); + remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = false; + }; + }, + + ensureDesktopKeyboardBridge() { + if (this._desktopKeyboardCleanup) return; + + const deactivateWhenOutsideDesktop = (event) => { + const target = event.target; + if (target?.closest?.(".office-desktop-wrap") || target?.matches?.("[data-office-desktop-frame]")) return; + this._desktopKeyboardActive = false; + }; + const forwardKeyboardEvent = (event, pressed) => { + if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return; + if (event.defaultPrevented || isEditableInputTarget(event.target)) return; + + const frame = this.desktopFrame(); + if (!frame || document.activeElement === frame) return; + const client = frame.contentWindow?.client; + const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup; + if (!client?.capture_keyboard || typeof handler !== "function") return; + if (pressed && this.isDesktopPasteShortcut(event)) { + void this.syncHostClipboardToDesktop(frame); + } + + const allowDefault = handler.call(client, event); + if (!allowDefault) { + event.preventDefault(); + event.stopPropagation(); + } + }; + const onKeydown = (event) => forwardKeyboardEvent(event, true); + const onKeyup = (event) => forwardKeyboardEvent(event, false); + + document.addEventListener("pointerdown", deactivateWhenOutsideDesktop, true); + document.addEventListener("keydown", onKeydown, true); + document.addEventListener("keyup", onKeyup, true); + this._desktopKeyboardCleanup = () => { + document.removeEventListener("pointerdown", deactivateWhenOutsideDesktop, true); + document.removeEventListener("keydown", onKeydown, true); + document.removeEventListener("keyup", onKeyup, true); + this._desktopKeyboardActive = false; + this._desktopKeyboardCleanup = null; + }; + }, + + stopDesktopKeyboardBridge() { + this._desktopKeyboardCleanup?.(); + }, + + queueDesktopResize(options = {}) { + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; + const token = this.session?.desktop?.token || ""; + const frame = this.desktopFrame(options.frame || null); + const target = frame?.parentElement || frame; + if (!token || !target) return; + const force = Boolean(options.force); + const serverResize = options.serverResize !== false; + const rect = target.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (width < 320 || height < 220) return; + const key = `${token}:${width}x${height}`; + const refreshFrameOnly = () => { + this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false }); + }; + if (!serverResize) { + refreshFrameOnly(); + return; + } + if (key === this._desktopResizeKey || key === this._desktopResizePendingKey) { + refreshFrameOnly(); + return; + } + refreshFrameOnly(); + if (!force && this.shouldDeferDesktopResize()) { + this._desktopResizePending = true; + return; + } + if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer); + this._desktopResizePendingKey = key; + this._desktopResizeTimer = globalThis.setTimeout(async () => { + this._desktopResizeTimer = null; + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) { + if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = ""; + return; + } + if (!force && this.shouldDeferDesktopResize()) { + if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = ""; + this._desktopResizePending = true; + return; + } + try { + const params = new URLSearchParams({ token, width: String(width), height: String(height) }); + const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" }); + if (response.ok) { + const result = await response.json().catch(() => ({})); + const displaySize = this.rememberDesktopDisplaySize( + token, + result?.width || width, + result?.height || height, + ); + this._desktopResizeKey = key; + const activeFrame = this.desktopFrame(frame); + const activeTarget = activeFrame?.parentElement || activeFrame; + const activeRect = activeTarget?.getBoundingClientRect?.(); + const activeWidth = Math.round(activeRect?.width || 0); + const activeHeight = Math.round(activeRect?.height || 0); + if (activeWidth >= 320 && activeHeight >= 220) { + const activeKey = `${token}:${activeWidth}x${activeHeight}`; + if (activeKey !== key) { + this.queueDesktopResize({ force: true, serverResize: true, frame: activeFrame }); + return; + } + } + if (result?.reload) this.reloadDesktopFrame(activeFrame || frame); + this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame, token, displaySize }); + } + } catch (error) { + console.warn("Desktop resize skipped", error); + } finally { + if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = ""; + } + }, DESKTOP_RESIZE_DELAY_MS); + }, + + reloadDesktopFrame(frame = null) { + const target = this.desktopFrame(frame); + if (!target) return; + const current = target.getAttribute("src") || target.src || this.officialOfficeUrl(); + if (!current) return; + try { + const url = new URL(current, window.location.href); + url.searchParams.set("a0_reload", String(Date.now())); + target.setAttribute("src", `${url.pathname}${url.search}`); + } catch { + target.setAttribute("src", current); + } + }, + + async handleDesktopUrlIntents(intents = []) { + const incoming = Array.isArray(intents) + ? intents.filter((intent) => intent && typeof intent === "object") + : []; + if (!incoming.length) return; + this._desktopUrlIntentQueue.push(...incoming); + if (this._desktopUrlIntentBusy) return; + + this._desktopUrlIntentBusy = true; + try { + while (this._desktopUrlIntentQueue.length) { + const intent = this._desktopUrlIntentQueue.shift(); + await this.openDesktopUrlIntent(intent); + } + } finally { + this._desktopUrlIntentBusy = false; + } + }, + + async openDesktopUrlIntent(intent = {}) { + const url = String(intent?.url || "").trim(); + const handled = await handleUrlIntent({ url, source: "desktop-url" }); + const isEditorIntent = url.startsWith("a0-editor:"); + this.setMessage( + handled + ? (isEditorIntent ? "Opened text in Editor" : "Opened link in Browser") + : (isEditorIntent ? "Editor is not available" : "Browser is not available"), + ); + }, + + browserDestinationForDesktopUrl() { + if (this.isDesktopInModal()) return "canvas"; + return "modal"; + }, + + isDesktopInModal() { + const modalDesktop = Array.from(document.querySelectorAll(".office-panel")) + .some((panel) => panel.closest?.(".modal") && panel.querySelector?.("[data-office-desktop-frame]")); + if (modalDesktop) return true; + return this._mode === "modal"; + }, + + startDesktopMonitor() { + this.stopDesktopMonitor(); + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; + const tabId = this.session?.tab_id || ""; + const sessionId = this.session?.desktop_session_id || this.session?.session_id || ""; + if (!tabId || !sessionId) return; + this._desktopHeartbeatSessionId = sessionId; + this._desktopHeartbeatTabId = tabId; + this._desktopHeartbeatMisses = 0; + + const tick = async () => { + if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; + try { + const response = await callDesktop("sync", { + desktop_session_id: sessionId, + file_id: this.session.file_id || "", + }); + if (response?.intentional_shutdown || response?.shutdown) { + await this.handleIntentionalDesktopShutdown(response); + return; + } + if (response?.ok === false) throw new Error(response.error || "Desktop session closed."); + this._desktopHeartbeatMisses = 0; + await this.handleDesktopUrlIntents(response?.url_intents); + if (response?.document) { + const document = normalizeDocument(response.document); + this.replaceActiveSession({ + ...this.session, + document, + path: document.path || this.session.path, + file_id: document.file_id || this.session.file_id, + version: document.version || this.session.version, + }); + } + } catch { + if (!this.session || this.session.tab_id !== tabId) return; + this._desktopHeartbeatMisses += 1; + if (this._desktopHeartbeatMisses >= 2) { + await this.handleOfficialOfficeClosed(tabId); + } + } + }; + + this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS); + globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS)); + }, + + stopDesktopMonitor() { + if (this._desktopHeartbeatTimer) { + globalThis.clearInterval(this._desktopHeartbeatTimer); + } + this._desktopHeartbeatTimer = null; + this._desktopHeartbeatSessionId = ""; + this._desktopHeartbeatTabId = ""; + this._desktopHeartbeatMisses = 0; + }, + + async handleOfficialOfficeClosed(tabId) { + if (this._desktopIntentionalShutdown) return; + const tab = this.tabs.find((item) => item.tab_id === tabId); + const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session) + ? this.session + : null; + const target = tab || hiddenDesktopDocument; + if (!target || target._desktopClosed) return; + target._desktopClosed = true; + this.stopDesktopMonitor(); + this.stopDesktopResizeObserver(); + this.stopXpraDesktopPrime(); + this.message = "Desktop is restarting"; + await this.ensureDesktopSession({ + force: true, + select: this.activeTabId === tabId || Boolean(hiddenDesktopDocument), + message: "Desktop is restarting", + }); + target._desktopClosed = false; + await this.refresh(); + }, + + defaultTitle(kind, fmt) { + const date = new Date().toISOString().slice(0, 10); + if (fmt === "odt") return `Writer ${date}`; + if (fmt === "docx") return `DOCX ${date}`; + if (kind === "spreadsheet") return `Spreadsheet ${date}`; + if (kind === "presentation") return `Presentation ${date}`; + return `Document ${date}`; + }, + + tabTitle(tab = {}) { + tab = tab || {}; + return tab.title || tab.document?.basename || basename(tab.path); + }, + + tabLabel(tab = {}) { + tab = tab || {}; + const title = this.tabTitle(tab); + return tab.dirty ? `${title} unsaved` : title; + }, + + tabIcon(tab = {}) { + tab = tab || {}; + const ext = String(tab.extension || tab.document?.extension || "").toLowerCase(); + if (this.isDesktopSession(tab)) return "desktop_windows"; + if (ext === "md") return "article"; + if (ext === "odt" || ext === "docx") return "description"; + if (ext === "ods" || ext === "xlsx") return "table_chart"; + if (ext === "odp" || ext === "pptx") return "co_present"; + if (ext === "txt") return "notes"; + return "draft"; + }, + + async runNewMenuAction(action = "") { + const normalized = String(action || "").trim().toLowerCase(); + if (normalized === "open") return await this.openFileBrowser(); + if (normalized === "writer") return await this.create("document", "odt"); + if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods"); + if (normalized === "presentation") return await this.create("presentation", "odp"); + return null; + }, + + installHeaderNewMenu(header = null) { + if (!header || header.querySelector(".office-header-actions")) return () => {}; + + const root = globalThis.document.createElement("div"); + root.className = "office-header-actions surface-modal-new-action"; + root.innerHTML = ` + + + `; + + const button = root.querySelector(".office-header-new-button"); + const menu = root.querySelector(".office-new-menu"); + const setOpen = (open) => { + root.classList.toggle("is-open", open); + button?.setAttribute("aria-expanded", open.toString()); + if (menu) menu.hidden = !open; + }; + const onButtonClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + setOpen(!root.classList.contains("is-open")); + }; + const onDocumentClick = (event) => { + if (!root.contains(event.target)) setOpen(false); + }; + const onDocumentKeydown = (event) => { + if (event.key === "Escape") setOpen(false); + }; + + button?.addEventListener("click", onButtonClick); + for (const item of root.querySelectorAll("[data-office-new-action]")) { + item.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + const action = event.currentTarget?.dataset?.officeNewAction || ""; + setOpen(false); + await this.runNewMenuAction(action); + }); + } + globalThis.document.addEventListener("click", onDocumentClick); + globalThis.document.addEventListener("keydown", onDocumentKeydown); + + placeSurfaceModalHeaderAction(header, root, "new"); + + setOpen(false); + return () => { + button?.removeEventListener("click", onButtonClick); + globalThis.document.removeEventListener("click", onDocumentClick); + globalThis.document.removeEventListener("keydown", onDocumentKeydown); + root.remove(); + }; + }, + + setupFloatingModal(element = null) { + const root = element || globalThis.document?.querySelector(".office-panel"); + const modal = root?.closest?.(".modal"); + const inner = root?.closest?.(".modal-inner"); + const body = root?.closest?.(".modal-bd"); + const header = inner?.querySelector?.(".modal-header"); + if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return; + + inner.dataset.officeModalReady = "1"; + modal?.classList?.add("surface-floating", "modal-floating", "modal-no-backdrop"); + inner.classList.add("surface-modal", "office-modal", "modal-no-backdrop"); + body.classList.add("office-modal-body"); + header.style.cursor = "move"; + + const inset = 8; + const minWidth = 720; + const minHeight = 520; + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const cleanup = []; + let beforeFocusBounds = null; + let dragging = false; + let resizing = false; + let pointerId = 0; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + let startWidth = 0; + let startHeight = 0; + let resizeMode = ""; + + const newMenuCleanup = this.installHeaderNewMenu(header); + + const currentBounds = () => { + const rect = inner.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }; + + const normalizedBounds = (bounds) => { + const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2); + const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2); + const safeMinWidth = Math.min(minWidth, maxWidth); + const safeMinHeight = Math.min(minHeight, maxHeight); + const width = clamp(bounds.width, safeMinWidth, maxWidth); + const height = clamp(bounds.height, safeMinHeight, maxHeight); + return { + width, + height, + left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)), + top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)), + }; + }; + + const setBounds = (bounds) => { + const next = normalizedBounds(bounds); + inner.style.position = "fixed"; + inner.style.transform = "none"; + inner.style.left = `${Math.round(next.left)}px`; + inner.style.top = `${Math.round(next.top)}px`; + inner.style.width = `${Math.round(next.width)}px`; + inner.style.height = `${Math.round(next.height)}px`; + inner.style.right = "auto"; + inner.style.bottom = "auto"; + inner.style.margin = "0"; + }; + + const ensurePosition = () => { + setBounds(currentBounds()); + }; + + const shield = globalThis.document.createElement("div"); + shield.className = "office-modal-input-shield"; + inner.appendChild(shield); + cleanup.push(() => shield.remove()); + + const setShield = (visible, cursor = "") => { + shield.style.display = visible ? "block" : "none"; + shield.style.cursor = cursor; + }; + + const focusButton = globalThis.document.createElement("button"); + focusButton.type = "button"; + focusButton.className = "surface-button office-modal-focus-button"; + focusButton.innerHTML = ''; + const updateFocusButton = (active) => { + const label = active ? "Restore size" : "Focus mode"; + focusButton.setAttribute("aria-label", label); + focusButton.setAttribute("title", label); + focusButton.querySelector("x-icon").name = active ? "fullscreen_exit" : "fullscreen"; + }; + updateFocusButton(false); + placeSurfaceModalHeaderAction(header, focusButton, "window"); + cleanup.push(() => focusButton.remove()); + + const setFocusMode = (enabled) => { + ensurePosition(); + if (enabled) { + beforeFocusBounds = currentBounds(); + inner.classList.add("is-focus-mode"); + setBounds({ + left: inset, + top: inset, + width: globalThis.innerWidth - inset * 2, + height: globalThis.innerHeight - inset * 2, + }); + updateFocusButton(true); + return; + } + inner.classList.remove("is-focus-mode"); + setBounds(beforeFocusBounds || currentBounds()); + beforeFocusBounds = null; + updateFocusButton(false); + }; + + const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode")); + focusButton.addEventListener("click", onFocusClick); + cleanup.push(() => focusButton.removeEventListener("click", onFocusClick)); + + const onPointerDown = (event) => { + if (event.button !== 0) return; + if (event.target?.closest?.("button,a,input,textarea,select")) return; + if (inner.classList.contains("is-focus-mode")) return; + ensurePosition(); + const rect = inner.getBoundingClientRect(); + dragging = true; + pointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + startLeft = rect.left; + startTop = rect.top; + startWidth = rect.width; + startHeight = rect.height; + inner.classList.add("is-dragging"); + setShield(true, "move"); + header.setPointerCapture?.(pointerId); + event.preventDefault(); + }; + + const onPointerMove = (event) => { + if (!dragging || event.pointerId !== pointerId) return; + setBounds({ + left: startLeft + event.clientX - startX, + top: startTop + event.clientY - startY, + width: startWidth, + height: startHeight, + }); + }; + + const onPointerUp = (event) => { + if (!dragging || event.pointerId !== pointerId) return; + dragging = false; + inner.classList.remove("is-dragging"); + setShield(false); + header.releasePointerCapture?.(pointerId); + }; + + const createResizeHandle = (mode) => { + const handle = globalThis.document.createElement("div"); + handle.className = `office-modal-resizer is-${mode}`; + handle.dataset.officeResize = mode; + inner.appendChild(handle); + cleanup.push(() => handle.remove()); + return handle; + }; + + const onResizeDown = (event) => { + if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return; + ensurePosition(); + const rect = inner.getBoundingClientRect(); + resizing = true; + resizeMode = event.currentTarget.dataset.officeResize || ""; + pointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + startLeft = rect.left; + startTop = rect.top; + startWidth = rect.width; + startHeight = rect.height; + inner.classList.add("is-resizing"); + this.suspendDesktopResize(); + setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize"); + event.currentTarget.setPointerCapture?.(pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + + const onResizeMove = (event) => { + if (!resizing || event.pointerId !== pointerId) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + setBounds({ + left: startLeft, + top: startTop, + width: resizeMode === "bottom" ? startWidth : startWidth + dx, + height: resizeMode === "right" ? startHeight : startHeight + dy, + }); + }; + + const onResizeUp = (event) => { + if (!resizing || event.pointerId !== pointerId) return; + resizing = false; + resizeMode = ""; + inner.classList.remove("is-resizing"); + setShield(false); + event.currentTarget.releasePointerCapture?.(pointerId); + this.resumeDesktopResize(); + }; + + header.addEventListener("pointerdown", onPointerDown); + header.addEventListener("pointermove", onPointerMove); + header.addEventListener("pointerup", onPointerUp); + header.addEventListener("pointercancel", onPointerUp); + cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown)); + cleanup.push(() => header.removeEventListener("pointermove", onPointerMove)); + cleanup.push(() => header.removeEventListener("pointerup", onPointerUp)); + cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp)); + + for (const mode of ["right", "bottom", "corner"]) { + const handle = createResizeHandle(mode); + handle.addEventListener("pointerdown", onResizeDown); + handle.addEventListener("pointermove", onResizeMove); + handle.addEventListener("pointerup", onResizeUp); + handle.addEventListener("pointercancel", onResizeUp); + cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown)); + cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove)); + cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp)); + cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp)); + } + + const onWindowResize = () => { + if (inner.classList.contains("is-focus-mode")) { + setBounds({ + left: inset, + top: inset, + width: globalThis.innerWidth - inset * 2, + height: globalThis.innerHeight - inset * 2, + }); + return; + } + ensurePosition(); + }; + globalThis.addEventListener("resize", onWindowResize); + cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize)); + + if (globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame(ensurePosition); + } else { + globalThis.setTimeout(ensurePosition, 0); + } + this._floatingCleanup = () => { + newMenuCleanup?.(); + cleanup.splice(0).reverse().forEach((entry) => entry()); + modal?.classList?.remove("surface-floating", "modal-floating", "modal-no-backdrop"); + inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode"); + this._desktopResizeSuspended = false; + this._desktopResizePending = false; + delete inner.dataset.officeModalReady; + }; + }, +}; + +export const store = createStore("desktop", model); diff --git a/plugins/_desktop/webui/main.html b/plugins/_desktop/webui/main.html new file mode 100644 index 0000000000000000000000000000000000000000..9d864347b34ae38d234b1e339d8460ab6c31ba7b --- /dev/null +++ b/plugins/_desktop/webui/main.html @@ -0,0 +1,21 @@ + + + Desktop + + + + + + diff --git a/plugins/_desktop/webui/thumbnail.jpg b/plugins/_desktop/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2f02c2195b34bf5c91c4d9d87460e7d7d9085450 Binary files /dev/null and b/plugins/_desktop/webui/thumbnail.jpg differ diff --git a/plugins/_discovery/AGENTS.md b/plugins/_discovery/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..91a769b8bdb3947c81f3b65f8f69d388781d37fe --- /dev/null +++ b/plugins/_discovery/AGENTS.md @@ -0,0 +1,31 @@ +# Plugin Discovery DOX + +## Purpose + +- Own contextual plugin discovery cards and welcome-screen promotions. + +## Ownership + +- `plugin.yaml` owns metadata and always-enabled status. +- `extensions/` owns backend or WebUI discovery contributions. +- `webui/discovery-store.js` owns discovery UI state and actions. + +## Local Contracts + +- Discovery cards must be accurate, dismissible where appropriate, and avoid advertising already-complete setup. +- CTA actions must match supported welcome-screen action contracts. +- Keep card IDs unique and plugin-prefixed. +- The Welcome screen `welcome-actions-end` surface renders feature channel cards plus the compact OAuth account-provider card; other hero discovery cards stay out of the lower welcome grid. +- Do not hide discovery cards solely because model setup is incomplete; model setup gating belongs to the chat thread. + +## Work Guidance + +- Prefer backend status checks before surfacing setup or integration prompts. + +## Verification + +- Smoke-test welcome-screen discovery cards, ordering, dismissal, and CTA behavior after changes. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_discovery/extensions/python/banners/10_discovery_cards.py b/plugins/_discovery/extensions/python/banners/10_discovery_cards.py new file mode 100644 index 0000000000000000000000000000000000000000..460f52ee8723e46a255ee627d9d988e7d81e785f --- /dev/null +++ b/plugins/_discovery/extensions/python/banners/10_discovery_cards.py @@ -0,0 +1,154 @@ +from helpers.extension import Extension +from helpers import plugins + + +class DiscoveryCardsExtension(Extension): + """Injects discovery cards into the banners list.""" + + def _oauth_summary(self) -> dict: + try: + from plugins._oauth.helpers.providers import provider_registry + from plugins._oauth.helpers.route_bootstrap import is_installed + from plugins._oauth.helpers.summary import build_oauth_status_summary + + return build_oauth_status_summary( + provider_registry=provider_registry, + routes_installed=is_installed, + ) + except Exception: + return {} + + def _oauth_usage_windows(self, summary: dict) -> list[dict]: + windows: list[dict] = [] + accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {} + connected = accounts.get("connected", []) if isinstance(accounts, dict) else [] + for account in connected: + if not isinstance(account, dict): + continue + provider_name = account.get("short_name") or account.get("display_name") or "Account" + for window in account.get("usage_windows") or []: + if not isinstance(window, dict): + continue + windows.append({ + **window, + "key": f'{account.get("provider_id", "oauth")}-{window.get("key", "usage")}', + "title": f'{provider_name} {window.get("title") or "Usage"}', + }) + return windows[:4] + + def _oauth_account_chips(self, summary: dict) -> list[dict]: + accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {} + if not isinstance(accounts, dict): + return [] + connected = accounts.get("connected") or [] + available = accounts.get("available") or [] + source = connected if connected else available + chips = [] + for account in source[:4]: + if not isinstance(account, dict): + continue + chips.append({ + "provider_id": account.get("provider_id") or "", + "label": account.get("short_name") or account.get("display_name") or account.get("provider_id"), + "detail": account.get("account_label") if account.get("connected") else "Available", + "connected": bool(account.get("connected")), + }) + return chips + + async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs): + # Optional logic: only show specific cards if plugins aren't already configured. + # Telegram, Email, Whatsapp are built-in, so we only need to check if they've been configured. + + telegram_config = plugins.get_plugin_config("_telegram_integration") or {} + email_config = plugins.get_plugin_config("_email_integration") or {} + whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {} + oauth_summary = self._oauth_summary() + oauth_accounts = oauth_summary.get("oauth_accounts") if isinstance(oauth_summary, dict) else {} + connected_count = int(oauth_accounts.get("connected_count") or 0) if isinstance(oauth_accounts, dict) else 0 + total_count = int(oauth_accounts.get("total_count") or 0) if isinstance(oauth_accounts, dict) else 0 + + # 1. Plugin Hub Hero + banners.append({ + "id": "discovery-plugin-hub", + "type": "hero", + "title": "Discover the Plugin Hub", + "description": "Extend Agent Zero with integrations, tools, and automations from the community.", + "thumbnail": "/plugins/_discovery/webui/assets/hero-plugin-hub.png", + "icon": "extension", + "cta_text": "Explore Plugins", + "cta_action": "open-plugin-hub", + "dismissible": True, + "priority": 100, + "show_in_onboarding": True + }) + + # 2. Telegram + if not telegram_config.get("bot_token"): + banners.append({ + "id": "discovery-telegram", + "type": "feature", + "title": "Telegram", + "description": "Chat on Telegram wherever you are.", + "thumbnail": "/plugins/_discovery/webui/assets/thumb-telegram.png", + "icon": "send", + "cta_text": "Connect", + "cta_action": "open-plugin-config:_telegram_integration", + "dismissible": True, + "priority": 50, + "show_in_onboarding": True + }) + + # 3. Email + email_handlers = email_config.get("handlers") or [] + email_is_configured = any((handler or {}).get("username") for handler in email_handlers) + if not email_is_configured: + banners.append({ + "id": "discovery-email", + "type": "feature", + "title": "Email", + "description": "Let Agent Zero read and send emails on your behalf.", + "thumbnail": "/plugins/_discovery/webui/assets/thumb-email.png", + "icon": "mail", + "cta_text": "Connect", + "cta_action": "open-plugin-config:_email_integration", + "dismissible": True, + "priority": 50, + "show_in_onboarding": True + }) + + # 4. WhatsApp + if not whatsapp_config.get("phone_number_id"): + banners.append({ + "id": "discovery-whatsapp", + "type": "feature", + "title": "WhatsApp", + "description": "Send and receive WhatsApp messages through A0.", + "thumbnail": "/plugins/_discovery/webui/assets/thumb-whatsapp.png", + "icon": "chat", + "cta_text": "Connect", + "cta_action": "open-plugin-config:_whatsapp_integration", + "dismissible": True, + "priority": 50, + "show_in_onboarding": True + }) + + # 5. OAuth account providers + oauth_card = { + "id": "discovery-oauth-accounts", + "type": "hero", + "placement": "after-features", + "title": "Your AI accounts", + "description": "Use your subscription-backed logins for model access." + if not connected_count + else "", + "cta_text": "Connect accounts" if not connected_count else "Manage accounts", + "cta_action": "open-plugin-config:_oauth", + "dismissible": True, + "priority": 40, + "show_in_onboarding": True, + "account_chips": self._oauth_account_chips(oauth_summary), + } + usage_windows = self._oauth_usage_windows(oauth_summary) + if usage_windows: + oauth_card["usage_windows"] = usage_windows + banners.append(oauth_card) diff --git a/plugins/_discovery/extensions/webui/initFw_end/auto-modal.js b/plugins/_discovery/extensions/webui/initFw_end/auto-modal.js new file mode 100644 index 0000000000000000000000000000000000000000..2fcce0076ee693adeaf4b4c766f0e6a58f659e2c --- /dev/null +++ b/plugins/_discovery/extensions/webui/initFw_end/auto-modal.js @@ -0,0 +1,136 @@ +import { callJsonApi } from "/js/api.js"; +import { isModalOpen } from "/js/modals.js"; + +const DEFAULT_SURFACE = "welcome"; +const STARTUP_DELAY_MS = 650; +const SUPPRESSION_PREFIX = "discovery_auto_modal_closed"; + +let initialized = false; +let lastOpened = null; +let checking = false; + +function cleanPath(path = "") { + return String(path || "").replace(/^\/+/, ""); +} + +function currentContextId(fallback = "") { + try { + return fallback || globalThis.getContext?.() || sessionStorage.getItem("lastSelectedChat") || "welcome"; + } catch { + return fallback || "welcome"; + } +} + +function bannerSupportsSurface(banner, surface) { + const surfaces = banner?.auto_modal_surfaces; + return !Array.isArray(surfaces) || surfaces.length === 0 || surfaces.includes(surface); +} + +function suppressionKey({ surface, ctxid, reason, path }) { + return `${SUPPRESSION_PREFIX}:${surface}:${ctxid || "none"}:${reason || "unknown"}:${cleanPath(path)}`; +} + +function isSuppressed(item) { + try { + return sessionStorage.getItem(suppressionKey(item)) === "1"; + } catch { + return false; + } +} + +function suppress(item) { + try { + sessionStorage.setItem(suppressionKey(item), "1"); + } catch { + // no-op + } +} + +function modalAlreadyOpen(path) { + return isModalOpen(path); +} + +async function fetchAutoModalBanner(surface, ctxid) { + const response = await callJsonApi("/banners", { + banners: [], + context: { + is_welcome: surface === "welcome", + is_onboarding: document.body.dataset.mode === "onboarding", + surface, + ctxid, + }, + }); + + const banners = Array.isArray(response?.banners) ? response.banners : []; + return banners + .filter((banner) => banner?.auto_modal_path) + .filter((banner) => bannerSupportsSurface(banner, surface)) + .sort((left, right) => (Number(right.auto_modal_priority || 0) - Number(left.auto_modal_priority || 0)))[0] || null; +} + +async function maybeOpenAutoModal(surface = DEFAULT_SURFACE, detail = {}) { + if (checking) return; + checking = true; + const ctxid = currentContextId(detail.ctxid || ""); + + try { + const banner = await fetchAutoModalBanner(surface, ctxid); + if (!banner?.auto_modal_path) return; + + const item = { + surface, + ctxid, + path: banner.auto_modal_path, + reason: banner.auto_modal_reason || banner.id || "auto-modal", + }; + + if (isSuppressed(item) || modalAlreadyOpen(item.path)) return; + + const opener = globalThis.ensureModalOpen || globalThis.openModal; + if (!opener) return; + + lastOpened = item; + await opener(item.path); + } catch (error) { + console.error("Discovery auto-modal check failed:", error); + } finally { + checking = false; + } +} + +function handleModalClosed(event) { + const closedPath = event?.detail?.modalPath || ""; + if (lastOpened && cleanPath(closedPath) === cleanPath(lastOpened.path)) { + suppress(lastOpened); + const surface = lastOpened.surface; + const ctxid = lastOpened.ctxid; + lastOpened = null; + window.setTimeout(() => { + void maybeOpenAutoModal(surface, { ctxid }); + }, 250); + return; + } + + window.setTimeout(() => { + void maybeOpenAutoModal(DEFAULT_SURFACE); + }, 250); +} + +function handleChatCreated(event) { + const ctxid = event?.detail?.ctxid || ""; + window.setTimeout(() => { + void maybeOpenAutoModal("chat-created", { ctxid }); + }, 300); +} + +export default function initDiscoveryAutoModal() { + if (initialized) return; + initialized = true; + + document.addEventListener("modal-closed", handleModalClosed); + document.addEventListener("chat-created", handleChatCreated); + + window.setTimeout(() => { + void maybeOpenAutoModal(DEFAULT_SURFACE); + }, STARTUP_DELAY_MS); +} diff --git a/plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html b/plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html new file mode 100644 index 0000000000000000000000000000000000000000..faad832687798a014e08df2b9e57ec0f53c18cd0 --- /dev/null +++ b/plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html @@ -0,0 +1,597 @@ + + +
+ + + +
diff --git a/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html b/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html new file mode 100644 index 0000000000000000000000000000000000000000..32c82dc32531c8cd2383f8e572270806aee718b2 --- /dev/null +++ b/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html @@ -0,0 +1,557 @@ + + +
+ + + +
diff --git a/plugins/_discovery/plugin.yaml b/plugins/_discovery/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ceeba7b209d980a6ec04cc621bd6d628e4c87e08 --- /dev/null +++ b/plugins/_discovery/plugin.yaml @@ -0,0 +1,8 @@ +name: _discovery +title: Plugin Discovery +description: Contextual discovery cards on the welcome screen promoting plugins and integrations. +version: 1.0.0 +settings_sections: [] +always_enabled: true +per_project_config: false +per_agent_config: false diff --git a/plugins/_discovery/webui/assets/cli-connector-preview.png b/plugins/_discovery/webui/assets/cli-connector-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..9865459ed1b615971d4b9aa6d76ff68435c5813c Binary files /dev/null and b/plugins/_discovery/webui/assets/cli-connector-preview.png differ diff --git a/plugins/_discovery/webui/assets/hero-openai-oauth.png b/plugins/_discovery/webui/assets/hero-openai-oauth.png new file mode 100644 index 0000000000000000000000000000000000000000..eba0d148586b62d5564e012011126318af204eea Binary files /dev/null and b/plugins/_discovery/webui/assets/hero-openai-oauth.png differ diff --git a/plugins/_discovery/webui/assets/hero-plugin-hub.png b/plugins/_discovery/webui/assets/hero-plugin-hub.png new file mode 100644 index 0000000000000000000000000000000000000000..95068aaf53e4a24f0dd077a6f1ab9f9781c53987 Binary files /dev/null and b/plugins/_discovery/webui/assets/hero-plugin-hub.png differ diff --git a/plugins/_discovery/webui/assets/thumb-email.png b/plugins/_discovery/webui/assets/thumb-email.png new file mode 100644 index 0000000000000000000000000000000000000000..717cc63699ef4fbc40e16693039d8424f9ab60f8 Binary files /dev/null and b/plugins/_discovery/webui/assets/thumb-email.png differ diff --git a/plugins/_discovery/webui/assets/thumb-telegram.png b/plugins/_discovery/webui/assets/thumb-telegram.png new file mode 100644 index 0000000000000000000000000000000000000000..9d00c60dd9f7b50e097c20233bf85862f3451d3e Binary files /dev/null and b/plugins/_discovery/webui/assets/thumb-telegram.png differ diff --git a/plugins/_discovery/webui/assets/thumb-whatsapp.png b/plugins/_discovery/webui/assets/thumb-whatsapp.png new file mode 100644 index 0000000000000000000000000000000000000000..37e7d4877c5e4847e1fca240e6549aa0c9e37a56 Binary files /dev/null and b/plugins/_discovery/webui/assets/thumb-whatsapp.png differ diff --git a/plugins/_discovery/webui/discovery-store.js b/plugins/_discovery/webui/discovery-store.js new file mode 100644 index 0000000000000000000000000000000000000000..2feb1756f042bb802812393aa807de4d463967f4 --- /dev/null +++ b/plugins/_discovery/webui/discovery-store.js @@ -0,0 +1,190 @@ +import { createStore } from "/js/AlpineStore.js"; +import { toastFrontendError } from "/components/notifications/notification-store.js"; +import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js"; +import * as API from "/js/api.js"; + +const STORAGE_KEY = "dismissed_discovery_cards"; + +const model = { + // --- State --- + /** @type {any[]} */ + cards: [], + hasDismissedCards: false, + _initialized: false, + _isLoading: false, + + // --- Lifecycle --- + init() { + if (this._initialized) return; + this._initialized = true; + }, + + // --- Actions --- + + async refreshCards() { + if (this._isLoading) return; + this._isLoading = true; + + try { + const response = await API.callJsonApi("/banners", { + banners: [], + context: { + is_onboarding: document.body.dataset.mode === "onboarding" + }, + }); + + const banners = response?.banners || []; + const dismissed = this._getDismissedIds(); + + // Filter out standard banners, keep only hero and feature + // Also respect the onboarding filtering + const is_onboarding = document.body.dataset.mode === "onboarding"; + + this.cards = banners + .filter((card) => card.type === "hero" || card.type === "feature") + .filter((card) => !is_onboarding || card.show_in_onboarding === true) + .filter((card) => !dismissed.has(card.id)) + .sort((left, right) => (right.priority || 0) - (left.priority || 0)); + + this.hasDismissedCards = dismissed.size > 0; + } catch (error) { + console.error("Failed to fetch discovery cards:", error); + } finally { + this._isLoading = false; + } + }, + + dismissCard(cardId) { + const dismissed = this._getDismissedIds(); + dismissed.add(cardId); + this._persistDismissedIds(dismissed); + + // Optimistically update UI + this.cards = this.cards.filter(c => c.id !== cardId); + this.hasDismissedCards = true; + }, + + dismissFeatureCards() { + const featureIds = this.featureCards + .filter((card) => card.dismissible !== false) + .map((card) => card.id) + .filter(Boolean); + + if (!featureIds.length) return; + + const dismissed = this._getDismissedIds(); + const dismissSet = new Set(featureIds); + for (const id of dismissSet) { + dismissed.add(id); + } + this._persistDismissedIds(dismissed); + + this.cards = this.cards.filter((card) => !dismissSet.has(card.id)); + this.hasDismissedCards = true; + }, + + undismissCards() { + localStorage.removeItem(STORAGE_KEY); + this.refreshCards(); + }, + + async executeCta(action) { + if (!action) return; + + try { + if (action === "open-plugin-hub") { + await pluginListStore.open("pluginHub"); + return; + } + + if (action.startsWith("open-plugin-config:")) { + const pluginName = action.split(":")[1]; + await pluginListStore.openPluginConfig(pluginName); + return; + } + + if (action.startsWith("open-url:")) { + const url = action.slice("open-url:".length); + if (url) { + window.open(url, "_blank", "noopener,noreferrer"); + } + return; + } + } catch (error) { + console.error("Discovery action failed:", error); + const message = error instanceof Error ? error.message : String(error); + await toastFrontendError(message, "Discovery"); + } + }, + + usageWidth(window) { + const value = Math.max(0, Math.min(100, this.remainingPercent(window))); + return `${value}%`; + }, + + remainingPercent(window) { + const remaining = Number(window?.remaining_percent); + if (Number.isFinite(remaining)) return remaining; + const used = Number(window?.used_percent); + if (Number.isFinite(used)) return 100 - used; + return Number.NaN; + }, + + formatRemainingPercent(window) { + const number = this.remainingPercent(window); + if (!Number.isFinite(number)) return "0%"; + return `${Math.round(number * 10) / 10}% left`; + }, + + formatReset(window) { + const seconds = Number(window?.reset_at || 0); + if (!Number.isFinite(seconds) || seconds <= 0) return ""; + const remainingMs = Math.max(0, seconds * 1000 - Date.now()); + const minutes = Math.round(remainingMs / 60000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h`; + return `${Math.round(hours / 24)}d`; + }, + + // --- Helpers (Private-ish) --- + + _getDismissedIds() { + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); + if (!Array.isArray(raw)) return new Set(); + return new Set(raw); + } catch { + return new Set(); + } + }, + + _persistDismissedIds(ids) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(ids))); + }, + + // --- Computed --- + + get topHeroCards() { + return this.cards.filter((card) => card.type === "hero" && card.placement !== "after-features"); + }, + + get bottomHeroCards() { + return this.cards.filter((card) => card.type === "hero" && card.placement === "after-features"); + }, + + get oauthAccountCards() { + return this.bottomHeroCards.filter((card) => card.id === "discovery-oauth-accounts"); + }, + + get heroCards() { + return [...this.topHeroCards, ...this.bottomHeroCards]; + }, + + get featureCards() { + return this.cards.filter((card) => card.type === "feature"); + }, +}; + +const store = createStore("discoveryStore", model); +export { store }; diff --git a/plugins/_discovery/webui/thumbnail.jpg b/plugins/_discovery/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1d9b0e341dfbba633995bb047f3849b5f5e6e138 Binary files /dev/null and b/plugins/_discovery/webui/thumbnail.jpg differ diff --git a/plugins/_document_query/AGENTS.md b/plugins/_document_query/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..3850d116db12edeb488ff49985d515295ac9afe2 --- /dev/null +++ b/plugins/_document_query/AGENTS.md @@ -0,0 +1,31 @@ +# Document Query Plugin DOX + +## Purpose + +- Own document loading, parsing, indexing, and Q&A over local and remote documents. + +## Ownership + +- `tools/document_query.py` owns the agent-facing query tool. +- `helpers/` owns fetching, parser routing, indexing, and query orchestration. +- `prompts/` owns document-query tool and optimization prompts. +- `hooks.py`, `default_config.yaml`, `plugin.yaml`, `skills/`, `extensions/`, and `webui/` own dependency bootstrap, settings, metadata, skill guidance, hooks, and UI. + +## Local Contracts + +- Respect configured size, timeout, retry, parser concurrency, and OCR limits. +- Fetch local and remote resources safely and avoid leaking document contents beyond intended prompt/tool use. +- Keep optional dependency installation targeted to the framework runtime. + +## Work Guidance + +- Coordinate parser changes with timeout handling and fallback behavior. +- Keep prompt and skill guidance clear that image files, screenshots, scans, charts, photos, and diagrams should go to vision tools first when available; `document_query` image OCR is a text-focused fallback when vision is unavailable or cannot read the needed text. + +## Verification + +- Smoke-test representative PDF, text/HTML, image/OCR, and remote-document queries after parser changes. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_document_query/README.md b/plugins/_document_query/README.md new file mode 100644 index 0000000000000000000000000000000000000000..dfa306b73578b21b48787d5391f07a241b53dd34 --- /dev/null +++ b/plugins/_document_query/README.md @@ -0,0 +1,62 @@ +# Document Query Plugin + +Load, parse, index, and Q&A over local and remote documents with configurable +timeouts and thread-safe parsers. + +## Features + +- **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes +- **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers +- **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks +- **Adaptive OCR** - long PDFs skip OCR automatically to avoid pathological parse times +- **Adaptive indexing** - very large extracted documents increase chunk size to keep embedding work bounded +- **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats +- **Configurable timeouts** - per-document and gather-level timeouts +- **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured + +## Configuration + +See default_config.yaml for all options. Key settings: + +| Setting | Default | Description | +|---------|---------|-------------| +| fetch_timeout | 30 | HTTP fetch timeout (seconds) | +| fetch_retries | 3 | HTTP retry attempts | +| max_remote_bytes | 52428800 | Max remote document size | +| per_document_timeout | 60 | Max time for a single document parse | +| gather_timeout | 120 | Max time for all documents combined | +| parser_concurrency | 1 | Max parser jobs running across all chats in one process | +| context_intro_chunks | 2 | Leading chunks included per document for title/abstract grounding | +| chunk_size | 1000 | Text splitter chunk size | +| chunk_overlap | 100 | Text splitter overlap | +| max_index_chunks | 1200 | Maximum indexed chunks before adaptive chunk sizing, or 0 for no cap | +| search_threshold | 0.5 | Similarity search threshold | +| liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks | +| liteparse_num_workers | 2 | Max LiteParse OCR workers per parser job | +| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for PDFs at or above this effective page count | +| thread_offload | true | Offload sync parsers to thread pool | + +LiteParse is installed into the Agent Zero framework runtime from hooks.py during +plugin install/startup. If installation fails, the plugin logs the error and +continues with the legacy parser fallbacks. + +LiteParse always runs in a child process so native parser and OCR failures stay +isolated from the Web UI process. + +## Parsers + +| Parser | MIME Types | Backend | +|--------|-----------|---------| +| LiteParseParser | PDF, Office/OpenDocument, images | LiteParse | +| PdfParser | application/pdf | PyMuPDF + Tesseract OCR fallback | +| HtmlParser | text/html | Markdownify transformer | +| TextParser | text/*, application/json, YAML, XML, TOML, JS, TS, shell | Direct read | +| ImageParser | image/* | UnstructuredLoader | +| UnstructuredParser | * (catch-all) | UnstructuredLoader hi-res | + +## Adding a new parser + +1. Create helpers/parsers/.py extending BaseParser +2. Set mimetypes class attribute +3. Implement _parse_sync(document, config) +4. Register in helpers/parsers/__init__.py diff --git a/plugins/_document_query/default_config.yaml b/plugins/_document_query/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..43adb8c67cd8e1908b9e7d72784966b7df32bfda --- /dev/null +++ b/plugins/_document_query/default_config.yaml @@ -0,0 +1,37 @@ +# Document Query Plugin Configuration +# All timeout values in seconds + +# --- Timeouts --- +fetch_timeout: 30 # HTTP fetch connect/read timeout +fetch_retries: 3 # HTTP retry attempts +fetch_retry_backoff: 1.0 # delay between HTTP retry attempts +per_document_timeout: 60 # max time for a single document parse +gather_timeout: 120 # max time for all documents combined in one call + +# --- Parser settings --- +parser_concurrency: 1 # max parser jobs running across all chats in this process +context_intro_chunks: 2 # always include leading chunks per document for title/abstract grounding +chunk_size: 1000 +chunk_overlap: 100 +max_index_chunks: 1200 # adapt chunk size above this many indexed chunks +search_threshold: 0.5 +search_limit: 100 +max_remote_bytes: 52428800 # 50 MB + +# --- Feature flags --- +liteparse_enabled: true # prefer LiteParse before legacy parser fallbacks +liteparse_ocr_enabled: true +liteparse_ocr_language: eng +liteparse_ocr_server_url: +liteparse_tessdata_path: +liteparse_max_pages: 1000 +liteparse_target_pages: +liteparse_dpi: 150 +liteparse_preserve_very_small_text: false +liteparse_output_format: text +liteparse_num_workers: 2 # balanced default for OCR speed without overloading shared Web UI runtime +liteparse_ocr_auto_disable: true # disable OCR automatically for long PDFs +liteparse_ocr_auto_disable_pages: 30 # OCR-on runtime climbs sharply around this page count +liteparse_ocr_auto_sample_pages: 5 +pdf_ocr_fallback: true # enable legacy Tesseract fallback after PyMuPDF +thread_offload: true # offload sync parsers to thread pool diff --git a/plugins/_document_query/extensions/python/startup_migration/_20_document_query_runtime.py b/plugins/_document_query/extensions/python/startup_migration/_20_document_query_runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..47396fee09c0686d2a8b5f6d6a1780d5d85a1700 --- /dev/null +++ b/plugins/_document_query/extensions/python/startup_migration/_20_document_query_runtime.py @@ -0,0 +1,12 @@ +from helpers.extension import Extension +from helpers.plugins import call_plugin_hook + + +class DocumentQueryRuntime(Extension): + def execute(self, **kwargs): + call_plugin_hook( + "_document_query", + "ensure_dependencies", + raise_on_error=False, + ) + diff --git a/plugins/_document_query/helpers/__init__.py b/plugins/_document_query/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/plugins/_document_query/helpers/document_query.py b/plugins/_document_query/helpers/document_query.py new file mode 100644 index 0000000000000000000000000000000000000000..05dd112e4983a4f95e5daf5f7e2249149a151a62 --- /dev/null +++ b/plugins/_document_query/helpers/document_query.py @@ -0,0 +1,514 @@ +"""Document query helper with thread-safe parsers and configurable timeouts. + +Extracted from the monolithic helpers/document_query.py into a plugin +with parser strategy pattern where every document parser is offloaded to +a thread pool and bounded by configurable timeouts. +""" + +import asyncio +import json +import threading +from datetime import datetime +from typing import Any, Callable, List, Optional, Sequence, Tuple +from urllib.parse import urlparse + +from langchain.schema import SystemMessage, HumanMessage +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_core.documents import Document + +from helpers import files, errors +from helpers.print_style import PrintStyle +from helpers.vector_db import VectorDB +from agent import Agent + +from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_resource +from plugins._document_query.helpers.parsers import BaseParser, get_parsers_for_mimetype + + +DEFAULT_SEARCH_THRESHOLD = 0.5 +DEFAULT_PARSER_CONCURRENCY = 1 +SMALL_DOCUMENT_FALLBACK_MAX_CHARS = 12000 +_PARSER_SEMAPHORES: dict[tuple[int, int], asyncio.Semaphore] = {} +_PARSER_SEMAPHORES_LOCK = threading.Lock() + + +def _positive_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def _nonnegative_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _parser_semaphore(config: dict) -> asyncio.Semaphore: + concurrency = _positive_int( + config.get("parser_concurrency"), + DEFAULT_PARSER_CONCURRENCY, + ) + loop = asyncio.get_running_loop() + key = (id(loop), concurrency) + with _PARSER_SEMAPHORES_LOCK: + semaphore = _PARSER_SEMAPHORES.get(key) + if semaphore is None: + semaphore = asyncio.Semaphore(concurrency) + _PARSER_SEMAPHORES[key] = semaphore + return semaphore + + +def _load_config(agent: Agent) -> dict: + """Load plugin config with fallback to defaults.""" + from helpers.plugins import get_plugin_config + return get_plugin_config("_document_query", agent=agent) or {} + + +class DocumentQueryStore: + """FAISS Store for document query results.""" + + CONTEXT_DATA_KEY = "_document_query_store" + DEFAULT_CHUNK_SIZE = 1000 + DEFAULT_CHUNK_OVERLAP = 100 + DEFAULT_MAX_INDEX_CHUNKS = 1200 + _GET_LOCK = threading.RLock() + + @classmethod + def get(cls, agent: Agent): + if not agent or not agent.config: + raise ValueError("Agent and agent config must be provided") + + context = getattr(agent, "context", None) + if context is None: + return cls(agent) + + with cls._GET_LOCK: + store = context.get_data(cls.CONTEXT_DATA_KEY, recursive=False) + if not isinstance(store, cls): + store = cls(agent) + context.set_data(cls.CONTEXT_DATA_KEY, store, recursive=False) + else: + store.agent = agent + store.config = _load_config(agent) + return store + + def __init__(self, agent: Agent): + self.agent = agent + self.vector_db: VectorDB | None = None + self.config = _load_config(agent) + + @staticmethod + def normalize_uri(uri: str) -> str: + normalized = uri.strip() + parsed = urlparse(normalized) + scheme = parsed.scheme or "file" + if scheme == "file": + path = files.fix_dev_path( + normalized.removeprefix("file://").removeprefix("file:") + ) + normalized = f"file://{path}" + elif scheme in ["http", "https"]: + normalized = normalized.replace("http://", "https://") + return normalized + + def init_vector_db(self): + return VectorDB(self.agent, cache=True) + + async def add_document( + self, text: str, document_uri: str, metadata: dict | None = None + ) -> tuple[bool, list[str]]: + document_uri = self.normalize_uri(document_uri) + await self.delete_document(document_uri) + doc_metadata = metadata or {} + doc_metadata["document_uri"] = document_uri + doc_metadata["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + chunks = self._split_text_for_index(text) + docs = [] + for i, chunk in enumerate(chunks): + chunk_metadata = doc_metadata.copy() + chunk_metadata["chunk_index"] = i + chunk_metadata["total_chunks"] = len(chunks) + docs.append(Document(page_content=chunk, metadata=chunk_metadata)) + if not docs: + PrintStyle.error(f"No chunks created for document: {document_uri}") + return False, [] + try: + if not self.vector_db: + self.vector_db = self.init_vector_db() + ids = await self.vector_db.insert_documents(docs) + PrintStyle.standard(f"Added document '{document_uri}' with {len(docs)} chunks") + return True, ids + except Exception as e: + err_text = errors.format_error(e) + PrintStyle.error(f"Error adding document '{document_uri}': {err_text}") + return False, [] + + def _split_text_for_index(self, text: str) -> list[str]: + chunk_size = _positive_int( + self.config.get("chunk_size"), + self.DEFAULT_CHUNK_SIZE, + ) + chunk_overlap = min( + _nonnegative_int( + self.config.get("chunk_overlap"), + self.DEFAULT_CHUNK_OVERLAP, + ), + max(0, chunk_size - 1), + ) + chunks = self._split_text(text, chunk_size, chunk_overlap) + + max_chunks = _nonnegative_int( + self.config.get("max_index_chunks"), + self.DEFAULT_MAX_INDEX_CHUNKS, + ) + if not max_chunks or len(chunks) <= max_chunks: + return chunks + + overlap_ratio = chunk_overlap / chunk_size if chunk_size else 0 + overlap_ratio = max(0, min(overlap_ratio, 0.5)) + target_size = max( + chunk_size + 1, + int(len(text) / max(1, max_chunks * (1 - overlap_ratio))) + 1, + ) + + for _ in range(8): + target_overlap = min(int(target_size * overlap_ratio), target_size - 1) + chunks = self._split_text(text, target_size, target_overlap) + if len(chunks) <= max_chunks or target_size >= len(text): + return chunks + target_size = min(len(text), int(target_size * 1.25) + 1) + + return chunks + + @staticmethod + def _split_text(text: str, chunk_size: int, chunk_overlap: int) -> list[str]: + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ) + return text_splitter.split_text(text) + + async def get_document(self, document_uri: str) -> Optional[Document]: + if not self.vector_db: + return None + document_uri = self.normalize_uri(document_uri) + docs = await self._get_document_chunks(document_uri) + if not docs: + return None + chunks = sorted(docs, key=lambda x: x.metadata.get("chunk_index", 0)) + full_content = "\n".join(chunk.page_content for chunk in chunks) + metadata = chunks[0].metadata.copy() + metadata.pop("chunk_index", None) + metadata.pop("total_chunks", None) + return Document(page_content=full_content, metadata=metadata) + + async def _get_document_chunks(self, document_uri: str) -> List[Document]: + if not self.vector_db: + return [] + document_uri = self.normalize_uri(document_uri) + chunks = await self.vector_db.search_by_metadata( + filter=f"document_uri == '{document_uri}'", + ) + PrintStyle.standard(f"Found {len(chunks)} chunks for document: {document_uri}") + return chunks + + async def document_exists(self, document_uri: str) -> bool: + if not self.vector_db: + return False + document_uri = self.normalize_uri(document_uri) + chunks = await self._get_document_chunks(document_uri) + return len(chunks) > 0 + + async def delete_document(self, document_uri: str) -> bool: + if not self.vector_db: + return False + document_uri = self.normalize_uri(document_uri) + chunks = await self.vector_db.search_by_metadata( + filter=f"document_uri == '{document_uri}'", + ) + if not chunks: + return False + ids_to_delete = [chunk.metadata["id"] for chunk in chunks] + if ids_to_delete: + dels = await self.vector_db.delete_documents_by_ids(ids_to_delete) + PrintStyle.standard(f"Deleted document '{document_uri}' with {len(dels)} chunks") + return True + return False + + async def search_documents( + self, query: str, limit: int = 10, threshold: float = 0.5, filter: str = "" + ) -> List[Document]: + if not self.vector_db: + return [] + if not query: + return [] + try: + results = await self.vector_db.search_by_similarity_threshold( + query=query, limit=limit, threshold=threshold, filter=filter + ) + PrintStyle.standard(f"Search '{query}' returned {len(results)} results") + return results + except Exception as e: + PrintStyle.error(f"Error searching documents: {str(e)}") + return [] + + async def search_document( + self, document_uri: str, query: str, limit: int = 10, threshold: float = 0.5 + ) -> List[Document]: + return await self.search_documents( + query, limit, threshold, f"document_uri == '{document_uri}'" + ) + + async def list_documents(self) -> List[str]: + if not self.vector_db: + return [] + uris = set() + for doc in self.vector_db.db.get_all_docs().values(): + if isinstance(doc.metadata, dict): + uri = doc.metadata.get("document_uri") + if uri: + uris.add(uri) + return sorted(list(uris)) + + +class DocumentQueryHelper: + + def __init__( + self, agent: Agent, progress_callback: Callable[[str], None] | None = None + ): + self.agent = agent + self.store = DocumentQueryStore.get(agent) + self.progress_callback = progress_callback or (lambda x: None) + self.store_lock = asyncio.Lock() + self.config = _load_config(agent) + + async def document_qa( + self, document_uris: List[str] | str, questions: Sequence[str] | str + ) -> Tuple[bool, str]: + if isinstance(document_uris, str): + document_uris = [document_uris] + if isinstance(questions, str): + questions = [questions] + self.progress_callback(f"Starting Q&A process for {len(document_uris)} documents") + await self.agent.handle_intervention() + + gather_timeout = self.config.get("gather_timeout", 120) + try: + document_contents = await asyncio.wait_for( + asyncio.gather( + *[self.document_get_content(uri, True) for uri in document_uris] + ), + timeout=gather_timeout, + ) + except asyncio.TimeoutError: + raise ValueError(f"Document indexing timed out after {gather_timeout}s") + + await self.agent.handle_intervention() + search_threshold = self.config.get("search_threshold", DEFAULT_SEARCH_THRESHOLD) + search_limit = self.config.get("search_limit", 100) + selected_chunks = {} + normalized_uris = [self.store.normalize_uri(uri) for uri in document_uris] + intro_chunk_count = _positive_int( + self.config.get("context_intro_chunks"), + 2, + ) + for uri in normalized_uris: + for chunk in await self._get_document_intro_chunks(uri, intro_chunk_count): + selected_chunks[chunk.metadata["id"]] = chunk + + for question in questions: + self.progress_callback(f"Optimizing query: {question}") + await self.agent.handle_intervention() + system_content = self.agent.parse_prompt("fw.document_query.optimize_query.md") + optimized_query = ( + await self.agent.call_utility_model( + system=system_content, message=f'Search Query: "{question}"', + ) + ).strip() + + await self.agent.handle_intervention() + self.progress_callback(f"Searching documents with query: {optimized_query}") + doc_filter = " or ".join( + [f"document_uri == '{uri}'" for uri in normalized_uris] + ) + chunks = await self.store.search_documents( + query=optimized_query, limit=search_limit, + threshold=search_threshold, filter=doc_filter, + ) + self.progress_callback(f"Found {len(chunks)} chunks") + for chunk in chunks: + selected_chunks[chunk.metadata["id"]] = chunk + + if not selected_chunks: + fallback_content = self._small_document_fallback_content( + document_uris, + document_contents, + ) + if fallback_content: + self.progress_callback( + "No matching chunks found; using extracted document content" + ) + ai_response = await self._answer_questions_from_content( + fallback_content, + questions, + "extracted document content", + ) + self.progress_callback(f"Q&A process completed") + return True, ai_response + + self.progress_callback("No relevant content found in the documents") + content = f"!!! No content found for documents: {json.dumps(document_uris)} matching queries: {json.dumps(questions)}" + return False, content + + content = "\n\n----\n\n".join( + [chunk.page_content for chunk in selected_chunks.values()] + ) + ai_response = await self._answer_questions_from_content( + content, + questions, + f"{len(selected_chunks)} chunks", + ) + self.progress_callback(f"Q&A process completed") + return True, ai_response + + async def _answer_questions_from_content( + self, + content: str, + questions: Sequence[str], + context_label: str, + ) -> str: + self.progress_callback( + f"Processing {len(questions)} questions in context of {context_label}" + ) + await self.agent.handle_intervention() + questions_str = "\n".join([f" * {question}" for question in questions]) + qa_system_message = self.agent.parse_prompt("fw.document_query.system_prompt.md") + qa_user_message = f"# Document:\n{content}\n\n# Queries:\n{questions_str}" + ai_response, _reasoning = await self.agent.call_chat_model( + messages=[ + SystemMessage(content=qa_system_message), + HumanMessage(content=qa_user_message), + ], + explicit_caching=False, + ) + return str(ai_response) + + @staticmethod + def _small_document_fallback_content( + document_uris: Sequence[str], + document_contents: Sequence[str], + max_chars: int = SMALL_DOCUMENT_FALLBACK_MAX_CHARS, + ) -> str: + blocks = [] + for document_uri, document_content in zip(document_uris, document_contents): + text = (document_content or "").strip() + if text: + blocks.append(f"# Source: {document_uri}\n\n{text}") + + if not blocks: + return "" + + content = "\n\n----\n\n".join(blocks) + if len(content) > max_chars: + return "" + return content + + async def _get_document_intro_chunks( + self, + document_uri: str, + limit: int, + ) -> list[Document]: + if limit <= 0: + return [] + if not hasattr(self.store, "_get_document_chunks"): + return [] + chunks = await self.store._get_document_chunks(document_uri) + return sorted(chunks, key=lambda chunk: chunk.metadata.get("chunk_index", 0))[ + :limit + ] + + async def document_get_content( + self, document_uri: str, add_to_db: bool = False + ) -> str: + self.progress_callback(f"Fetching document content") + await self.agent.handle_intervention() + document = await fetch_public_resource( + document_uri, + self.config, + self.agent.handle_intervention, + ) + document_uri_norm = self.store.normalize_uri(document.uri) + await self.agent.handle_intervention() + exists = await self.store.document_exists(document_uri_norm) + document_content = "" + + if not exists: + await self.agent.handle_intervention() + parsers = get_parsers_for_mimetype(document.mimetype, self.config) + if not parsers: + raise ValueError( + f"No parser found for mimetype '{document.mimetype}' ({document.uri})" + ) + per_doc_timeout = self.config.get("per_document_timeout", 60) + thread_offload = self.config.get("thread_offload", True) + document_content = await self._parse_document( + document=document, + parsers=parsers, + timeout=per_doc_timeout, + thread_offload=thread_offload, + ) + if add_to_db: + self.progress_callback(f"Indexing document") + await self.agent.handle_intervention() + async with self.store_lock: + success, ids = await self.store.add_document( + document_content, document_uri_norm + ) + if not success: + self.progress_callback(f"Failed to index document") + raise ValueError(f"Failed to index document: {document_uri_norm}") + self.progress_callback(f"Indexed {len(ids)} chunks") + else: + await self.agent.handle_intervention() + doc = await self.store.get_document(document_uri_norm) + if doc: + document_content = doc.page_content + else: + raise ValueError(f"Document not found: {document_uri_norm}") + return document_content + + async def _parse_document( + self, + document: FetchedDocument, + parsers: list[BaseParser], + timeout: float, + thread_offload: bool, + ) -> str: + errors_seen = [] + semaphore = _parser_semaphore(self.config) + for parser in parsers: + try: + async with semaphore: + self.progress_callback("Parsing document content") + content = await parser.parse( + document=document, + config=self.config, + timeout=timeout, + thread_offload=thread_offload, + ) + if content: + return content + errors_seen.append(f"{parser.__class__.__name__}: no content") + except Exception as e: + errors_seen.append(f"{parser.__class__.__name__}: {e}") + PrintStyle.error(f"Document parser failed: {errors_seen[-1]}") + + raise ValueError( + f"No parser succeeded for mimetype '{document.mimetype}' ({document.uri}): " + + "; ".join(errors_seen) + ) diff --git a/plugins/_document_query/helpers/fetch.py b/plugins/_document_query/helpers/fetch.py new file mode 100644 index 0000000000000000000000000000000000000000..be1afeb90c9ed46ebd965bc83483ecc5f477d3bb --- /dev/null +++ b/plugins/_document_query/helpers/fetch.py @@ -0,0 +1,189 @@ +"""Centralized document fetching for the document_query plugin.""" + +from __future__ import annotations + +import asyncio +import mimetypes +import os +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Awaitable, Callable +from urllib.parse import urlparse + +from helpers import files +from helpers.network import fetch_public_http_resource + + +InterventionCallback = Callable[[], Awaitable[None]] + + +@dataclass(frozen=True) +class FetchedDocument: + """Fetched document bytes plus metadata needed by parsers.""" + + uri: str + scheme: str + mimetype: str + content: bytes + encoding: str | None = None + charset: str | None = None + local_path: str | None = None + source_uri: str | None = None + + def text(self) -> str: + charset = self.charset or "utf-8" + return self.content.decode(charset, errors="replace") + + def suffix(self) -> str: + path = self.local_path or urlparse(self.uri).path or self.uri + suffix = Path(path).suffix + if suffix: + return suffix + guessed = mimetypes.guess_extension(self.mimetype) + return guessed or ".bin" + + @contextmanager + def local_file(self): + """Yield a filesystem path for parsers that cannot consume bytes.""" + if self.local_path and os.path.exists(self.local_path): + yield self.local_path + return + + tmp = "" + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=self.suffix()) as f: + f.write(self.content) + tmp = f.name + yield tmp + finally: + if tmp and os.path.exists(tmp): + os.unlink(tmp) + + +ProtocolHandler = Callable[ + [str, str, dict, InterventionCallback | None], Awaitable[FetchedDocument] +] + +_PROTOCOL_HANDLERS: dict[str, ProtocolHandler] = {} + + +def register_protocol_handler(scheme: str, handler: ProtocolHandler) -> None: + """Register or replace a fetch handler for a URI scheme.""" + _PROTOCOL_HANDLERS[scheme.lower()] = handler + + +async def fetch_public_resource( + uri: str, + config: dict | None = None, + intervention_callback: InterventionCallback | None = None, +) -> FetchedDocument: + """Fetch local or remote content once, then pass bytes to parsers.""" + config = config or {} + parsed = urlparse(uri) + scheme = (parsed.scheme or "file").lower() + handler = _PROTOCOL_HANDLERS.get(scheme) + if not handler: + raise ValueError(f"Unsupported document scheme: {scheme}") + return await handler(uri, scheme, config, intervention_callback) + + +async def _fetch_file( + uri: str, + scheme: str, + config: dict, + intervention_callback: InterventionCallback | None, +) -> FetchedDocument: + parsed = urlparse(uri) + raw_path = parsed.path if parsed.scheme == "file" else uri + if not raw_path: + raise ValueError(f"Invalid document path: {uri}") + + path = _fix_file_path(raw_path) + mimetype, encoding = mimetypes.guess_type(path) + if encoding: + raise ValueError(f"Compressed documents are unsupported '{encoding}' ({uri})") + mimetype = mimetype or "application/octet-stream" + if mimetype == "application/octet-stream": + raise ValueError(f"Unsupported document mimetype '{mimetype}' ({uri})") + + if intervention_callback: + await intervention_callback() + return FetchedDocument( + uri=path, + source_uri=uri, + scheme=scheme, + mimetype=mimetype, + encoding=encoding, + content=files.read_file_bin(path), + local_path=path, + ) + + +async def _fetch_http( + uri: str, + scheme: str, + config: dict, + intervention_callback: InterventionCallback | None, +) -> FetchedDocument: + timeout = float(config.get("fetch_timeout", 30)) + retries = max(1, int(config.get("fetch_retries", 3))) + retry_backoff = float(config.get("fetch_retry_backoff", 1.0)) + max_remote_bytes = int(config.get("max_remote_bytes", 50 * 1024 * 1024)) + parsed = urlparse(uri) + guessed_mimetype, encoding = mimetypes.guess_type(parsed.path or uri) + if encoding: + raise ValueError(f"Compressed documents are unsupported '{encoding}' ({uri})") + + last_error = "" + for attempt in range(retries): + try: + if intervention_callback: + await intervention_callback() + resource = await asyncio.to_thread( + fetch_public_http_resource, + uri, + max_bytes=max_remote_bytes, + timeout=(timeout, timeout), + ) + if intervention_callback: + await intervention_callback() + + mimetype = ( + resource.content_type + or guessed_mimetype + or "application/octet-stream" + ) + if mimetype == "application/octet-stream": + raise ValueError( + f"Unsupported document mimetype '{mimetype}' ({uri})" + ) + + return FetchedDocument( + uri=resource.url, + source_uri=uri, + scheme=urlparse(resource.url).scheme or scheme, + mimetype=mimetype, + encoding=encoding, + charset=resource.encoding, + content=resource.content, + ) + except Exception as e: + last_error = str(e) + if attempt < retries - 1: + await asyncio.sleep(retry_backoff) + if intervention_callback: + await intervention_callback() + + raise ValueError(f"Document fetch error: {uri} ({last_error})") + +register_protocol_handler("file", _fetch_file) +register_protocol_handler("http", _fetch_http) +register_protocol_handler("https", _fetch_http) + + +def _fix_file_path(path: str) -> str: + if os.path.isabs(path) and os.path.exists(path): + return path + return files.fix_dev_path(path) diff --git a/plugins/_document_query/helpers/parsers/__init__.py b/plugins/_document_query/helpers/parsers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2e7b0087e36b2aeef1bf447a805a56d2e702a804 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/__init__.py @@ -0,0 +1,43 @@ +"""Document parser registry.""" +from .base import BaseParser +from .liteparse import LiteParseParser +from .pdf import PdfParser +from .html import HtmlParser +from .text import TextParser +from .image import ImageParser +from .unstructured import UnstructuredParser + +_PARSERS = [ + LiteParseParser(), + PdfParser(), + HtmlParser(), + TextParser(), + ImageParser(), + UnstructuredParser(), +] + +def get_parsers_for_mimetype(mimetype: str, config: dict | None = None) -> list[BaseParser]: + config = config or {} + parsers = [] + for parser in _PARSERS: + if parser.enabled(config) and parser.can_handle(mimetype): + parsers.append(parser) + return parsers + +def get_parser_for_mimetype(mimetype: str, config: dict | None = None) -> BaseParser | None: + parsers = get_parsers_for_mimetype(mimetype, config) + if parsers: + return parsers[0] + return None + +__all__ = [ + "BaseParser", + "LiteParseParser", + "PdfParser", + "HtmlParser", + "TextParser", + "ImageParser", + "UnstructuredParser", + "get_parser_for_mimetype", + "get_parsers_for_mimetype", +] diff --git a/plugins/_document_query/helpers/parsers/base.py b/plugins/_document_query/helpers/parsers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..25716e632e6851e8ddd67dfdb09abdaf792e6866 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/base.py @@ -0,0 +1,65 @@ +"""Base parser with built-in thread offload and timeout.""" + +import asyncio +from abc import ABC, abstractmethod + +from helpers.print_style import PrintStyle +from plugins._document_query.helpers.fetch import FetchedDocument + + +class BaseParser(ABC): + """Abstract base for document parsers. + + Every parser runs synchronously but is automatically offloaded to a + thread pool and bounded by a configurable timeout when called through + parse(). This prevents any single parser from blocking the asyncio + event loop. + """ + + mimetypes: list[str] = [] + + def enabled(self, config: dict) -> bool: + return True + + def can_handle(self, mimetype: str) -> bool: + """Return True if this parser supports *mimetype*.""" + for pattern in self.mimetypes: + if pattern == "*": + return True + if pattern.endswith("/"): + if mimetype.startswith(pattern): + return True + elif mimetype == pattern: + return True + return False + + async def parse( + self, + document: FetchedDocument, + config: dict, + timeout: float = 60.0, + thread_offload: bool = True, + ) -> str: + try: + if thread_offload: + return await asyncio.wait_for( + asyncio.to_thread(self._parse_sync, document, config), + timeout=timeout, + ) + else: + return await asyncio.wait_for( + self._parse_async(document, config), + timeout=timeout, + ) + except asyncio.TimeoutError: + PrintStyle.error( + f"Parser {self.__class__.__name__} timed out after {timeout}s on {document.uri}" + ) + raise ValueError(f"Document parsing timed out after {timeout}s: {document.uri}") + + async def _parse_async(self, document: FetchedDocument, config: dict) -> str: + return self._parse_sync(document, config) + + @abstractmethod + def _parse_sync(self, document: FetchedDocument, config: dict) -> str: + ... diff --git a/plugins/_document_query/helpers/parsers/html.py b/plugins/_document_query/helpers/parsers/html.py new file mode 100644 index 0000000000000000000000000000000000000000..b9c37b61197c3e94d7a7722e0df630a118250107 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/html.py @@ -0,0 +1,20 @@ +"""HTML parser using Markdownify transformer.""" + +from plugins._document_query.helpers.fetch import FetchedDocument +from .base import BaseParser + + +class HtmlParser(BaseParser): + mimetypes = ["text/html"] + + def _parse_sync(self, document: FetchedDocument, config: dict) -> str: + from langchain_community.document_transformers import MarkdownifyTransformer + from langchain_core.documents import Document + + parts = [ + Document( + page_content=document.text(), + metadata={"source": document.source_uri or document.uri}, + ) + ] + return "\n".join(e.page_content for e in MarkdownifyTransformer().transform_documents(parts)) diff --git a/plugins/_document_query/helpers/parsers/image.py b/plugins/_document_query/helpers/parsers/image.py new file mode 100644 index 0000000000000000000000000000000000000000..95c69c86a714a031e512ab1200499c071b22c9f9 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/image.py @@ -0,0 +1,10 @@ +"""Image parser — delegates to UnstructuredLoader.""" +from plugins._document_query.helpers.fetch import FetchedDocument +from .base import BaseParser +from .unstructured import UnstructuredParser + + +class ImageParser(BaseParser): + mimetypes = ["image/"] + def _parse_sync(self, document: FetchedDocument, config: dict) -> str: + return UnstructuredParser()._parse_sync(document, config) diff --git a/plugins/_document_query/helpers/parsers/liteparse.py b/plugins/_document_query/helpers/parsers/liteparse.py new file mode 100644 index 0000000000000000000000000000000000000000..0ff3275717c45ce4c091102b5e7f8fdbfec998c9 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/liteparse.py @@ -0,0 +1,321 @@ +"""LiteParse-backed parser for fast local document parsing.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from plugins._document_query.helpers.fetch import FetchedDocument +from .base import BaseParser + + +DEFAULT_OCR_AUTO_DISABLE_PAGES = 30 +DEFAULT_OCR_AUTO_SAMPLE_PAGES = 5 + + +@dataclass(frozen=True) +class _PdfTextProfile: + page_count: int + sampled_pages: int + text_chars: int + + +class LiteParseParser(BaseParser): + """Fast parser powered by run-llama/liteparse when available.""" + + DEFAULT_NUM_WORKERS = 2 + + mimetypes = [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "image/", + ] + + def enabled(self, config: dict) -> bool: + return bool(config.get("liteparse_enabled", True)) + + def _parse_sync(self, document: FetchedDocument, config: dict) -> str: + # Keep LiteParse native/OCR failures isolated from the Web UI process. + return self._parse_subprocess(document, config) + + def _parse_in_process(self, document: FetchedDocument, config: dict) -> str: + try: + from liteparse import LiteParse + except Exception as e: + raise RuntimeError("LiteParse is not installed") from e + + parser = LiteParse(**self._liteparse_kwargs(config)) + with document.local_file() as file_path: + result = parser.parse(file_path) + + text = getattr(result, "text", "") or "" + if not text.strip(): + raise ValueError("LiteParse returned no text") + return text + + def _parse_subprocess(self, document: FetchedDocument, config: dict) -> str: + with document.local_file() as file_path: + payload = { + "file_path": file_path, + "kwargs": self._liteparse_kwargs(config, document, file_path), + } + env = os.environ.copy() + project_root = str(Path(__file__).resolve().parents[4]) + python_path = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = ( + f"{project_root}{os.pathsep}{python_path}" + if python_path + else project_root + ) + timeout = float(config.get("per_document_timeout", 60)) + result = subprocess.run( + [ + sys.executable, + "-m", + "plugins._document_query.helpers.parsers.liteparse_worker", + ], + input=json.dumps(payload), + text=True, + capture_output=True, + cwd=project_root, + env=env, + timeout=timeout, + check=False, + ) + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + raise RuntimeError( + "LiteParse subprocess failed" + f" with exit code {result.returncode}: {detail[-2000:]}" + ) + + try: + response = json.loads(result.stdout) + except json.JSONDecodeError as e: + raise RuntimeError( + "LiteParse subprocess returned invalid output: " + f"{result.stdout[-2000:]}" + ) from e + + text = response.get("text", "") or "" + if not text.strip(): + raise ValueError("LiteParse returned no text") + return text + + def _liteparse_kwargs( + self, + config: dict, + document: FetchedDocument | None = None, + file_path: str | None = None, + ) -> dict: + ocr_enabled = bool(config.get("liteparse_ocr_enabled", True)) + if ocr_enabled and self._should_disable_ocr(config, document, file_path): + ocr_enabled = False + + kwargs = { + "ocr_enabled": ocr_enabled, + "ocr_language": config.get("liteparse_ocr_language", "eng"), + "max_pages": int(config.get("liteparse_max_pages", 1000)), + "dpi": float(config.get("liteparse_dpi", 150)), + "preserve_very_small_text": bool( + config.get("liteparse_preserve_very_small_text", False) + ), + "quiet": True, + } + + optional_keys = { + "ocr_server_url": "liteparse_ocr_server_url", + "target_pages": "liteparse_target_pages", + "output_format": "liteparse_output_format", + "password": "liteparse_password", + } + for liteparse_key, config_key in optional_keys.items(): + value = config.get(config_key) + if value not in (None, ""): + kwargs[liteparse_key] = value + + kwargs["num_workers"] = _positive_int( + config.get("liteparse_num_workers"), + self.DEFAULT_NUM_WORKERS, + ) + + tessdata_path = config.get("liteparse_tessdata_path") or _detect_tessdata_path() + if tessdata_path: + kwargs["tessdata_path"] = tessdata_path + + return kwargs + + def _should_disable_ocr( + self, + config: dict, + document: FetchedDocument | None, + file_path: str | None, + ) -> bool: + if not bool(config.get("liteparse_ocr_auto_disable", True)): + return False + if not document or document.mimetype != "application/pdf" or not file_path: + return False + + profile = _pdf_text_profile(file_path, config) + if not profile or profile.sampled_pages <= 0: + return False + + effective_pages = _effective_page_budget(config, profile.page_count) + auto_disable_pages = _positive_int( + config.get("liteparse_ocr_auto_disable_pages"), + DEFAULT_OCR_AUTO_DISABLE_PAGES, + ) + if effective_pages < auto_disable_pages: + return False + + return True + + +def _pdf_text_profile(file_path: str, config: dict) -> _PdfTextProfile | None: + try: + import fitz + except Exception: + return None + + try: + with fitz.open(file_path) as doc: + page_count = doc.page_count + if page_count <= 0: + return _PdfTextProfile(0, 0, 0) + sample_indexes = _sample_page_indexes( + page_count=page_count, + sample_pages=_positive_int( + config.get("liteparse_ocr_auto_sample_pages"), + DEFAULT_OCR_AUTO_SAMPLE_PAGES, + ), + target_pages=config.get("liteparse_target_pages"), + ) + text_chars = 0 + for page_index in sample_indexes: + text = doc[page_index].get_text("text") or "" + text_chars += len("".join(text.split())) + return _PdfTextProfile( + page_count=page_count, + sampled_pages=len(sample_indexes), + text_chars=text_chars, + ) + except Exception: + return None + + +def _effective_page_budget(config: dict, page_count: int) -> int: + target_pages = config.get("liteparse_target_pages") + if target_pages not in (None, ""): + parsed_target_count = _target_page_count(str(target_pages), page_count) + if parsed_target_count: + return parsed_target_count + + max_pages = _positive_int(config.get("liteparse_max_pages"), 1000) + return min(max_pages, page_count) + + +def _target_page_count(value: str, page_count: int) -> int: + pages = _target_page_numbers(value, page_count) + if pages is None: + return 0 + return len(pages) + + +def _target_page_numbers(value: str, page_count: int) -> set[int] | None: + pages: set[int] = set() + for raw_part in value.split(","): + part = raw_part.strip() + if not part: + continue + if "-" in part: + start_raw, end_raw = part.split("-", 1) + try: + start = int(start_raw.strip()) + end = int(end_raw.strip()) + except ValueError: + return None + if start <= 0 or end <= 0: + return None + if start > end: + start, end = end, start + pages.update(range(start, min(end, page_count) + 1)) + else: + try: + page = int(part) + except ValueError: + return None + if page <= 0: + return None + if page <= page_count: + pages.add(page) + return pages + + +def _sample_page_indexes( + page_count: int, + sample_pages: int, + target_pages: str | None = None, +) -> list[int]: + if page_count <= 0 or sample_pages <= 0: + return [] + + candidate_indexes: list[int] + if target_pages not in (None, ""): + target_page_numbers = _target_page_numbers(str(target_pages), page_count) + if target_page_numbers: + candidate_indexes = sorted(page - 1 for page in target_page_numbers) + else: + candidate_indexes = list(range(page_count)) + else: + candidate_indexes = list(range(page_count)) + + if len(candidate_indexes) <= sample_pages: + return candidate_indexes + + anchors = [0, 1, 2, len(candidate_indexes) // 2, len(candidate_indexes) - 1] + indexes = [] + seen = set() + for anchor in anchors: + index = candidate_indexes[min(max(anchor, 0), len(candidate_indexes) - 1)] + if index not in seen: + seen.add(index) + indexes.append(index) + if len(indexes) >= sample_pages: + break + return indexes + + +def _detect_tessdata_path() -> str: + env_path = os.getenv("TESSDATA_PREFIX", "") + candidates = [ + env_path, + "/usr/share/tesseract-ocr/5/tessdata", + "/usr/share/tesseract-ocr/4.00/tessdata", + "/usr/share/tessdata", + "/usr/local/share/tessdata", + ] + for candidate in candidates: + if candidate and (Path(candidate) / "eng.traineddata").is_file(): + return candidate + return "" + + +def _positive_int(value, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default diff --git a/plugins/_document_query/helpers/parsers/liteparse_worker.py b/plugins/_document_query/helpers/parsers/liteparse_worker.py new file mode 100644 index 0000000000000000000000000000000000000000..67c50b2883c90340e74a2ffb8f7ca85033f0b5a9 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/liteparse_worker.py @@ -0,0 +1,24 @@ +"""Subprocess entry point for isolating LiteParse native runtime crashes.""" + +from __future__ import annotations + +import json +import sys + + +def main() -> int: + payload = json.load(sys.stdin) + file_path = payload["file_path"] + kwargs = payload.get("kwargs") or {} + + from liteparse import LiteParse + + parser = LiteParse(**kwargs) + result = parser.parse(file_path) + text = getattr(result, "text", "") or "" + json.dump({"text": text}, sys.stdout) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/_document_query/helpers/parsers/pdf.py b/plugins/_document_query/helpers/parsers/pdf.py new file mode 100644 index 0000000000000000000000000000000000000000..7a8d2a8cc49fcab2940e5984213dd281d37a315a --- /dev/null +++ b/plugins/_document_query/helpers/parsers/pdf.py @@ -0,0 +1,44 @@ +"""PDF parser with PyMuPDF primary and Tesseract OCR fallback.""" + +import os + +from helpers.print_style import PrintStyle +from plugins._document_query.helpers.fetch import FetchedDocument + +from .base import BaseParser + + +class PdfParser(BaseParser): + mimetypes = ["application/pdf"] + + def _parse_sync(self, document: FetchedDocument, config: dict) -> str: + with document.local_file() as file_path: + if not os.path.exists(file_path): + raise ValueError(f"Temporary file not found: {file_path}") + contents = self._parse_with_pymupdf(file_path) + if not contents: + if not config.get("pdf_ocr_fallback", True): + raise ValueError("PyMuPDF returned no content and OCR fallback is disabled") + contents = self._parse_with_ocr(file_path) + return contents + + def _parse_with_pymupdf(self, file_path: str) -> str: + from langchain_community.document_loaders.pdf import PyMuPDFLoader + from langchain_community.document_loaders.parsers.images import TesseractBlobParser + + try: + loader = PyMuPDFLoader( + file_path, mode="single", extract_tables="markdown", + extract_images=True, images_inner_format="text", + images_parser=TesseractBlobParser(), pages_delimiter="\n", + ) + return "\n".join(e.page_content for e in loader.load()) + except Exception as e: + PrintStyle.error(f"PyMuPDF parsing failed: {e}") + return "" + + def _parse_with_ocr(self, file_path: str) -> str: + import pdf2image, pytesseract + PrintStyle.debug(f"FALLBACK: OCR for {file_path}") + pages = pdf2image.convert_from_path(file_path) + return "\n\n".join(pytesseract.image_to_string(p) for p in pages) diff --git a/plugins/_document_query/helpers/parsers/text.py b/plugins/_document_query/helpers/parsers/text.py new file mode 100644 index 0000000000000000000000000000000000000000..5546ccb4e3eefb84aee1ee9df6708e758a556735 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/text.py @@ -0,0 +1,16 @@ +"""Plain text and config document parser.""" + +from plugins._document_query.helpers.fetch import FetchedDocument +from .base import BaseParser + + +class TextParser(BaseParser): + mimetypes = [ + "text/", "application/json", "application/yaml", "application/x-yaml", + "application/xml", "application/toml", "application/x-toml", + "application/javascript", "application/typescript", + "application/x-sh", "application/x-shellscript", + ] + + def _parse_sync(self, document: FetchedDocument, config: dict) -> str: + return document.text() diff --git a/plugins/_document_query/helpers/parsers/unstructured.py b/plugins/_document_query/helpers/parsers/unstructured.py new file mode 100644 index 0000000000000000000000000000000000000000..783abedfd1aaff5d83d1b07de4c858a775f1fc36 --- /dev/null +++ b/plugins/_document_query/helpers/parsers/unstructured.py @@ -0,0 +1,25 @@ +"""Catch-all parser using UnstructuredLoader.""" + +import os + +from plugins._document_query.helpers.fetch import FetchedDocument +from .base import BaseParser + +os.environ.setdefault("USER_AGENT", "@mixedbread-ai/unstructured") + + +class UnstructuredParser(BaseParser): + mimetypes = ["*"] + + def _parse_sync(self, document: FetchedDocument, config: dict) -> str: + from langchain_unstructured import UnstructuredLoader + + with document.local_file() as file_path: + loader = UnstructuredLoader( + file_path=file_path, + mode="single", + partition_via_api=False, + strategy="hi_res", + ) + elements = loader.load() + return "\n".join(e.page_content for e in elements) diff --git a/plugins/_document_query/hooks.py b/plugins/_document_query/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..04cb609c820b123f0b10777819e28bfa56ecd320 --- /dev/null +++ b/plugins/_document_query/hooks.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import importlib +import importlib.util +import shutil +import subprocess +import sys +import threading +from pathlib import Path + +from helpers.errors import format_error +from helpers.print_style import PrintStyle + + +_LOCK = threading.Lock() +_CHECKED = False +_PLUGIN_DIR = Path(__file__).resolve().parent +_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt" + + +def has_liteparse() -> bool: + return importlib.util.find_spec("liteparse") is not None + + +def ensure_dependencies(raise_on_error: bool = True) -> bool: + """Install framework-runtime dependencies needed by the plugin.""" + global _CHECKED + + if _CHECKED and has_liteparse(): + return True + + with _LOCK: + if _CHECKED and has_liteparse(): + return True + if has_liteparse(): + _CHECKED = True + return True + + try: + _install_requirements() + importlib.invalidate_caches() + if not has_liteparse(): + raise RuntimeError( + "Document Query dependency 'liteparse' is still unavailable after installation" + ) + _CHECKED = True + return True + except Exception as e: + message = ( + "Document Query: failed to install LiteParse dependency: " + f"{format_error(e)}" + ) + if raise_on_error: + raise RuntimeError(message) from e + PrintStyle.error(message) + return False + + +def install() -> bool: + return ensure_dependencies(raise_on_error=True) + + +def _install_requirements() -> None: + uv = shutil.which("uv") + if not uv: + raise RuntimeError( + "Document Query plugin requires 'uv' to install liteparse automatically" + ) + requirement = _liteparse_requirement() + if not requirement: + raise RuntimeError( + f"Document Query LiteParse requirement not found in {_ROOT_REQUIREMENTS_FILE}" + ) + + cmd = [ + uv, + "pip", + "install", + "--python", + sys.executable, + requirement, + ] + + PrintStyle.info("Document Query: liteparse not found, installing plugin dependency") + subprocess.check_call(cmd, cwd=str(_PLUGIN_DIR)) + + +def _liteparse_requirement() -> str: + if not _ROOT_REQUIREMENTS_FILE.is_file(): + return "" + for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines(): + requirement = line.strip() + if requirement.startswith("liteparse"): + return requirement + return "" diff --git a/plugins/_document_query/plugin.yaml b/plugins/_document_query/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d6b9d9f7e5bbbdbc83ca6da5924e6ddd1187e9a5 --- /dev/null +++ b/plugins/_document_query/plugin.yaml @@ -0,0 +1,8 @@ +name: _document_query +title: Document Query +description: Load, parse, index, and Q&A over local and remote documents with configurable timeouts and thread-safe parsers. +version: 1.0.0 +settings_sections: + - agent +per_project_config: true +per_agent_config: false diff --git a/plugins/_document_query/prompts/agent.system.tool.document_query.md b/plugins/_document_query/prompts/agent.system.tool.document_query.md new file mode 100644 index 0000000000000000000000000000000000000000..d3d72078fb382e345acf798b3b021659b2a350db --- /dev/null +++ b/plugins/_document_query/prompts/agent.system.tool.document_query.md @@ -0,0 +1,12 @@ +### document_query +read, extract, summarize, compare, or answer questions over local/remote documents, PDFs, Office files, HTML/text/code files, large text-heavy files, and fallback OCR when vision tools cannot read a document image/scan. + +For document Q&A, document/code analysis, multi-document comparison, PDF or large-file extraction, or fallback OCR after vision tools are unavailable or insufficient, first load the `document-query` skill with `skills_tool:load`, then call this tool using the loaded instructions. + +Use vision tools first for image files, screenshots, scans, charts, photos, diagrams, and other visual inputs when available. Call `document_query` for images only as document-style OCR fallback when vision cannot read the needed text. + +Minimal args after loading the skill: +- `document`: one local path/URL or a list of paths/URLs +- `queries`: optional list of questions; omit it to return extracted document content + +Use normal user-facing language in final answers. Keep parser/runtime details internal. diff --git a/plugins/_document_query/prompts/fw.document_query.optimize_query.md b/plugins/_document_query/prompts/fw.document_query.optimize_query.md new file mode 100644 index 0000000000000000000000000000000000000000..717792657e8e2447b4a667aaaa09751492f00dda --- /dev/null +++ b/plugins/_document_query/prompts/fw.document_query.optimize_query.md @@ -0,0 +1,28 @@ +# AI role +- You are an AI assistant being part of a larger RAG system based on vector similarity search +- Your job is to take a human written question and convert it into a concise vector store search query +- The goal is to yield as many correct results and as few false positives as possible + +# Input +- you are provided with original search query as user message + +# Response rules !!! +- respond only with optimized result query text +- no text before or after +- no conversation, you are a tool agent, not a conversational agent + +# Optimized query +- optimized query is concise, short and to the point +- contains only keywords and phrases, no full sentences +- include alternatives and variations for better coverage + + +# Examples +User: What is the capital of France? +Agent: france capital city + +User: What does it say about transmission? +Agent: transmission gearbox automatic manual + +User: What did John ask Monica on Tuesday? +Agent: john monica conversation dialogue question ask tuesday diff --git a/plugins/_document_query/prompts/fw.document_query.system_prompt.md b/plugins/_document_query/prompts/fw.document_query.system_prompt.md new file mode 100644 index 0000000000000000000000000000000000000000..f8b3b072279c37a94421c8dec3b4ba690a32a025 --- /dev/null +++ b/plugins/_document_query/prompts/fw.document_query.system_prompt.md @@ -0,0 +1,5 @@ +You are an AI assistant who can answer questions about a given document text. +The assistant is part of a larger application that is used to answer questions about a document. +The assistant is given a document and a list of queries and the assistant must answer the quries based on the document. +!! The response should be in markdown format. +!! The response should only include the queries as headings and the answers to the queries. The markdown should contain paragraphs with "#### " as headings ( being the original query) followed by the query answer as the paragraph text content. diff --git a/plugins/_document_query/skills/document-query/SKILL.md b/plugins/_document_query/skills/document-query/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..f82902b5a3b09b54fca089ca14493bfd50ea0244 --- /dev/null +++ b/plugins/_document_query/skills/document-query/SKILL.md @@ -0,0 +1,155 @@ +--- +name: document-query +description: Use when reading, extracting, summarizing, comparing, or answering questions over local or remote documents, code files, PDFs, Office files, HTML/text files, large text-heavy files, and fallback OCR for document images or scans when vision tools are unavailable or insufficient. +version: 1.0.0 +author: Agent Zero Team +tags: ["documents", "ocr", "qa", "pdf", "code", "analysis"] +trigger_patterns: + - document query + - read document + - ask questions about a document + - summarize document + - compare documents + - fallback image OCR + - OCR document fallback + - analyze code file +--- + +# Document Query + +Use the `document_query` tool to read, extract, summarize, compare, or answer questions over documents and document-like files. + +## When To Use + +Use `document_query` for: + +- Local files and URLs containing document text: PDF, HTML, Office files, plain text, Markdown, CSV/TSV, XML/JSON, logs, code files, and similar content. +- Large text-heavy files where ordinary reading/search would be too broad and the user needs Q&A, summarization, comparison, or extraction. +- Q&A over one or more documents. +- Summaries, comparisons, entity extraction, key-point extraction, and table/text extraction. +- Code-file Q&A when the user points to specific files or URLs and wants answers from their contents. +- Fallback OCR over text-heavy document images, scans, or screenshots only when vision tools are unavailable, insufficient, or unable to read the needed text. + +Use vision tools first for image files, screenshots, scans, charts, photos, diagrams, and other visual inputs when available. Do not route images to `document_query` as a first-pass reader. + +Do not use `document_query` for purely visual questions, first-pass screenshot inspection, or tasks that require spatial/visual reasoning beyond document text; use vision tools when available for those cases. + +## Inputs + +`document_query` accepts: + +- `document`: required. A single local path/URL or a list of local paths/URLs. +- `queries`: optional. A list of questions. When omitted, the tool returns extracted document content. +- `query`: optional compatibility shortcut for a single question. + +Local paths must be full paths. `file://` is optional for local files. URLs should use `http://` or `https://`. + +For directories or codebases, first identify the relevant files with file/search tools, then pass the files themselves to `document_query`. Do not pass a directory path as the document. + +## Workflow + +1. Use `document_query` directly after this skill is loaded. +2. If the user asks for an answer, summary, comparison, or extraction, pass natural-language questions in `queries`. +3. If the user asks to inspect raw contents or you need to see the extracted text first, omit `queries`. +4. For multiple documents, pass a list in `document` and ask comparison or extraction questions in `queries`. +5. Keep parser/runtime details internal. Do not tell the user which parser, OCR runtime, or fallback path was used unless they explicitly ask about internals. +6. Answer from the returned document evidence. If the document does not contain the answer, say that it is not found in the document. + +## Examples + +### Return Extracted Content + +```json +{ + "thoughts": [ + "The user wants the document text, so I should extract the content." + ], + "headline": "Extracting document content", + "tool_name": "document_query", + "tool_args": { + "document": "/a0/usr/workdir/report.pdf" + } +} +``` + +### Answer Questions Over A Document + +```json +{ + "thoughts": [ + "The user asks questions whose answers should come from the PDF." + ], + "headline": "Answering questions from the document", + "tool_name": "document_query", + "tool_args": { + "document": "/a0/usr/workdir/report.pdf", + "queries": [ + "What is the report's main conclusion?", + "What dates or deadlines does it mention?" + ] + } +} +``` + +### Compare Multiple Documents + +```json +{ + "thoughts": [ + "The user wants a comparison across two source documents." + ], + "headline": "Comparing documents", + "tool_name": "document_query", + "tool_args": { + "document": [ + "https://example.com/policy-2025.pdf", + "/a0/usr/workdir/policy-2026.pdf" + ], + "queries": [ + "Compare the main changes between the two documents.", + "Which requirements appear only in the second document?" + ] + } +} +``` + +### Fallback OCR After Vision Cannot Read A Document Image + +```json +{ + "thoughts": [ + "Vision tools are unavailable or could not read the scanned document text, so document_query is the fallback OCR path." + ], + "headline": "Reading document text with fallback OCR", + "tool_name": "document_query", + "tool_args": { + "document": "/a0/usr/workdir/scan.png", + "queries": [ + "What text is visible in the document?", + "What is the document title?" + ] + } +} +``` + +### Code File Q&A + +```json +{ + "thoughts": [ + "The user asks about specific code files, so I can query those files as documents." + ], + "headline": "Answering from code files", + "tool_name": "document_query", + "tool_args": { + "document": [ + "/a0/usr/workdir/src/app.py", + "/a0/usr/workdir/src/config.py" + ], + "queries": [ + "Where is the database connection configured?", + "Which environment variables are required?" + ] + } +} +``` diff --git a/plugins/_document_query/tools/document_query.py b/plugins/_document_query/tools/document_query.py new file mode 100644 index 0000000000000000000000000000000000000000..5a37bcbf28e5ed4fd7e100514c38c1d60fe5ed46 --- /dev/null +++ b/plugins/_document_query/tools/document_query.py @@ -0,0 +1,55 @@ +import asyncio + +from helpers.tool import Tool, Response +from plugins._document_query.helpers.document_query import DocumentQueryHelper + + +class DocumentQueryTool(Tool): + + async def execute(self, **kwargs): + document_uri = kwargs.get("document") + document_uris = [] + + if isinstance(document_uri, list): + document_uris = document_uri + elif isinstance(document_uri, str): + document_uris = [document_uri] + + if not document_uris: + return Response(message="Error: no document provided", break_loop=False) + + queries = ( + kwargs["queries"] + if "queries" in kwargs + else [kwargs["query"]] + if ("query" in kwargs and kwargs["query"]) + else [] + ) + try: + progress = [] + + def progress_callback(msg): + progress.append(msg) + self.log.update(progress="\n".join(progress)) + + helper = DocumentQueryHelper(self.agent, progress_callback) + if not queries: + gather_timeout = helper.config.get("gather_timeout", 120) + try: + contents = await asyncio.wait_for( + asyncio.gather( + *[helper.document_get_content(uri) for uri in document_uris] + ), + timeout=gather_timeout, + ) + except asyncio.TimeoutError: + return Response( + message=f"Error: document processing timed out after {gather_timeout}s", + break_loop=False, + ) + content = "\n\n---\n\n".join(contents) + else: + _, content = await helper.document_qa(document_uris, queries) + return Response(message=content, break_loop=False) + except Exception as e: # pylint: disable=broad-exception-caught + return Response(message=f"Error processing document: {e}", break_loop=False) diff --git a/plugins/_document_query/webui/config.html b/plugins/_document_query/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..fc889efefc38bb2f2d62fda0156ce25004c26589 --- /dev/null +++ b/plugins/_document_query/webui/config.html @@ -0,0 +1,490 @@ + + + Document Query + + + +
+ +
+ + + diff --git a/plugins/_document_query/webui/thumbnail.jpg b/plugins/_document_query/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0ab4c877a755b40cdf1b64228f7d2b605f64bd85 Binary files /dev/null and b/plugins/_document_query/webui/thumbnail.jpg differ diff --git a/plugins/_editor/AGENTS.md b/plugins/_editor/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..8d4fabda665684f8fa570c91597e6d3f50dc067d --- /dev/null +++ b/plugins/_editor/AGENTS.md @@ -0,0 +1,36 @@ +# Editor Plugin DOX + +## Purpose + +- Own the native Markdown and plain text editor surface for canvas and floating modal workflows. + +## Ownership + +- `api/` owns editor session and WebSocket handlers. +- `helpers/` owns editor text session and open-file context helpers. +- `prompts/` owns agent-visible open-file context. +- `webui/` owns editor panel, preview, store, and main surface. +- `extensions/` owns editor hook contributions. + +## Local Contracts + +- Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior. +- Do not expose unsaved content or local paths beyond intended chat/context surfaces. +- Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work. +- Keep Editor Open wired through the File Browser text picker so users can open one or more Markdown or plain text files with an obvious confirmation action. +- Keep Download in the Editor file-actions menu and save dirty text before downloading it. +- Keep Save As distinct from Rename: Save As writes the current editor text to a chosen `.md` or `.txt` path and retargets the active session without removing the original file. +- Keep Markdown and plain text on the same toolbar, with full-document source and preview modes plus shared Undo/Redo buttons and keyboard shortcuts. +- Preserve source chat context ids when opening Markdown files from tool-result canvas handoffs. + +## Work Guidance + +- Coordinate editor preview and session changes with canvas surface registration. + +## Verification + +- Smoke-test opening, editing, previewing, and reconnecting editor sessions after changes. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_editor/api/editor_session.py b/plugins/_editor/api/editor_session.py new file mode 100644 index 0000000000000000000000000000000000000000..60dbf457896039997f78f2d9e8c7c9505d8d9edc --- /dev/null +++ b/plugins/_editor/api/editor_session.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request +from plugins._editor.helpers import markdown_sessions +from plugins._office.helpers import document_store + + +class EditorSession(ApiHandler): + async def process(self, input: dict, request: Request) -> dict: + action = str(input.get("action") or "open").lower().strip() + context_id = str(input.get("ctxid") or input.get("context_id") or "").strip() + + if action == "status": + return { + "ok": True, + "open_files": markdown_sessions.get_manager().list_open(context_id=context_id), + } + if action == "home": + return {"ok": True, "path": document_store.default_open_path(context_id)} + if action == "list": + return { + "ok": True, + "open_files": markdown_sessions.get_manager().list_open( + context_id=context_id, + limit=int(input.get("limit") or 20), + ), + } + if action == "activate": + return markdown_sessions.get_manager().activate(str(input.get("session_id") or "")) + if action == "close": + closed = markdown_sessions.get_manager().close(str(input.get("session_id") or "")) + store_session_id = str(input.get("store_session_id") or "").strip() + file_id = str(input.get("file_id") or "").strip() + closed["store_closed"] = document_store.close_session( + session_id=store_session_id, + file_id="" if store_session_id else file_id, + ) + return closed + if action == "create": + fmt = str(input.get("format") or "md").lower().lstrip(".") + if fmt not in document_store.EDITOR_TEXT_EXTENSIONS: + return {"ok": False, "error": "Editor can only create Markdown (.md) and text (.txt) documents."} + try: + doc = document_store.create_document( + kind="document", + title=str(input.get("title") or "Untitled"), + fmt=fmt, + content=str(input.get("content") or ""), + path=str(input.get("path") or ""), + context_id=context_id, + ) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + return await self._open_document(doc, input, request, context_id=context_id) + if action == "open": + file_id = str(input.get("file_id") or "").strip() + try: + doc = ( + document_store.get_document(file_id) + if file_id + else document_store.register_document( + str(input.get("path") or ""), + context_id=context_id, + allow_base_dir=self._allow_base_dir_open(input), + ) + ) + except Exception as exc: + return {"ok": False, "error": str(exc)} + return await self._open_document(doc, input, request, context_id=context_id) + if action == "save": + session_id = str(input.get("session_id") or "").strip() + if not session_id: + return {"ok": False, "error": "session_id is required."} + return markdown_sessions.get_manager().save(session_id, text=input.get("text")) + if action == "save_as": + session_id = str(input.get("session_id") or "").strip() + path = str(input.get("path") or "").strip() + if not session_id: + return {"ok": False, "error": "session_id is required."} + if not path: + return {"ok": False, "error": "path is required."} + try: + result = markdown_sessions.get_manager().save_as(session_id, path, text=input.get("text")) + except Exception as exc: + return {"ok": False, "error": str(exc)} + document_store.close_session(session_id=str(input.get("store_session_id") or "").strip()) + store_session = document_store.create_session( + result["document"]["file_id"], + user_id=str(input.get("user_id") or "agent-zero-user"), + permission="write", + origin=self._origin(request), + ) + return { + **result, + "store_session_id": store_session["session_id"], + } + if action == "renamed": + return self._renamed(input, context_id) + if action == "refresh": + return markdown_sessions.get_manager().refresh_document(str(input.get("file_id") or "")) + return {"ok": False, "error": f"Unsupported editor session action: {action}"} + + async def _open_document( + self, + doc: dict, + input: dict, + request: Request, + context_id: str = "", + ) -> dict: + if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS: + return { + "ok": False, + "error": f".{doc.get('extension', '')} documents use the Desktop surface.", + "requires_desktop": True, + "document": _public_doc(doc), + } + + mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view" + store_session = document_store.create_session( + doc["file_id"], + user_id=str(input.get("user_id") or "agent-zero-user"), + permission="write" if mode == "edit" else "read", + origin=self._origin(request), + ) + try: + editor = markdown_sessions.get_manager().open( + doc, + sid="", + context_id=context_id, + refresh=input.get("refresh") is True, + ) + except ValueError as exc: + document_store.close_session(session_id=store_session["session_id"]) + return {"ok": False, "error": str(exc)} + return { + **editor, + "store_session_id": store_session["session_id"], + "session_id": editor["session_id"], + "mode": mode, + } + + def _renamed(self, input: dict, context_id: str = "") -> dict: + file_id = str(input.get("file_id") or "").strip() + path = str(input.get("path") or "").strip() + if not file_id: + return {"ok": False, "error": "file_id is required."} + if not path: + return {"ok": False, "error": "path is required."} + try: + updated = document_store.rename_document( + file_id, + path, + content=input.get("text") if "text" in input else None, + context_id=context_id, + ) + markdown_sessions.get_manager().renamed( + file_id, + updated, + text=input.get("text") if "text" in input else None, + ) + except Exception as exc: + return {"ok": False, "error": str(exc)} + return { + "ok": True, + "document": _public_doc(updated), + "version": document_store.item_version(updated), + "refreshFiles": False, + } + + def _origin(self, request: Request) -> str: + origin = request.headers.get("Origin") or request.host_url.rstrip("/") + return origin.rstrip("/") + + def _allow_base_dir_open(self, input: dict) -> bool: + if str(input.get("source") or "").strip() != "file-browser": + return False + return bool(str(input.get("path") or "").strip()) + + +def _public_doc(doc: dict) -> dict: + return { + "file_id": doc["file_id"], + "path": document_store.display_path(doc["path"]), + "basename": doc["basename"], + "title": doc["basename"], + "extension": doc["extension"], + "size": doc["size"], + "version": document_store.item_version(doc), + "last_modified": doc["last_modified"], + } diff --git a/plugins/_editor/api/ws_editor.py b/plugins/_editor/api/ws_editor.py new file mode 100644 index 0000000000000000000000000000000000000000..8adf31d1c19187f4c9c20649f129159f598cd9d7 --- /dev/null +++ b/plugins/_editor/api/ws_editor.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Any + +from helpers.ws import WsHandler +from helpers.ws_manager import WsResult +from plugins._editor.helpers import markdown_sessions +from plugins._office.helpers import document_store + + +class WsEditor(WsHandler): + async def on_disconnect(self, sid: str) -> None: + markdown_sessions.get_manager().close_sid(sid) + + async def process(self, event: str, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult | None: + if not event.startswith("editor_"): + return None + try: + if event == "editor_open": + return self._open(data, sid) + if event == "editor_input": + return markdown_sessions.get_manager().input( + str(data.get("session_id") or ""), + text=data.get("text") if "text" in data else None, + patch=data.get("patch") if isinstance(data.get("patch"), dict) else None, + ) + if event == "editor_save": + return markdown_sessions.get_manager().save( + str(data.get("session_id") or ""), + text=data.get("text") if "text" in data else None, + ) + if event == "editor_activate": + return markdown_sessions.get_manager().activate(str(data.get("session_id") or "")) + if event == "editor_close": + return markdown_sessions.get_manager().close(str(data.get("session_id") or "")) + except FileNotFoundError as exc: + return WsResult.error(code="EDITOR_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId")) + except Exception as exc: + return WsResult.error(code="EDITOR_ERROR", message=str(exc), correlation_id=data.get("correlationId")) + + return WsResult.error( + code="UNKNOWN_EDITOR_EVENT", + message=f"Unknown editor event: {event}", + correlation_id=data.get("correlationId"), + ) + + def _open(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult: + context_id = str(data.get("ctxid") or data.get("context_id") or "") + file_id = str(data.get("file_id") or "").strip() + path = str(data.get("path") or "").strip() + if file_id: + doc = document_store.get_document(file_id) + elif path: + doc = document_store.register_document(path, context_id=context_id) + else: + fmt = str(data.get("format") or "md").lower().strip().lstrip(".") + if fmt not in document_store.EDITOR_TEXT_EXTENSIONS: + return WsResult.error( + code="UNSUPPORTED_EDITOR_DOCUMENT", + message=f"Editor can only create Markdown (.md) and text (.txt) documents, not .{fmt}.", + correlation_id=data.get("correlationId"), + ) + doc = document_store.create_document( + kind="document", + title=str(data.get("title") or "Untitled"), + fmt=fmt, + content=str(data.get("content") or ""), + context_id=context_id, + ) + return markdown_sessions.get_manager().open( + doc, + sid=sid, + context_id=context_id, + refresh=data.get("refresh") is True, + ) diff --git a/plugins/_editor/extensions/python/message_loop_prompts_after/_55_include_editor_open_files.py b/plugins/_editor/extensions/python/message_loop_prompts_after/_55_include_editor_open_files.py new file mode 100644 index 0000000000000000000000000000000000000000..f3b7a77bff50d86e11ebfba51c8528713e7942b8 --- /dev/null +++ b/plugins/_editor/extensions/python/message_loop_prompts_after/_55_include_editor_open_files.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from agent import LoopData +from helpers.extension import Extension +from plugins._editor.helpers import open_files_context + + +class IncludeEditorOpenFiles(Extension): + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + if not self.agent or not self.agent.context: + return + + context = open_files_context.build_context(self.agent.context.id) + if not context: + loop_data.extras_temporary.pop("editor_open_files", None) + return + + loop_data.extras_temporary["editor_open_files"] = self.agent.read_prompt( + "agent.extras.editor_open_files.md", + editor_open_files=context, + ) diff --git a/plugins/_editor/extensions/python/text_editor_patch_after/_40_sync_open_sessions.py b/plugins/_editor/extensions/python/text_editor_patch_after/_40_sync_open_sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..2a74eb1f9762a18ccc7176a40841baa273377b7d --- /dev/null +++ b/plugins/_editor/extensions/python/text_editor_patch_after/_40_sync_open_sessions.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from plugins._editor.helpers import markdown_sessions + + +class SyncOpenEditorSessionsAfterTextEditorPatch(Extension): + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any): + path = str((data or {}).get("path") or "").strip() + if not path: + return + markdown_sessions.get_manager().sync_external_file_mutations([path]) diff --git a/plugins/_editor/extensions/python/text_editor_write_after/_40_sync_open_sessions.py b/plugins/_editor/extensions/python/text_editor_write_after/_40_sync_open_sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..fa16a6b8edb6910b738bf8cefe5f4d1949de215d --- /dev/null +++ b/plugins/_editor/extensions/python/text_editor_write_after/_40_sync_open_sessions.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from plugins._editor.helpers import markdown_sessions + + +class SyncOpenEditorSessionsAfterTextEditorWrite(Extension): + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any): + path = str((data or {}).get("path") or "").strip() + if not path: + return + markdown_sessions.get_manager().sync_external_file_mutations([path]) diff --git a/plugins/_editor/extensions/python/webui_ws_disconnect/_50_editor.py b/plugins/_editor/extensions/python/webui_ws_disconnect/_50_editor.py new file mode 100644 index 0000000000000000000000000000000000000000..2201855ada8fa4af699e7c1573d13954f7531114 --- /dev/null +++ b/plugins/_editor/extensions/python/webui_ws_disconnect/_50_editor.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from plugins._editor.api.ws_editor import WsEditor + + +class EditorWebuiWsDisconnect(Extension): + async def execute( + self, + instance: Any = None, + sid: str = "", + **kwargs: Any, + ) -> None: + if instance is None: + return + handler = WsEditor( + instance.socketio, + instance.lock, + manager=instance.manager, + namespace=instance.namespace, + ) + await handler.on_disconnect(sid) diff --git a/plugins/_editor/extensions/python/webui_ws_event/_50_editor.py b/plugins/_editor/extensions/python/webui_ws_event/_50_editor.py new file mode 100644 index 0000000000000000000000000000000000000000..cbf5035643c38fab95080b7b0e9c932cfb571a29 --- /dev/null +++ b/plugins/_editor/extensions/python/webui_ws_event/_50_editor.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from helpers.ws_manager import WsResult +from plugins._editor.api.ws_editor import WsEditor + + +class EditorWebuiWsEvents(Extension): + async def execute( + self, + instance: Any = None, + sid: str = "", + event_type: str = "", + data: dict[str, Any] | None = None, + response_data: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + if not event_type.startswith("editor_") or instance is None or response_data is None: + return + + handler = WsEditor( + instance.socketio, + instance.lock, + manager=instance.manager, + namespace=instance.namespace, + ) + result = await handler.process(event_type, data or {}, sid) + if result is None: + return + + if isinstance(result, WsResult): + payload = result.as_result( + handler_id=handler.identifier, + fallback_correlation_id=(data or {}).get("correlationId"), + ) + if payload.get("ok"): + response_data.update(payload.get("data") or {}) + else: + response_data["editor_error"] = payload.get("error") or { + "code": "EDITOR_ERROR", + "error": "Editor request failed", + } + return + + response_data.update(result) diff --git a/plugins/_editor/extensions/python/workdir_file_mutation_after/_40_sync_open_sessions.py b/plugins/_editor/extensions/python/workdir_file_mutation_after/_40_sync_open_sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..5d5c591c990178d777f5c12f468f70ccfdcc3f64 --- /dev/null +++ b/plugins/_editor/extensions/python/workdir_file_mutation_after/_40_sync_open_sessions.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from plugins._editor.helpers import markdown_sessions + + +class SyncOpenEditorSessionsAfterWorkdirMutation(Extension): + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any): + payload = data or {} + paths = payload.get("paths") + if not isinstance(paths, list): + paths = [payload.get("path") or payload.get("current_path") or payload.get("parent_path")] + markdown_sessions.get_manager().sync_external_file_mutations( + [str(path) for path in paths if path], + ) diff --git a/plugins/_editor/extensions/webui/right-canvas-panels/editor-panel.html b/plugins/_editor/extensions/webui/right-canvas-panels/editor-panel.html new file mode 100644 index 0000000000000000000000000000000000000000..3f17e55ab3ad891d8c0f04115cb0d74aeddd2715 --- /dev/null +++ b/plugins/_editor/extensions/webui/right-canvas-panels/editor-panel.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/plugins/_editor/extensions/webui/right_canvas_register_surfaces/register-editor.js b/plugins/_editor/extensions/webui/right_canvas_register_surfaces/register-editor.js new file mode 100644 index 0000000000000000000000000000000000000000..2c4e8d53c68cc1e88db41f661b140e7b86da1c50 --- /dev/null +++ b/plugins/_editor/extensions/webui/right_canvas_register_surfaces/register-editor.js @@ -0,0 +1,42 @@ +import { store as editorStore } from "/plugins/_editor/webui/editor-store.js"; + +function waitForElement(selector, timeoutMs = 3000) { + const found = document.querySelector(selector); + if (found) return Promise.resolve(found); + return new Promise((resolve) => { + const timeout = globalThis.setTimeout(() => { + observer.disconnect(); + resolve(document.querySelector(selector)); + }, timeoutMs); + const observer = new MutationObserver(() => { + const element = document.querySelector(selector); + if (!element) return; + globalThis.clearTimeout(timeout); + observer.disconnect(); + resolve(element); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); +} + +export default async function registerEditorSurface(surfaces) { + surfaces.registerSurface({ + id: "editor", + title: "Editor", + icon: "article", + order: 30, + modalPath: "/plugins/_editor/webui/main.html", + beginDockHandoff() { + editorStore.beginSurfaceHandoff?.(); + }, + async open(payload = {}) { + const panel = await waitForElement('[data-surface-id="editor"] .editor-panel'); + if (!panel) throw new Error("Editor surface panel did not mount."); + await editorStore.onMount?.(panel, { mode: "canvas" }); + await editorStore.onOpen?.(payload); + }, + async close() { + await editorStore.cleanup?.(); + }, + }); +} diff --git a/plugins/_editor/extensions/webui/set_messages_after_loop/sync-text-editor-results.js b/plugins/_editor/extensions/webui/set_messages_after_loop/sync-text-editor-results.js new file mode 100644 index 0000000000000000000000000000000000000000..f4b2947f627f7f619782306f88a5e867e86a2c26 --- /dev/null +++ b/plugins/_editor/extensions/webui/set_messages_after_loop/sync-text-editor-results.js @@ -0,0 +1,228 @@ +import { store as editorStore } from "/plugins/_editor/webui/editor-store.js"; +import { open as openSurface } from "/js/surfaces.js"; + +const SYNC_WINDOW_MS = 10 * 60 * 1000; +const syncedTextEditorResults = new Set(); + +export default async function syncTextEditorResultsIntoOpenEditor(context) { + if (!context?.results?.length) return; + + for (const { args } of context.results) { + const payload = getTextEditorPayload(args); + if (toolName(payload) !== "text_editor") continue; + if (!shouldSyncTextEditorResult(args, payload)) continue; + + const target = textEditorTarget(payload); + if (!target.path || target.extension !== "md") continue; + const explicitOpen = shouldOpenEditorUiFromResult(payload, target); + if (context.historyEmpty && !explicitOpen) continue; + + const key = [ + args?.id || "", + payload.action || "", + target.path || "", + payload.version || "", + payload.last_modified || "", + ].join(":"); + if (syncedTextEditorResults.has(key)) continue; + syncedTextEditorResults.add(key); + + globalThis.setTimeout(() => { + if (explicitOpen) { + void openSurface("editor", { + path: target.path || "", + file_id: target.file_id || "", + ctxid: target.ctxid || target.context_id || "", + context_id: target.context_id || target.ctxid || "", + refresh: true, + source: "tool-result-open", + }); + return; + } + void syncOpenEditorSurface(target); + }, 0); + } +} + +function getTextEditorPayload(args = {}) { + const contentPayload = parseMaybeJson(args.content); + const kvpsPayload = args.kvps && typeof args.kvps === "object" + ? args.kvps + : parseMaybeJson(args.kvps); + return { + ...pickPayloadFields(args), + ...(contentPayload || {}), + ...(kvpsPayload || {}), + }; +} + +function pickPayloadFields(args = {}) { + const payload = {}; + for (const key of [ + "_tool_name", + "tool_name", + "action", + "extension", + "format", + "context_id", + "ctxid", + "last_modified", + "open_canvas", + "open_document", + "open_in_canvas", + "path", + "version", + ]) { + if (args[key] != null && args[key] !== "") payload[key] = args[key]; + } + return payload; +} + +function toolName(payload = {}) { + return String(payload._tool_name || payload.tool_name || "").trim(); +} + +function shouldSyncTextEditorResult(args = {}, payload = {}) { + if (!isFresh(args.timestamp, payload.last_modified)) return false; + const action = String(payload.action || "").trim().toLowerCase().replace("-", "_"); + return ["write", "patch"].includes(action); +} + +function shouldOpenEditorUiFromResult(payload = {}, document = {}) { + return isExplicitEditorUiRequest(payload) && documentExtension(payload, document) === "md"; +} + +function isExplicitEditorUiRequest(payload = {}) { + const action = String(payload.action || "").trim().toLowerCase().replace("-", "_"); + return action === "open" + || truthy(payload.open_in_canvas) + || truthy(payload.open_canvas) + || truthy(payload.open_document); +} + +function textEditorTarget(payload = {}) { + const path = String(payload.path || "").trim(); + const extension = documentExtension(payload, { path }); + const contextId = contextIdFromPayload(payload); + return { + path, + file_id: "", + context_id: contextId, + ctxid: contextId, + extension, + format: extension, + version: payload.version || "", + last_modified: payload.last_modified || "", + }; +} + +function documentExtension(payload = {}, document = {}) { + return String( + payload.format + || payload.extension + || extensionFromPath(document.path) + || "", + ).toLowerCase().replace(/^\./, ""); +} + +function extensionFromPath(path = "") { + const clean = String(path || "").split("?")[0].split("#")[0]; + const name = clean.split("/").filter(Boolean).pop() || ""; + const index = name.lastIndexOf("."); + return index > 0 ? name.slice(index + 1).toLowerCase() : ""; +} + +function isEditorSurfaceOpen() { + return Boolean( + globalThis.document?.querySelector?.( + '[data-surface-id="editor"] .editor-panel, .modal-inner[data-surface-id="editor"] .editor-panel, .modal-inner[data-canvas-surface="editor"] .editor-panel', + ), + ); +} + +async function syncOpenEditorSurface(document = {}) { + const editor = editorStore; + if (!editor || !isEditorSurfaceOpen()) return false; + if (!hasSameDocument(editor, document)) return false; + if (isDirtySameDocument(editor, document)) return false; + await editor.openSession?.({ + path: document.path || "", + file_id: document.file_id || "", + ctxid: document.ctxid || document.context_id || "", + context_id: document.context_id || document.ctxid || "", + refresh: true, + source: "tool-result-sync", + }); + return true; +} + +function hasSameDocument(store, document = {}) { + return documentEntries(store).some((entry) => documentsMatch(entry, document)); +} + +function isDirtySameDocument(store, document = {}) { + return documentEntries(store).some((entry) => { + if (!documentsMatch(entry, document)) return false; + const isActive = entry === store?.session || (entry.tab_id && entry.tab_id === store?.activeTabId); + return Boolean(entry.dirty || (isActive && store?.dirty)); + }); +} + +function documentEntries(store) { + const entries = []; + if (store?.session) entries.push(store.session); + if (Array.isArray(store?.tabs)) entries.push(...store.tabs); + return entries; +} + +function documentsMatch(entry = {}, document = {}) { + const path = String(document.path || "").trim(); + const fileId = String(document.file_id || "").trim(); + const entryPath = String(entry.path || entry.document?.path || "").trim(); + const entryFileId = String(entry.file_id || entry.document?.file_id || "").trim(); + return Boolean( + (fileId && entryFileId === fileId) + || (path && entryPath === path), + ); +} + +function contextIdFromPayload(payload = {}) { + return String(payload.context_id || payload.ctxid || "").trim(); +} + +function truthy(value) { + if (value === true) return true; + if (value === false || value == null) return false; + if (typeof value === "number") return value !== 0; + return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase()); +} + +function isFresh(...timestamps) { + const now = Date.now(); + for (const value of timestamps) { + const time = parseTimestamp(value); + if (time && now - time < SYNC_WINDOW_MS) return true; + } + return false; +} + +function parseTimestamp(value) { + if (!value) return 0; + if (typeof value === "number") return value > 1e12 ? value : value * 1000; + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? parsed : 0; +} + +function parseMaybeJson(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} diff --git a/plugins/_editor/extensions/webui/surfaces_register/register-editor.js b/plugins/_editor/extensions/webui/surfaces_register/register-editor.js new file mode 100644 index 0000000000000000000000000000000000000000..bf7a40f053409188411853c025bb197f65c085cb --- /dev/null +++ b/plugins/_editor/extensions/webui/surfaces_register/register-editor.js @@ -0,0 +1,3 @@ +import registerEditorSurface from "../right_canvas_register_surfaces/register-editor.js"; + +export default registerEditorSurface; diff --git a/plugins/_editor/helpers/markdown_sessions.py b/plugins/_editor/helpers/markdown_sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..0347fa3cba0b88912f24d00ee04400c303486704 --- /dev/null +++ b/plugins/_editor/helpers/markdown_sessions.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from plugins._office.helpers import document_store + + +@dataclass +class MarkdownSession: + session_id: str + file_id: str + sid: str + context_id: str + extension: str + path: str + title: str + text: str = "" + dirty: bool = False + active: bool = False + base_sha256: str = "" + base_version: str = "" + external_modified: bool = False + external_version: str = "" + opened_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + last_active_at: float = field(default_factory=time.time) + + +class MarkdownSessionManager: + """Owns native Editor sessions for Markdown and plain text documents.""" + + def __init__(self) -> None: + self._sessions: dict[str, MarkdownSession] = {} + self._active_by_context: dict[str, str] = {} + + def open(self, doc: dict[str, Any], sid: str = "", context_id: str = "", refresh: bool = False) -> dict[str, Any]: + ext = str(doc["extension"]).lower() + if ext not in document_store.EDITOR_TEXT_EXTENSIONS: + raise ValueError(f"Editor is only available for Markdown and text files. Open .{ext} files in the Desktop.") + + normalized_context = str(context_id or "") + if refresh: + try: + doc = document_store.register_document(doc["path"], context_id=normalized_context) + except Exception: + pass + + for session in self._sessions.values(): + if session.file_id != doc["file_id"] or session.context_id != normalized_context: + continue + if sid: + session.sid = sid + doc_sha = str(doc.get("sha256") or "") + should_reload = not session.dirty and (refresh or (doc_sha and doc_sha != session.base_sha256)) + if should_reload: + session.text = document_store.read_text_for_editor(doc) + session.dirty = False + _set_session_base(session, doc) + elif session.dirty and doc_sha and doc_sha != session.base_sha256: + _mark_session_external(session, doc) + session.path = doc["path"] + session.title = doc["basename"] + session.extension = ext + session.updated_at = time.time() + self.activate(session.session_id) + return self._payload(session, doc) + + session = MarkdownSession( + session_id=uuid.uuid4().hex, + file_id=doc["file_id"], + sid=sid, + context_id=normalized_context, + extension=ext, + path=doc["path"], + title=doc["basename"], + text=document_store.read_text_for_editor(doc), + ) + _set_session_base(session, doc) + self._sessions[session.session_id] = session + self.activate(session.session_id) + return self._payload(session, doc) + + def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]: + session = self._require(session_id) + if text is not None: + session.text = str(text) + elif patch: + session.text = _apply_text_patch(session.text, patch) + session.dirty = True + session.updated_at = time.time() + self.activate(session.session_id) + return {"ok": True, "session_id": session.session_id} + + def save(self, session_id: str, text: str | None = None) -> dict[str, Any]: + session = self._require(session_id) + if text is not None: + session.text = str(text) + + conflict = self._save_conflict(session) + if conflict is not None: + return conflict + + updated = document_store.write_text_document(session.file_id, session.text) + session.updated_at = time.time() + session.dirty = False + session.path = updated["path"] + session.title = updated["basename"] + session.extension = str(updated.get("extension") or session.extension).lower() + _set_session_base(session, updated) + self._refresh_file_sessions( + updated, + text=session.text, + dirty=False, + source_session_id=session.session_id, + ) + return { + "ok": True, + "document": _public_doc(updated), + "version": document_store.item_version(updated), + } + + def save_as(self, session_id: str, path: str, text: str | None = None) -> dict[str, Any]: + session = self._require(session_id) + if text is not None: + session.text = str(text) + + updated = document_store.save_text_document_as( + session.file_id, + path, + session.text, + context_id=session.context_id, + ) + old_file_id = session.file_id + session.file_id = updated["file_id"] + session.updated_at = time.time() + session.dirty = False + session.path = updated["path"] + session.title = updated["basename"] + session.extension = str(updated.get("extension") or session.extension).lower() + _set_session_base(session, updated) + return { + "ok": True, + "previous_file_id": old_file_id, + "document": _public_doc(updated), + "version": document_store.item_version(updated), + } + + def activate(self, session_id: str) -> dict[str, Any]: + session = self._require(session_id) + now = time.time() + previous_id = self._active_by_context.get(session.context_id) + if previous_id and previous_id in self._sessions: + self._sessions[previous_id].active = False + session.active = True + session.last_active_at = now + session.updated_at = now + self._active_by_context[session.context_id] = session.session_id + return {"ok": True, "session_id": session.session_id} + + def renamed(self, file_id: str, doc: dict[str, Any], text: str | None = None) -> dict[str, Any]: + updated = self._refresh_file_sessions(doc, text=text, dirty=False, refresh_dirty=True) + return {"ok": True, "updated": updated, "file_id": file_id} + + def refresh_document(self, file_id: str) -> dict[str, Any]: + normalized = str(file_id or "").strip() + if not normalized: + return {"ok": True, "refreshed": 0, "sessions": []} + try: + doc = document_store.get_document(normalized) + except Exception: + return {"ok": False, "refreshed": 0, "sessions": []} + if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS: + return {"ok": True, "refreshed": 0, "sessions": []} + + refreshed = self._refresh_file_sessions( + doc, + text=document_store.read_text_for_editor(doc), + dirty=False, + ) + return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed} + + def sync_external_file_mutations(self, paths: list[str] | tuple[str, ...] | str, context_id: str = "") -> dict[str, Any]: + raw_paths = [paths] if isinstance(paths, str) else list(paths or []) + normalized_paths = [str(path or "").strip() for path in raw_paths if str(path or "").strip()] + if not normalized_paths: + return {"ok": True, "matched": 0, "sessions": []} + + matched_file_ids: set[str] = set() + matched_sessions: list[str] = [] + for session in list(self._sessions.values()): + if not any(_paths_match(path, session.path, session.context_id) for path in normalized_paths): + continue + matched_file_ids.add(session.file_id) + matched_sessions.append(session.session_id) + + for file_id in matched_file_ids: + sessions = [session for session in self._sessions.values() if session.file_id == file_id] + if not sessions: + continue + session = sessions[0] + try: + doc = document_store.register_document(session.path, context_id=session.context_id) + except Exception: + try: + doc = document_store.get_document(file_id) + except Exception: + continue + for target in sessions: + _mark_session_external(target, doc) + continue + self.refresh_document(file_id) + + return { + "ok": True, + "matched": len(matched_file_ids), + "sessions": matched_sessions, + } + + def list_open(self, context_id: str = "", limit: int = 20) -> list[dict[str, Any]]: + context_id = str(context_id or "") + sessions = [session for session in self._sessions.values() if session.context_id == context_id] + sessions.sort(key=lambda item: (not item.active, -item.last_active_at, -item.updated_at)) + + grouped: dict[str, MarkdownSession] = {} + counts: dict[str, int] = {} + for session in sessions: + key = session.file_id or session.path + counts[key] = counts.get(key, 0) + 1 + current = grouped.get(key) + if current is None or session.active or session.last_active_at > current.last_active_at: + grouped[key] = session + + result = [] + for session in grouped.values(): + try: + doc = document_store.get_document(session.file_id) + version = document_store.item_version(doc) + size = doc.get("size", 0) + last_modified = doc.get("last_modified", "") + path = document_store.display_path(doc.get("path", session.path)) + title = doc.get("basename") or session.title + except Exception: + version = "" + size = 0 + last_modified = "" + path = document_store.display_path(session.path) + title = session.title + result.append({ + "session_id": session.session_id, + "file_id": session.file_id, + "title": title, + "extension": session.extension, + "path": path, + "version": version, + "size": size, + "last_modified": last_modified, + "dirty": session.dirty, + "active": session.active, + "external_modified": session.external_modified, + "external_version": session.external_version, + "open_sessions": counts.get(session.file_id or session.path, 1), + "last_active_at": session.last_active_at, + }) + + result.sort(key=lambda item: (not item["active"], -float(item["last_active_at"] or 0))) + safe_limit = max(1, int(limit or 20)) + return result[:safe_limit] + + def close(self, session_id: str) -> dict[str, Any]: + session = self._sessions.pop(str(session_id or ""), None) + if not session: + return {"ok": True, "closed": 0} + if self._active_by_context.get(session.context_id) == session.session_id: + replacement = sorted( + [item for item in self._sessions.values() if item.context_id == session.context_id], + key=lambda item: item.last_active_at, + reverse=True, + ) + if replacement: + self.activate(replacement[0].session_id) + else: + self._active_by_context.pop(session.context_id, None) + return {"ok": True, "closed": 1, "session_id": session_id} + + def close_sid(self, sid: str) -> int: + doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid] + for session_id in doomed: + self.close(session_id) + return len(doomed) + + def _save_conflict(self, session: MarkdownSession) -> dict[str, Any] | None: + try: + doc = document_store.get_document(session.file_id) + except Exception as exc: + return { + "ok": False, + "code": "editor_document_missing", + "error": f"Editor save failed because the document metadata is missing: {exc}", + } + + path = Path(doc["path"]) + desired = str(session.text or "").encode("utf-8") + desired_sha = document_store.sha256_bytes(desired) + current_exists = path.exists() + current = path.read_bytes() if current_exists else b"" + current_sha = document_store.sha256_bytes(current) if current_exists else "" + expected_sha = session.base_sha256 or str(doc.get("sha256") or "") + + if expected_sha and current_sha != expected_sha: + if current_exists and desired_sha == current_sha: + updated = document_store.register_document(path, context_id=session.context_id) + session.dirty = False + session.path = updated["path"] + session.title = updated["basename"] + _set_session_base(session, updated) + self._refresh_file_sessions( + updated, + text=session.text, + dirty=False, + source_session_id=session.session_id, + ) + return { + "ok": True, + "document": _public_doc(updated), + "version": document_store.item_version(updated), + } + + latest_doc = _refresh_registered_doc(doc, context_id=session.context_id) + _mark_session_external(session, latest_doc) + return { + "ok": False, + "code": "external_change_conflict", + "error": ( + "This file changed on disk since the Editor loaded it. " + "Reload it before saving to avoid overwriting the newer file." + ), + "document": _public_doc(latest_doc), + "version": document_store.item_version(latest_doc), + } + + return None + + def _refresh_file_sessions( + self, + doc: dict[str, Any], + text: str | None = None, + dirty: bool | None = None, + *, + source_session_id: str = "", + refresh_dirty: bool = False, + ) -> list[str]: + file_id = str(doc.get("file_id") or "").strip() + refreshed: list[str] = [] + for session in self._sessions.values(): + if session.file_id != file_id: + continue + can_replace_text = ( + text is not None + and ( + refresh_dirty + or not session.dirty + or (source_session_id and session.session_id == source_session_id) + ) + ) + if can_replace_text: + session.text = str(text) + session.path = doc["path"] + session.title = doc["basename"] + session.extension = str(doc.get("extension") or session.extension).lower() + if dirty is not None and (can_replace_text or refresh_dirty or not session.dirty): + session.dirty = dirty + if can_replace_text or not session.dirty: + _set_session_base(session, doc) + elif text is not None: + _mark_session_external(session, doc) + session.updated_at = time.time() + refreshed.append(session.session_id) + return refreshed + + def _payload(self, session: MarkdownSession, doc: dict[str, Any]) -> dict[str, Any]: + return { + "ok": True, + "session_id": session.session_id, + "file_id": session.file_id, + "title": session.title, + "extension": session.extension, + "path": session.path, + "text": session.text, + "dirty": session.dirty, + "active": session.active, + "external_modified": session.external_modified, + "external_version": session.external_version, + "context_id": session.context_id, + "document": _public_doc(doc), + "version": document_store.item_version(doc), + } + + def _require(self, session_id: str) -> MarkdownSession: + normalized = str(session_id or "").strip() + session = self._sessions.get(normalized) + if not session: + raise FileNotFoundError(f"Editor session not found: {normalized}") + return session + + +def get_manager() -> MarkdownSessionManager: + global _manager + try: + return _manager + except NameError: + _manager = MarkdownSessionManager() + return _manager + + +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]: + return { + "file_id": doc["file_id"], + "path": document_store.display_path(doc["path"]), + "basename": doc["basename"], + "title": doc["basename"], + "extension": doc["extension"], + "size": doc["size"], + "version": document_store.item_version(doc), + "last_modified": doc["last_modified"], + "exists": Path(doc["path"]).exists(), + } + + +def _set_session_base(session: MarkdownSession, doc: dict[str, Any]) -> None: + session.base_sha256 = str(doc.get("sha256") or "") + session.base_version = document_store.item_version(doc) + session.external_modified = False + session.external_version = "" + + +def _mark_session_external(session: MarkdownSession, doc: dict[str, Any]) -> None: + session.external_modified = True + try: + session.external_version = document_store.item_version(doc) + except Exception: + session.external_version = "" + + +def _refresh_registered_doc(doc: dict[str, Any], context_id: str = "") -> dict[str, Any]: + try: + path = Path(doc["path"]) + if path.exists(): + return document_store.register_document(path, context_id=context_id) + except Exception: + pass + return doc + + +def _paths_match(left: str, right: str, context_id: str = "") -> bool: + left_path = _normalize_path_for_compare(left, context_id=context_id) + right_path = _normalize_path_for_compare(right, context_id=context_id) + return bool(left_path and right_path and left_path == right_path) + + +def _normalize_path_for_compare(path: str, context_id: str = "") -> str: + value = str(path or "").strip() + if not value: + return "" + try: + return str(document_store.normalize_path(value, context_id=context_id)) + except Exception: + pass + try: + return str(document_store._path_from_a0(value).resolve(strict=False)) + except Exception: + return str(Path(value).expanduser().resolve(strict=False)) + + +def _apply_text_patch(text: str, patch: dict[str, Any]) -> str: + if "content" in patch: + return str(patch.get("content") or "") + start = int(patch.get("start") or 0) + end = int(patch.get("end") if patch.get("end") is not None else start) + replacement = str(patch.get("text") or "") + start = max(0, min(len(text), start)) + end = max(start, min(len(text), end)) + return text[:start] + replacement + text[end:] diff --git a/plugins/_editor/helpers/open_files_context.py b/plugins/_editor/helpers/open_files_context.py new file mode 100644 index 0000000000000000000000000000000000000000..54d45e1c334ceccd56bc3006151dda0a127a603f --- /dev/null +++ b/plugins/_editor/helpers/open_files_context.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +from plugins._editor.helpers import markdown_sessions + + +def build_context(context_id: str = "", max_items: int = 20) -> str: + files = markdown_sessions.get_manager().list_open(context_id=context_id, limit=max_items) + if not files: + return "" + + lines = [ + "These Markdown or plain text files are open in the Editor for the active Agent Zero context. Content is omitted; use `text_editor` with action `read` before content-sensitive edits.", + ] + for item in files: + lines.append(format_open_file_line(item)) + lines.append( + "For these paths, `text_editor` is the canonical saved-edit tool. Use `open_in_canvas: true` only when the user asks to open the Editor UI; open Editor sessions refresh automatically after saved text edits." + ) + return "\n".join(lines) + + +def format_open_file_line(item: dict[str, Any]) -> str: + active = "active, " if item.get("active") else "" + dirty = "dirty, " if item.get("dirty") else "saved, " + external = "external changes pending, " if item.get("external_modified") else "" + return ( + f"- {item.get('title', 'Untitled')} " + f"(.{item.get('extension', 'md')}, {active}{dirty}{external}" + f"file_id={item.get('file_id', '')}, path={item.get('path', '')}, " + f"version={item.get('version', '')}, size={item.get('size', 0)} bytes, " + f"last_modified={item.get('last_modified', '')}, open_sessions={item.get('open_sessions', 1)})" + ) diff --git a/plugins/_editor/plugin.yaml b/plugins/_editor/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..16e76409c1228a3b8b4990008241baff114b1c93 --- /dev/null +++ b/plugins/_editor/plugin.yaml @@ -0,0 +1,5 @@ +name: _editor +title: Editor +description: Native Markdown editor surface for Agent Zero canvas and floating modal workflows. +version: 1.0.0 +always_enabled: false diff --git a/plugins/_editor/prompts/agent.extras.editor_open_files.md b/plugins/_editor/prompts/agent.extras.editor_open_files.md new file mode 100644 index 0000000000000000000000000000000000000000..fbd7e5824759ab16448cc01a1aa3897df14b287e --- /dev/null +++ b/plugins/_editor/prompts/agent.extras.editor_open_files.md @@ -0,0 +1,2 @@ +[EDITOR OPEN FILES] +{{editor_open_files}} diff --git a/plugins/_editor/webui/editor-panel.html b/plugins/_editor/webui/editor-panel.html new file mode 100644 index 0000000000000000000000000000000000000000..4fa12d63be60ffb92dad487f5563ce88c2c264a4 --- /dev/null +++ b/plugins/_editor/webui/editor-panel.html @@ -0,0 +1,1082 @@ + + + + + +
+ +
+ + + + diff --git a/plugins/_editor/webui/editor-preview.js b/plugins/_editor/webui/editor-preview.js new file mode 100644 index 0000000000000000000000000000000000000000..767fdb2a6b42015b85f1137cba3dc04a3f76824c --- /dev/null +++ b/plugins/_editor/webui/editor-preview.js @@ -0,0 +1,138 @@ +import { renderSafeMarkdown } from "/js/safe-markdown.js"; + +const FOOTNOTE_DEF_RE = /^\[\^([^\]]+)\]:\s*(.*)$/; + +export function renderEditorPreviewMarkdown(markdown = "", fullMarkdown = markdown) { + return renderSafeMarkdown(prepareFootnotes(markdown, fullMarkdown), { + allowDataImages: true, + allowLatex: true, + openExternalLinksInNewTab: true, + }); +} + +export function buildMarkdownPages(markdown = "", fallbackTitle = "Markdown") { + const source = String(markdown || ""); + return [{ + index: 0, + title: fallbackTitle, + level: 0, + anchor: slugifyHeading(fallbackTitle), + start: 0, + end: source.length, + markdown: source, + }]; +} + +export function slugifyHeading(text = "", used = new Map()) { + const base = String(text || "") + .toLowerCase() + .replace(/<[^>]+>/g, "") + .replace(/[`*_~[\]()]/g, "") + .replace(/&[a-z0-9#]+;/gi, "") + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") || "section"; + const count = used.get(base) || 0; + used.set(base, count + 1); + return count ? `${base}-${count + 1}` : base; +} + +export function resolveDocumentRelativePath(documentPath = "", target = "") { + const value = String(target || "").trim(); + if (!value) return ""; + if (value.startsWith("/")) return normalizePath(value); + const base = parentPath(documentPath); + return normalizePath(`${base}/${value}`); +} + +export function splitHref(href = "") { + const value = String(href || "").trim(); + const hashIndex = value.indexOf("#"); + if (hashIndex < 0) return { path: value, fragment: "" }; + return { + path: value.slice(0, hashIndex), + fragment: decodeURIComponent(value.slice(hashIndex + 1) || ""), + }; +} + +export function isExternalHref(href = "") { + const value = String(href || "").trim(); + return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) || value.startsWith("//"); +} + +export function isMarkdownPath(path = "") { + return /\.md(?:own)?$/i.test(String(path || "").split(/[?#]/, 1)[0]); +} + +function prepareFootnotes(markdown = "", fullMarkdown = markdown) { + const definitions = []; + const body = []; + for (const line of String(fullMarkdown || "").split("\n")) { + const match = line.match(FOOTNOTE_DEF_RE); + if (match) { + definitions.push({ id: match[1], text: match[2] }); + } + } + for (const line of String(markdown || "").split("\n")) { + if (line.match(FOOTNOTE_DEF_RE)) { + continue; + } + body.push(line); + } + if (!definitions.length) return markdown; + + const counts = new Map(); + let prepared = body.join("\n").replace(/\[\^([^\]]+)\]/g, (_all, id) => { + const number = definitions.findIndex((item) => item.id === id) + 1; + if (number <= 0) return `[^${id}]`; + const count = (counts.get(id) || 0) + 1; + counts.set(id, count); + const safeId = footnoteId(id); + return `${number}`; + }); + + prepared += "\n\n
\n
    \n"; + for (const definition of definitions) { + const safeId = footnoteId(definition.id); + prepared += `
  1. ${escapeHtml(definition.text)} Back
  2. \n`; + } + prepared += "
\n
"; + return prepared; +} + +function footnoteId(id = "") { + return String(id || "") + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") || "note"; +} + +function parentPath(path = "") { + const normalized = String(path || "").split(/[?#]/, 1)[0].replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index <= 0) return "/"; + return normalized.slice(0, index); +} + +function normalizePath(path = "") { + const absolute = String(path || "").startsWith("/"); + const parts = []; + for (const part of String(path || "").split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + parts.pop(); + continue; + } + parts.push(part); + } + return `${absolute ? "/" : ""}${parts.join("/")}`; +} + +function escapeHtml(value = "") { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/plugins/_editor/webui/editor-store.js b/plugins/_editor/webui/editor-store.js new file mode 100644 index 0000000000000000000000000000000000000000..bb8926df3044bd486ed98eaeb1fce41ab54b00cb --- /dev/null +++ b/plugins/_editor/webui/editor-store.js @@ -0,0 +1,1843 @@ +import { createStore } from "/js/AlpineStore.js"; +import { callJsonApi } from "/js/api.js"; +import { getNamespacedClient } from "/js/websocket.js"; +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; +import { + openLatest as openLatestSurface, + placeSurfaceModalHeaderAction, + registerUrlHandler, + setupFloatingSurfaceModalChrome, +} from "/js/surfaces.js"; +import { + buildMarkdownPages, + isExternalHref, + isMarkdownPath, + renderEditorPreviewMarkdown, + resolveDocumentRelativePath, + slugifyHeading, + splitHref, +} from "/plugins/_editor/webui/editor-preview.js"; + +const editorSocket = getNamespacedClient("/ws"); +editorSocket.addHandlers(["ws_webui"]); + +const SAVE_MESSAGE_MS = 1800; +const INPUT_PUSH_DELAY_MS = 650; +const MAX_HISTORY = 80; +const SOURCE_MODE = "source"; +const PREVIEW_MODE = "preview"; +const EDITOR_TEXT_EXTENSIONS = new Set(["md", "txt"]); + +function currentContextId() { + try { + return globalThis.getContext?.() || ""; + } catch { + return ""; + } +} + +function basename(path = "") { + const value = String(path || "").split("?")[0].split("#")[0]; + return value.split("/").filter(Boolean).pop() || "Untitled"; +} + +function extensionOf(path = "") { + const name = basename(path).toLowerCase(); + const index = name.lastIndexOf("."); + return index >= 0 ? name.slice(index + 1) : ""; +} + +function parentPath(path = "") { + const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index <= 0) return "/"; + return normalized.slice(0, index); +} + +function textDocumentFilename(path = "", fallback = "Untitled.md") { + const name = basename(path || fallback || "Untitled.md"); + const ext = extensionOf(name); + if (EDITOR_TEXT_EXTENSIONS.has(ext)) return name; + return `${name.replace(/\.+$/, "") || "Untitled"}.md`; +} + +function textDocumentDefaultExtension(path = "") { + const ext = extensionOf(path); + return EDITOR_TEXT_EXTENSIONS.has(ext) ? ext : "md"; +} + +function editorIntent(url = "") { + const raw = String(url || "").trim(); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "a0-editor:") return null; + if (parsed.hostname === "open") { + return { path: parsed.searchParams.get("path") || "" }; + } + return null; + } catch { + return null; + } +} + +function uniqueTabId(session = {}) { + return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`); +} + +function editorContainsFocus(element) { + const active = document.activeElement; + return Boolean(element && active && (element === active || element.contains(active))); +} + +function placeCaretAtEnd(element) { + if (!element) return; + if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") { + const length = element.value?.length || 0; + element.selectionStart = length; + element.selectionEnd = length; + return; + } + const selection = globalThis.getSelection?.(); + const range = document.createRange?.(); + if (!selection || !range) return; + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +} + +function normalizeTextDocument(doc = {}) { + const path = doc.path || ""; + const extension = String(doc.extension || extensionOf(path)).toLowerCase(); + return { + ...doc, + extension, + title: doc.title || doc.basename || basename(path), + basename: doc.basename || basename(path), + path, + }; +} + +function normalizeSession(payload = {}) { + const document = normalizeTextDocument(payload.document || payload); + return { + ...payload, + document, + extension: String(payload.extension || document.extension || "").toLowerCase(), + file_id: payload.file_id || document.file_id || "", + path: document.path || payload.path || "", + title: payload.title || document.title || document.basename || basename(document.path), + tab_id: uniqueTabId(payload), + text: String(payload.text || ""), + dirty: Boolean(payload.dirty), + active: Boolean(payload.active), + }; +} + +function documentLabel(document = {}) { + return document.title || document.basename || basename(document.path); +} + +function escapeRegExp(value = "") { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function textNodesUnder(root, skipSelector = "") { + const nodes = []; + if (!root) return nodes; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.nodeValue) return NodeFilter.FILTER_REJECT; + if (skipSelector && node.parentElement?.closest(skipSelector)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }); + while (walker.nextNode()) nodes.push(walker.currentNode); + return nodes; +} + +function aceModeForLanguage(language = "") { + const value = String(language || "").toLowerCase(); + const aliases = { + bash: "sh", + shell: "sh", + zsh: "sh", + py: "python", + js: "javascript", + jsx: "javascript", + ts: "typescript", + md: "markdown", + yml: "yaml", + }; + return aliases[value] || value || "text"; +} + +function taskLineIndexes(markdown = "") { + const indexes = []; + String(markdown || "").split("\n").forEach((line, index) => { + if (/^\s*(?:[-*+]|\d+[.)])\s+\[[ xX]\](?:\s+|$)/.test(line)) indexes.push(index); + }); + return indexes; +} + +async function callEditor(action, payload = {}) { + const explicitContextId = String(payload.ctxid || payload.context_id || "").trim(); + return await callJsonApi("/plugins/_editor/editor_session", { + action, + ...payload, + ctxid: explicitContextId || currentContextId(), + }); +} + +async function requestEditor(eventType, payload = {}, timeoutMs = 5000) { + const explicitContextId = String(payload.ctxid || payload.context_id || "").trim(); + const response = await editorSocket.request(eventType, { + ...payload, + ctxid: explicitContextId || currentContextId(), + }, { timeoutMs }); + const results = Array.isArray(response?.results) ? response.results : []; + const first = results.find((item) => item?.ok === true && isEditorSocketData(item?.data)) + || results.find((item) => item?.ok === true); + if (!first) { + const error = results.find((item) => item?.error)?.error; + throw new Error(error?.error || error?.code || `${eventType} failed`); + } + if (first.data?.editor_error) { + const error = first.data.editor_error; + throw new Error(error.error || error.code || `${eventType} failed`); + } + return first.data || {}; +} + +function isEditorSocketData(data) { + if (!data || typeof data !== "object") return false; + return ( + Object.prototype.hasOwnProperty.call(data, "editor_error") + || Object.prototype.hasOwnProperty.call(data, "ok") + || Object.prototype.hasOwnProperty.call(data, "session_id") + || Object.prototype.hasOwnProperty.call(data, "document") + ); +} + +const model = { + status: null, + tabs: [], + activeTabId: "", + session: null, + loading: false, + saving: false, + dirty: false, + error: "", + message: "", + pendingClose: null, + viewMode: SOURCE_MODE, + searchOpen: false, + searchQuery: "", + searchMatches: [], + searchIndex: -1, + activePageIndex: 0, + previewEditing: false, + previewEditDirty: false, + previewEditText: "", + previewEditPageIndex: -1, + aceUnavailable: false, + editorText: "", + sourceEditor: null, + _root: null, + _mode: "modal", + _initialized: false, + _saveMessageTimer: null, + _inputTimer: null, + _history: [], + _historyIndex: -1, + _historyPushedAt: 0, + _pendingFocus: false, + _pendingFocusEnd: true, + _focusAttempts: 0, + _headerCleanup: null, + _settingSourceEditorValue: false, + _sourceEditorChangeHandler: null, + _previewEnhanceTimer: null, + _staticHighlightPromise: null, + _pendingPreviewFragment: "", + + async init() { + if (this._initialized) return; + this._initialized = true; + await this.refresh(); + }, + + async onMount(element = null, options = {}) { + await this.init(); + if (element && element !== this._root) { + if (this.sourceEditor && !element.contains?.(this.sourceEditor.container)) { + this.destroySourceEditor(); + } + this._root = element; + } + this._mode = options?.mode === "canvas" ? "canvas" : "modal"; + if (this._mode === "modal") { + this.setupMarkdownModal(element); + } + this.scheduleSourceEditorInit(); + }, + + async onOpen(payload = {}) { + await this.init(); + await this.refresh(); + if (payload?.path || payload?.file_id) { + const contextId = String(payload.ctxid || payload.context_id || "").trim(); + await this.openSession({ + path: payload.path || "", + file_id: payload.file_id || "", + ctxid: contextId, + context_id: contextId, + refresh: payload.refresh === true, + source: payload.source || "", + }); + return; + } + }, + + beforeHostHidden() { + this.flushInput(); + }, + + cleanup() { + this.flushInput(); + this.destroySourceEditor(); + if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer); + this._previewEnhanceTimer = null; + this._headerCleanup?.(); + this._headerCleanup = null; + if (this._mode === "modal") this._root = null; + }, + + beginSurfaceHandoff() { + this.flushInput(); + }, + + async refresh() { + try { + const status = await callEditor("status"); + this.status = status || {}; + this.error = ""; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } + }, + + isSourceMode() { + return this.viewMode === SOURCE_MODE; + }, + + isPreviewMode() { + return this.viewMode === PREVIEW_MODE; + }, + + async setViewMode(mode) { + const next = mode === PREVIEW_MODE && this.isTextDocument() ? PREVIEW_MODE : SOURCE_MODE; + if (this.viewMode === next) return; + this.applyPreviewEdit({ silent: true }); + this.syncEditorText(); + this.viewMode = next; + this.cancelPendingClose(); + if (next === SOURCE_MODE) { + this.setSourceEditorText(this.editorText); + this.scheduleSourceEditorInit(); + this.refreshSourceEditorLayout(); + this.queueRender({ focus: Boolean(this.session), end: false }); + return; + } + this.clampActivePage(); + this.schedulePreviewEnhance(); + }, + + async toggleViewMode() { + if (!this.isTextDocument()) return; + await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE); + }, + + viewModeIcon() { + return this.isPreviewMode() ? "code" : "article"; + }, + + viewModeTitle() { + return this.isPreviewMode() ? "Source edit" : "Preview"; + }, + + pages() { + if (!this.isTextDocument()) return []; + return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {})); + }, + + currentPage() { + const pages = this.pages(); + const index = Math.max(0, Math.min(this.activePageIndex, pages.length - 1)); + return pages[index] || pages[0] || { title: this.tabTitle(this.session || {}), markdown: "" }; + }, + + pageTitle() { + return this.currentPage().title || this.tabTitle(this.session || {}); + }, + + previewHtml() { + if (!this.isTextDocument()) return ""; + return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText); + }, + + startPreviewEdit() { + if (!this.session || !this.isTextDocument() || !this.isPreviewMode()) return; + const page = this.currentPage(); + this.previewEditing = true; + this.previewEditDirty = false; + this.previewEditPageIndex = this.activePageIndex; + this.previewEditText = page.markdown || ""; + globalThis.requestAnimationFrame?.(() => { + const editor = this._root?.querySelector?.("[data-editor-preview-source]"); + editor?.focus?.({ preventScroll: true }); + }); + }, + + onPreviewEditInput() { + if (this.previewEditing) this.previewEditDirty = true; + }, + + cancelPreviewEdit() { + this.previewEditing = false; + this.previewEditDirty = false; + this.previewEditText = ""; + this.previewEditPageIndex = -1; + this.schedulePreviewEnhance(); + }, + + applyPreviewEdit(options = {}) { + if (!this.previewEditing) return false; + if (!this.previewEditDirty && options.force !== true) { + this.cancelPreviewEdit(); + return false; + } + const pages = this.pages(); + const index = Math.max(0, Math.min( + this.previewEditPageIndex >= 0 ? this.previewEditPageIndex : this.activePageIndex, + pages.length - 1, + )); + const page = pages[index]; + if (!page) { + this.cancelPreviewEdit(); + return false; + } + + let replacement = String(this.previewEditText || ""); + this.previewEditing = false; + this.previewEditDirty = false; + this.previewEditText = ""; + this.previewEditPageIndex = -1; + + return this.replacePageMarkdown(page, replacement, { + message: "Document updated", + silent: options.silent, + }); + }, + + replacePageMarkdown(page = null, markdown = "", options = {}) { + if (!page) return false; + const source = String(this.editorText || ""); + const start = Math.max(0, Number(page.start || 0)); + const end = Math.max(start, Number(page.end ?? source.length)); + const before = source.slice(0, start); + const after = source.slice(end); + let replacement = String(markdown || ""); + if (replacement && after && !replacement.endsWith("\n")) replacement += "\n"; + const next = before + replacement + after; + if (next === source) { + this.schedulePreviewEnhance(); + return false; + } + + this.editorText = next; + this.setSourceEditorText(next); + if (this.session) { + this.session.text = next; + this.session.dirty = true; + } + this.dirty = true; + this.pushHistory(next); + this.scheduleInputPush(); + this.clampActivePage(); + this.schedulePreviewEnhance(); + if (!options.silent && options.message) this.setMessage(options.message); + return true; + }, + + togglePreviewTask(taskIndex, checked) { + if (!this.session || !this.isTextDocument() || !this.isPreviewMode() || this.previewEditing) return false; + const page = this.currentPage(); + const lines = String(page.markdown || "").split("\n"); + const indexes = taskLineIndexes(page.markdown || ""); + const lineIndex = indexes[Number(taskIndex)]; + if (lineIndex == null || !lines[lineIndex]) return false; + const nextLine = lines[lineIndex].replace( + /^(\s*(?:[-*+]|\d+[.)])\s+\[)[ xX](\](?:\s+|$))/, + `$1${checked ? "x" : " "}$2`, + ); + if (nextLine === lines[lineIndex]) return false; + lines[lineIndex] = nextLine; + return this.replacePageMarkdown(page, lines.join("\n")); + }, + + clampActivePage() { + const pages = this.pages(); + this.activePageIndex = Math.max(0, Math.min(this.activePageIndex, Math.max(0, pages.length - 1))); + }, + + schedulePreviewEnhance() { + if (!this.isTextDocument() || !this.isPreviewMode()) return; + if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer); + this._previewEnhanceTimer = globalThis.setTimeout(() => { + this._previewEnhanceTimer = null; + this.enhancePreview(); + }, 0); + }, + + enhancePreview() { + const root = this._root?.querySelector?.("[data-editor-preview]"); + if (!root) return; + this.addHeadingIds(root); + this.enhanceTables(root); + this.enhanceTaskLists(root); + this.enhanceImages(root); + this.enhanceLinks(root); + this.enhanceCodeBlocks(root); + this.renderMath(root); + this.applySearchHighlights(root); + this.scrollPendingFragment(root); + }, + + addHeadingIds(root) { + const used = new Map(); + root.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((heading) => { + if (!heading.id) heading.id = slugifyHeading(heading.textContent || "", used); + }); + }, + + enhanceTables(root) { + root.querySelectorAll("table").forEach((table) => { + if (table.parentElement?.classList.contains("editor-table-wrap")) return; + const wrapper = document.createElement("div"); + wrapper.className = "editor-table-wrap"; + table.parentNode?.insertBefore(wrapper, table); + wrapper.appendChild(table); + }); + }, + + enhanceTaskLists(root) { + root.querySelectorAll('input[type="checkbox"]').forEach((checkbox, index) => { + if (checkbox.dataset.editorTaskEnhanced === "true") return; + checkbox.dataset.editorTaskEnhanced = "true"; + checkbox.dataset.editorTaskIndex = String(index); + checkbox.disabled = false; + checkbox.removeAttribute("disabled"); + checkbox.addEventListener("change", (event) => { + const target = event.currentTarget; + this.togglePreviewTask(Number(target?.dataset?.editorTaskIndex || 0), Boolean(target?.checked)); + }); + }); + }, + + enhanceImages(root) { + const docPath = this.session?.path || this.session?.document?.path || ""; + root.querySelectorAll("img[src]").forEach((image) => { + const src = image.getAttribute("src") || ""; + if (!src || isExternalHref(src) || src.startsWith("data:") || src.startsWith("/api/image_get")) return; + const resolved = resolveDocumentRelativePath(docPath, src); + image.setAttribute("src", `/api/image_get?path=${encodeURIComponent(resolved)}`); + image.setAttribute("loading", "lazy"); + }); + }, + + enhanceLinks(root) { + const docPath = this.session?.path || this.session?.document?.path || ""; + root.querySelectorAll("a[href]").forEach((anchor) => { + const href = anchor.getAttribute("href") || ""; + if (!href || isExternalHref(href)) return; + const { path, fragment } = splitHref(href); + if (!path && fragment) { + anchor.dataset.editorFragment = fragment; + return; + } + if (!isMarkdownPath(path)) return; + anchor.dataset.editorMarkdownPath = resolveDocumentRelativePath(docPath, path); + anchor.dataset.editorFragment = fragment; + }); + }, + + async enhanceCodeBlocks(root) { + root.querySelectorAll("pre > code").forEach((code) => { + const pre = code.parentElement; + if (!pre || pre.parentElement?.classList.contains("editor-code-block")) return; + const wrapper = document.createElement("div"); + wrapper.className = "editor-code-block"; + const header = document.createElement("div"); + header.className = "editor-code-header"; + const language = this.codeLanguage(code); + const label = document.createElement("span"); + label.className = "editor-code-language"; + label.textContent = language || "text"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "editor-code-copy"; + button.textContent = "Copy"; + button.addEventListener("click", async () => { + await navigator.clipboard?.writeText(code.textContent || ""); + button.textContent = "Copied"; + globalThis.setTimeout(() => { button.textContent = "Copy"; }, 1200); + }); + header.append(label, button); + pre.parentNode?.insertBefore(wrapper, pre); + wrapper.append(header, pre); + this.highlightCodeBlock(code, language); + }); + }, + + codeLanguage(code) { + for (const className of code.classList || []) { + if (className.startsWith("language-")) return className.slice("language-".length); + if (className.startsWith("lang-")) return className.slice("lang-".length); + } + return ""; + }, + + async highlightCodeBlock(code, language) { + if (!language || !globalThis.ace?.require) return; + const source = code.textContent || ""; + try { + const highlighter = await this.loadAceStaticHighlighter(); + const darkMode = globalThis.localStorage?.getItem("darkMode"); + const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github"; + const mode = `ace/mode/${aceModeForLanguage(language)}`; + highlighter.render(source, mode, theme, 1, true, (result) => { + code.innerHTML = result.html; + code.classList.add("is-highlighted"); + }); + } catch { + // Fenced code still renders as preformatted text if highlighting is unavailable. + } + }, + + loadAceStaticHighlighter() { + if (this._staticHighlightPromise) return this._staticHighlightPromise; + this._staticHighlightPromise = new Promise((resolve, reject) => { + let existing = null; + try { + existing = globalThis.ace?.require?.("ace/ext/static_highlight"); + } catch { + existing = null; + } + if (existing?.render) { + resolve(existing); + return; + } + const script = document.createElement("script"); + script.src = "/vendor/ace-min/ext-static_highlight.js"; + script.onload = () => { + const loaded = globalThis.ace?.require?.("ace/ext/static_highlight"); + loaded?.render ? resolve(loaded) : reject(new Error("ACE highlighter unavailable")); + }; + script.onerror = () => reject(new Error("ACE highlighter failed to load")); + document.head.appendChild(script); + }); + return this._staticHighlightPromise; + }, + + renderMath(root) { + if (!globalThis.katex?.render) return; + for (const node of textNodesUnder(root, "code,pre,.katex,.editor-code-block")) { + this.replaceMathInTextNode(node); + } + }, + + replaceMathInTextNode(node) { + const text = node.nodeValue || ""; + const pattern = /(\$\$[^$]+\$\$|\$[^$\n]+\$)/g; + if (!pattern.test(text)) return; + pattern.lastIndex = 0; + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + let match; + while ((match = pattern.exec(text))) { + if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); + const raw = match[0]; + const displayMode = raw.startsWith("$$"); + const expression = raw.slice(displayMode ? 2 : 1, displayMode ? -2 : -1); + const span = document.createElement(displayMode ? "div" : "span"); + span.className = displayMode ? "editor-math-display" : "editor-math-inline"; + try { + globalThis.katex.render(expression, span, { throwOnError: false, displayMode }); + } catch { + span.textContent = raw; + } + fragment.append(span); + lastIndex = match.index + raw.length; + } + if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex))); + node.parentNode?.replaceChild(fragment, node); + }, + + openSearch() { + if (!this.isTextDocument()) return; + if (!this.isPreviewMode()) { + this.setViewMode(PREVIEW_MODE); + } + this.searchOpen = true; + this.runSearch(); + globalThis.requestAnimationFrame?.(() => { + this._root?.querySelector?.("[data-editor-search]")?.focus?.(); + }); + }, + + closeSearch() { + this.searchOpen = false; + this.searchQuery = ""; + this.searchMatches = []; + this.searchIndex = -1; + this.schedulePreviewEnhance(); + }, + + searchCountLabel() { + if (!this.searchQuery) return ""; + if (!this.searchMatches.length) return "0 of 0"; + return `${this.searchIndex + 1} of ${this.searchMatches.length}`; + }, + + runSearch() { + const query = String(this.searchQuery || ""); + if (!query) { + this.searchMatches = []; + this.searchIndex = -1; + this.schedulePreviewEnhance(); + return; + } + const lower = query.toLowerCase(); + const matches = []; + for (const page of this.pages()) { + const text = this.renderedTextForPage(page); + let index = 0; + let occurrence = 0; + while ((index = text.toLowerCase().indexOf(lower, index)) >= 0) { + matches.push({ pageIndex: page.index, occurrence, offset: index }); + occurrence += 1; + index += Math.max(1, lower.length); + } + } + this.searchMatches = matches; + this.searchIndex = matches.length ? 0 : -1; + this.goToCurrentSearchMatch(); + }, + + nextSearchMatch() { + if (!this.searchMatches.length) return; + this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length; + this.goToCurrentSearchMatch(); + }, + + previousSearchMatch() { + if (!this.searchMatches.length) return; + this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length; + this.goToCurrentSearchMatch(); + }, + + goToCurrentSearchMatch() { + const match = this.searchMatches[this.searchIndex]; + if (!match) { + this.schedulePreviewEnhance(); + return; + } + this.activePageIndex = match.pageIndex; + this.schedulePreviewEnhance(); + }, + + renderedTextForPage(page) { + const html = renderEditorPreviewMarkdown(page.markdown || "", this.editorText); + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.body.textContent || ""; + }, + + applySearchHighlights(root) { + root.querySelectorAll("mark.editor-search-mark").forEach((mark) => { + mark.replaceWith(document.createTextNode(mark.textContent || "")); + }); + const query = String(this.searchQuery || ""); + if (!query || !this.searchMatches.length) return; + const regex = new RegExp(escapeRegExp(query), "gi"); + const current = this.searchMatches[this.searchIndex]; + let occurrence = 0; + for (const node of textNodesUnder(root, "script,style")) { + const text = node.nodeValue || ""; + if (!regex.test(text)) continue; + regex.lastIndex = 0; + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + let match; + while ((match = regex.exec(text))) { + if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); + const mark = document.createElement("mark"); + mark.className = "editor-search-mark"; + if (current?.pageIndex === this.activePageIndex && current.occurrence === occurrence) { + mark.classList.add("is-current"); + } + mark.textContent = match[0]; + fragment.append(mark); + occurrence += 1; + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex))); + node.parentNode?.replaceChild(fragment, node); + } + root.querySelector("mark.editor-search-mark.is-current")?.scrollIntoView?.({ block: "center" }); + }, + + async handlePreviewClick(event) { + const anchor = event.target?.closest?.("a[href]"); + if (!anchor) return; + const markdownPath = anchor.dataset.editorMarkdownPath || ""; + const fragment = anchor.dataset.editorFragment || ""; + if (!markdownPath && fragment) { + event.preventDefault(); + this.navigateToFragment(fragment); + return; + } + if (!markdownPath) return; + event.preventDefault(); + this._pendingPreviewFragment = fragment; + const opened = await this.openSession({ path: markdownPath, refresh: true, source: "editor-preview-link" }); + if (!opened) return; + if (this.isPreviewMode() && fragment) { + this.navigateToFragment(fragment); + } + }, + + navigateToFragment(fragment = "") { + const target = String(fragment || "").replace(/^#/, ""); + if (!target) return; + const pages = this.pages(); + const normalized = target.toLowerCase(); + for (const page of pages) { + const doc = new DOMParser().parseFromString(renderEditorPreviewMarkdown(page.markdown || "", this.editorText), "text/html"); + const used = new Map(); + const headings = [...doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6")]; + if (headings.some((heading) => (heading.id || slugifyHeading(heading.textContent || "", used)) === normalized)) { + this.activePageIndex = page.index; + this._pendingPreviewFragment = target; + this.schedulePreviewEnhance(); + return; + } + } + this._pendingPreviewFragment = target; + this.schedulePreviewEnhance(); + }, + + scrollPendingFragment(root) { + const fragment = this._pendingPreviewFragment; + if (!fragment) return; + const target = root.querySelector(`#${CSS.escape(fragment)}`); + if (target) { + target.scrollIntoView({ block: "start" }); + this._pendingPreviewFragment = ""; + } + }, + + handleEditorKeydown(event) { + if (!(event.metaKey || event.ctrlKey) || !this.isTextDocument()) return; + const key = event.key.toLowerCase(); + const historyAction = key === "y" || (key === "z" && event.shiftKey) ? "redo" : key === "z" ? "undo" : ""; + const nativeEditing = event.target?.matches?.("input, textarea, [contenteditable='true']") + && !event.target.closest?.("[data-editor-ace], [data-editor-source]"); + if (nativeEditing) return; + if (historyAction && !this.previewEditing) { + event.preventDefault(); + event.stopPropagation(); + this[historyAction](); + return; + } + if (key === "f") { + event.preventDefault(); + this.openSearch(); + } + }, + + async create(kind = "document", format = "") { + const requested = String(format || "md").toLowerCase().replace(/^\./, ""); + const fmt = EDITOR_TEXT_EXTENSIONS.has(requested) ? requested : "md"; + const title = this.defaultTitle(kind, fmt); + return await this.openSession({ + action: "create", + kind: "document", + format: fmt, + title, + }); + }, + + async openFileBrowser() { + let workdirPath = "/a0/usr/workdir"; + try { + const home = await callEditor("home"); + if (home?.path) { + workdirPath = home.path; + } else { + const response = await callJsonApi("settings_get", null); + workdirPath = response?.settings?.workdir_path || workdirPath; + } + } catch { + try { + const response = await callJsonApi("settings_get", null); + workdirPath = response?.settings?.workdir_path || workdirPath; + } catch { + // The file browser can still open with the static fallback. + } + } + await fileBrowserStore.openTextPicker(workdirPath, async ({ selectedFiles = [] } = {}) => { + const files = selectedFiles.filter((file) => file?.path); + if (!files.length) return false; + for (const file of files) { + const session = await this.openPath(fileBrowserStore.normalizePath(file.path), { source: "file-browser", refresh: true }); + if (!session || session.ok === false) { + throw new Error(this.error || `Could not open ${file.name || file.path}`); + } + } + return true; + }); + }, + + async openPath(path, options = {}) { + return await this.openSession({ + path: String(path || ""), + source: options?.source || "", + refresh: options?.refresh === true, + }); + }, + + async openSession(payload = {}) { + this.loading = true; + this.error = ""; + try { + const response = await callEditor(payload.action || "open", payload); + if (response?.ok === false) { + this.error = response.error || "Text document could not be opened."; + return null; + } + if (response?.requires_desktop) { + const document = normalizeTextDocument(response.document || response); + this.setMessage(`${documentLabel(document)} uses the Desktop surface.`); + await this.refresh(); + return response; + } + const session = normalizeSession(response); + this.installSession(session); + await this.refresh(); + return session; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + return null; + } finally { + this.loading = false; + } + }, + + installSession(session) { + const existingIndex = this.tabs.findIndex((tab) => ( + (session.file_id && tab.file_id === session.file_id) + || (session.path && tab.path === session.path) + )); + if (existingIndex >= 0) { + const tabId = this.tabs[existingIndex].tab_id; + const wasActive = this.activeTabId === tabId || this.session?.tab_id === tabId; + const merged = { ...this.tabs[existingIndex], ...session, tab_id: tabId }; + this.tabs.splice(existingIndex, 1, merged); + this.activeTabId = tabId; + if (wasActive) { + this.hydrateActiveSession(merged, { preservePage: true, focus: false }); + return; + } + } else { + this.tabs.push(session); + this.activeTabId = session.tab_id; + } + this.selectTab(this.activeTabId); + }, + + hydrateActiveSession(tab, options = {}) { + this.session = tab || null; + this.activeTabId = tab?.tab_id || ""; + this.editorText = String(tab?.text || ""); + this.dirty = Boolean(tab?.dirty); + if (this.previewEditing) this.cancelPreviewEdit(); + if (!options.preservePage) { + this.activePageIndex = 0; + } else { + this.clampActivePage(); + } + this.searchMatches = []; + this.searchIndex = -1; + this.resetHistory(this.editorText); + this.setSourceEditorText(this.editorText); + this.updateSourceEditorMode(); + if (tab?.session_id) { + requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {}); + } + if (this.searchOpen && this.searchQuery) this.runSearch(); + else if (this.isSourceMode()) this.scheduleSourceEditorInit(); + else this.schedulePreviewEnhance(); + this.refreshSourceEditorLayout(); + this.queueRender({ focus: this.isSourceMode() && Boolean(tab) && options.focus !== false, end: false }); + }, + + selectTab(tabId, options = {}) { + this.applyPreviewEdit({ silent: true }); + this.syncEditorText(); + const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null; + this.previewEditing = false; + this.previewEditDirty = false; + this.previewEditText = ""; + this.previewEditPageIndex = -1; + this.hydrateActiveSession(tab, { preservePage: false, focus: options.focus !== false }); + }, + + ensureActiveTab() { + if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return; + if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false }); + }, + + isActiveTab(tab) { + return Boolean(tab && tab.tab_id === this.activeTabId); + }, + + isTabDirty(tab) { + return Boolean(tab?.dirty || (this.isActiveTab(tab) && (this.dirty || this.previewEditDirty))); + }, + + hasPendingClose() { + return Boolean(this.pendingClose); + }, + + pendingCloseTitle() { + const pending = this.pendingClose; + if (!pending) return ""; + if (pending.kind === "all") { + return `Close ${pending.totalCount || 0} open files?`; + } + const tab = this.tabs.find((item) => item.tab_id === pending.tabId); + return `Close ${this.tabTitle(tab || {})}?`; + }, + + pendingCloseMessage() { + const pending = this.pendingClose; + if (!pending) return ""; + const dirtyCount = Number(pending.dirtyCount || 0); + if (pending.kind === "all") { + if (dirtyCount === 0) return "All open text files will be closed."; + return `${dirtyCount} open ${dirtyCount === 1 ? "file has" : "files have"} unsaved changes.`; + } + if (dirtyCount > 0) return "This file has unsaved changes."; + return "This file will be closed."; + }, + + pendingCloseHasDirty() { + return Number(this.pendingClose?.dirtyCount || 0) > 0; + }, + + pendingCloseDiscardLabel() { + return this.pendingCloseHasDirty() ? "Discard" : "Close"; + }, + + beginCloseConfirmation(kind, tabIds = []) { + const ids = tabIds.filter(Boolean); + const tabs = ids.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean); + const dirtyCount = tabs.filter((tab) => this.isTabDirty(tab)).length; + this.pendingClose = { + kind, + tabId: kind === "single" ? ids[0] || "" : "", + tabIds: ids, + totalCount: tabs.length, + dirtyCount, + }; + if (kind === "single" && ids[0] && this.activeTabId !== ids[0]) { + this.selectTab(ids[0], { focus: false }); + } + }, + + cancelPendingClose() { + this.pendingClose = null; + }, + + async confirmPendingClose(options = {}) { + const pending = this.pendingClose; + if (!pending || this.loading) return; + this.pendingClose = null; + const save = options.save === true; + if (pending.kind === "all") { + await this.closeAllFiles({ confirm: false, save, tabIds: pending.tabIds || [] }); + return; + } + await this.closeTab(pending.tabId, { confirm: false, save }); + }, + + async closeTab(tabId, options = {}) { + const tab = this.tabs.find((item) => item.tab_id === tabId); + if (!tab) return; + if (this.isTabDirty(tab) && options.confirm !== false) { + this.beginCloseConfirmation("single", [tab.tab_id]); + return; + } + await this.closeTabNow(tab, { save: options.save === true }); + }, + + async closeTabNow(tab, options = {}) { + if (!tab || this.loading) return false; + const tabId = tab.tab_id; + if (options.save === true && this.isTabDirty(tab)) { + const saved = await this.saveTab(tab); + if (!saved) return false; + } + if (this.activeTabId === tabId && this.previewEditing) this.cancelPreviewEdit(); + try { + if (tab.session_id) { + await requestEditor("editor_close", { session_id: tab.session_id }, 2500).catch(() => null); + } + await callEditor("close", { + session_id: tab.session_id || "", + store_session_id: tab.store_session_id || "", + file_id: tab.file_id || "", + }); + } catch (error) { + console.warn("Editor close skipped", error); + } + this.tabs = this.tabs.filter((item) => item.tab_id !== tabId); + if (this.pendingClose?.tabId === tabId || this.pendingClose?.tabIds?.includes(tabId)) { + this.pendingClose = null; + } + if (this.activeTabId === tabId) { + this.session = null; + this.activeTabId = ""; + this.editorText = ""; + this.dirty = false; + this.ensureActiveTab(); + } + this.ensureActiveTab(); + await this.refresh(); + return true; + }, + + async closeActiveFile() { + if (!this.session || this.loading) return; + await this.closeTab(this.session.tab_id); + }, + + async closeAllFiles(options = {}) { + if (this.loading) return; + const requestedIds = Array.isArray(options.tabIds) && options.tabIds.length + ? options.tabIds + : this.visibleTabs().map((tab) => tab.tab_id); + const tabs = requestedIds.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean); + if (!tabs.length) return; + + const dirtyTabs = tabs.filter((tab) => this.isTabDirty(tab)); + if (dirtyTabs.length && options.confirm !== false) { + this.beginCloseConfirmation("all", tabs.map((tab) => tab.tab_id)); + return; + } + + this.pendingClose = null; + for (const tab of [...tabs]) { + const current = this.tabs.find((item) => item.tab_id === tab.tab_id); + if (!current) continue; + const closed = await this.closeTabNow(current, { + save: options.save === true && this.isTabDirty(current), + }); + if (!closed) break; + } + }, + + scheduleSourceEditorInit() { + if (!this.isSourceMode()) return; + globalThis.requestAnimationFrame?.(() => { + globalThis.requestAnimationFrame?.(() => this.initSourceEditor()); + }); + }, + + initSourceEditor() { + if (!this.isSourceMode() || !this._root) return; + const container = this._root.querySelector?.("[data-editor-ace]"); + if (this.sourceEditor && !this._root.contains?.(this.sourceEditor.container)) { + this.destroySourceEditor(); + } + if (!container || this.sourceEditor) return; + if (!globalThis.ace?.edit) { + this.aceUnavailable = true; + return; + } + + const editor = globalThis.ace.edit(container); + const darkMode = globalThis.localStorage?.getItem("darkMode"); + const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github"; + editor.setTheme(theme); + editor.session.setMode(this.sourceEditorMode()); + editor.session.setUseWrapMode(true); + editor.setOptions({ + fontSize: "13px", + showGutter: false, + showPrintMargin: false, + useWorker: false, + }); + editor.renderer.setShowGutter(false); + editor.renderer.setScrollMargin(14, 14, 0, 0); + editor.setValue(this.editorText || "", -1); + this._sourceEditorChangeHandler = () => { + if (this._settingSourceEditorValue) return; + this.editorText = editor.getValue(); + this.onSourceInput(); + }; + editor.session.on("change", this._sourceEditorChangeHandler); + this.sourceEditor = editor; + this.aceUnavailable = false; + this.updateSourceEditorMode(); + this.queueRender({ focus: Boolean(this.session), end: false }); + }, + + sourceEditorMode(tab = this.session) { + return this.isMarkdown(tab) ? "ace/mode/markdown" : "ace/mode/text"; + }, + + updateSourceEditorMode(tab = this.session) { + try { + this.sourceEditor?.session?.setMode(this.sourceEditorMode(tab)); + } catch {} + }, + + destroySourceEditor() { + if (this.sourceEditor?.session && this._sourceEditorChangeHandler) { + this.sourceEditor.session.off?.("change", this._sourceEditorChangeHandler); + } + const container = this.sourceEditor?.container; + this.sourceEditor?.destroy?.(); + if (container) container.textContent = ""; + this.sourceEditor = null; + this._sourceEditorChangeHandler = null; + }, + + setSourceEditorText(text = "") { + if (!this.sourceEditor) return; + const value = String(text || ""); + if (this.sourceEditor.getValue() === value) return; + this._settingSourceEditorValue = true; + this.sourceEditor.setValue(value, -1); + this._settingSourceEditorValue = false; + this.refreshSourceEditorLayout(); + }, + + refreshSourceEditorLayout() { + const editor = this.sourceEditor; + if (!editor) return; + const refresh = () => { + editor.resize?.(true); + editor.renderer?.updateFull?.(); + editor.renderer?.updateText?.(); + }; + if (globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(refresh)); + } else { + globalThis.setTimeout(refresh, 0); + } + }, + + async save() { + if (!this.session || this.saving || !this.isTextDocument()) return; + this.applyPreviewEdit({ silent: true }); + this.syncEditorText(); + this.saving = true; + this.error = ""; + try { + let response; + const payload = { session_id: this.session.session_id, text: this.editorText }; + try { + response = await requestEditor("editor_save", payload, 10000); + } catch (_socketError) { + response = await callEditor("save", payload); + } + if (response?.ok === false) throw new Error(response.error || "Save failed."); + const document = normalizeTextDocument(response.document || this.session.document || {}); + const updated = { + ...this.session, + text: this.editorText, + dirty: false, + document, + path: document.path || this.session.path, + file_id: document.file_id || this.session.file_id, + extension: document.extension || this.session.extension, + version: document.version || response.version || this.session.version, + }; + this.replaceActiveSession(updated); + this.dirty = false; + this.setMessage("Saved"); + await this.refresh(); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + this.saving = false; + } + }, + + async downloadActiveFile() { + if (!this.session || this.saving || !this.isTextDocument()) return; + if (this.dirty) await this.save(); + if (this.dirty) return; + const path = this.session.path || this.session.document?.path; + if (path) fileBrowserStore.downloadFile({ path, name: this.tabTitle() }); + }, + + async saveAs() { + if (!this.session || this.saving || !this.isTextDocument()) return; + this.applyPreviewEdit({ silent: true }); + this.syncEditorText(); + + let startPath = parentPath(this.session.path || this.session.document?.path || ""); + if (!startPath || startPath === "/") { + try { + const home = await callEditor("home"); + startPath = home?.path || startPath || "/a0/usr/workdir"; + } catch { + startPath = "/a0/usr/workdir"; + } + } + + await fileBrowserStore.openSaveAsPicker(startPath, { + filename: textDocumentFilename(this.session.path || this.session.title || "Untitled.md"), + defaultExtension: textDocumentDefaultExtension(this.session.path || this.session.title || "Untitled.md"), + onConfirm: async ({ path } = {}) => { + if (!path) return false; + await this.saveAsPath(path); + return true; + }, + }); + }, + + async saveAsPath(path) { + if (!this.session || this.saving || !this.isTextDocument()) return null; + this.saving = true; + this.error = ""; + try { + const payload = { + session_id: this.session.session_id, + store_session_id: this.session.store_session_id || "", + path, + text: this.editorText, + }; + const response = await callEditor("save_as", payload); + if (response?.ok === false) throw new Error(response.error || "Save As failed."); + const document = normalizeTextDocument(response.document || this.session.document || {}); + const updated = { + ...this.session, + text: this.editorText, + dirty: false, + document, + title: document.title || document.basename || basename(document.path), + path: document.path || path, + file_id: document.file_id || this.session.file_id, + extension: document.extension || this.session.extension, + store_session_id: response.store_session_id || this.session.store_session_id, + version: document.version || response.version || this.session.version, + }; + this.replaceActiveSession(updated); + this.dirty = false; + this.setMessage("Saved As"); + await this.refresh(); + return updated; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + throw error; + } finally { + this.saving = false; + } + }, + + async saveTab(tab) { + if (!tab || this.saving || !this.isTextDocument(tab)) return false; + if (this.isActiveTab(tab)) { + this.applyPreviewEdit({ silent: true }); + this.syncEditorText(); + } + this.saving = true; + this.error = ""; + try { + let response; + const payload = { + session_id: tab.session_id, + text: this.isActiveTab(tab) ? this.editorText : String(tab.text || ""), + }; + try { + response = await requestEditor("editor_save", payload, 10000); + } catch (_socketError) { + response = await callEditor("save", payload); + } + if (response?.ok === false) throw new Error(response.error || "Save failed."); + const document = normalizeTextDocument(response.document || tab.document || {}); + const updated = { + ...tab, + text: payload.text, + dirty: false, + document, + path: document.path || tab.path, + file_id: document.file_id || tab.file_id, + extension: document.extension || tab.extension, + version: document.version || response.version || tab.version, + }; + this.replaceSession(tab, updated); + if (this.isActiveTab(updated)) { + this.dirty = false; + } + this.setMessage("Saved"); + await this.refresh(); + return true; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + return false; + } finally { + this.saving = false; + } + }, + + async renameActiveFile() { + if (!this.session || this.saving) return; + this.applyPreviewEdit({ silent: true }); + const session = this.session; + const path = session.path || session.document?.path || ""; + if (!path) { + this.error = "This document does not have a file path to rename."; + return; + } + const name = basename(path || session.title || ""); + const extension = extensionOf(name); + await fileBrowserStore.openRenameModal( + { + name, + path, + is_dir: false, + size: session.document?.size || 0, + modified: session.document?.last_modified || "", + type: "document", + }, + { + currentPath: parentPath(path), + validateName: (newName) => { + if (!extension) return true; + return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`; + }, + performRename: async ({ path: renamedPath }) => { + const payload = { + file_id: session.file_id || "", + path: renamedPath, + }; + if (this.isTextDocument(session)) { + this.syncEditorText(); + payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || ""; + } + return await callEditor("renamed", payload); + }, + onRenamed: async ({ path: renamedPath, response }) => { + await this.handleActiveFileRenamed(session, renamedPath, response); + }, + }, + ); + }, + + async handleActiveFileRenamed(session, renamedPath, renameResponse = null) { + const response = renameResponse || await callEditor("renamed", { + file_id: session.file_id || "", + path: renamedPath, + }); + if (response?.ok === false) throw new Error(response.error || "Rename failed."); + + const document = normalizeTextDocument(response.document || session.document || {}); + const updated = { + ...session, + document, + title: document.title || document.basename || basename(document.path), + path: document.path || renamedPath, + extension: document.extension || session.extension, + file_id: document.file_id || session.file_id, + version: document.version || response.version || session.version, + text: this.session?.tab_id === session.tab_id ? this.editorText : session.text, + dirty: false, + }; + this.replaceSession(session, updated); + this.dirty = false; + this.setMessage("Renamed"); + await this.refresh(); + }, + + replaceActiveSession(next) { + if (!this.session) return; + this.replaceSession(this.session, next); + }, + + replaceSession(previous, next) { + const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id); + if (wasActive) { + this.session = next; + this.updateSourceEditorMode(next); + } + const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id)); + if (index >= 0) this.tabs.splice(index, 1, next); + }, + + setMessage(value) { + this.message = value; + if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer); + this._saveMessageTimer = globalThis.setTimeout(() => { + this.message = ""; + this._saveMessageTimer = null; + }, SAVE_MESSAGE_MS); + }, + + resetHistory(text) { + this._history = [String(text || "")]; + this._historyIndex = 0; + this._historyPushedAt = 0; + }, + + pushHistory(text, coalesce = false) { + const value = String(text || ""); + if (this._history[this._historyIndex] === value) return; + const now = Date.now(); + if ( + coalesce + && this._historyPushedAt + && now - this._historyPushedAt <= INPUT_PUSH_DELAY_MS + && this._historyIndex === this._history.length - 1 + && this._historyIndex > 0 + ) { + this._history[this._historyIndex] = value; + this._historyPushedAt = now; + return; + } + this._history = this._history.slice(0, this._historyIndex + 1); + this._history.push(value); + if (this._history.length > MAX_HISTORY) this._history.shift(); + this._historyIndex = this._history.length - 1; + this._historyPushedAt = coalesce ? now : 0; + }, + + undo() { + if (this._historyIndex <= 0) return; + this._historyPushedAt = 0; + this._historyIndex -= 1; + this.applyEditorText(this._history[this._historyIndex], true); + }, + + redo() { + if (this._historyIndex >= this._history.length - 1) return; + this._historyPushedAt = 0; + this._historyIndex += 1; + this.applyEditorText(this._history[this._historyIndex], true); + }, + + canUndo() { + return this._historyIndex > 0; + }, + + canRedo() { + return this._historyIndex < this._history.length - 1; + }, + + applyEditorText(text, markDirty = false) { + this.editorText = String(text || ""); + this.setSourceEditorText(this.editorText); + if (this.session) { + this.session.text = this.editorText; + this.session.dirty = markDirty || this.session.dirty; + } + if (markDirty) this.markDirty(); + this.queueRender({ focus: true }); + }, + + markDirty() { + this.dirty = true; + if (this.session) this.session.dirty = true; + }, + + onSourceInput() { + this.markDirty(); + this.pushHistory(this.editorText, true); + this.scheduleInputPush(); + }, + + syncEditorText() { + if (!this.session) return; + if (this.previewEditing) return; + if (this.sourceEditor && this.isSourceMode()) { + this.editorText = this.sourceEditor.getValue(); + } + this.session.text = this.editorText; + }, + + scheduleInputPush() { + if (!this.session?.session_id || !this.isTextDocument()) return; + if (this._inputTimer) globalThis.clearTimeout(this._inputTimer); + this._inputTimer = globalThis.setTimeout(() => { + this._inputTimer = null; + this.flushInput(); + }, INPUT_PUSH_DELAY_MS); + }, + + flushInput() { + if (!this.session?.session_id || !this.isTextDocument()) return; + if (this.previewEditing) return; + this.syncEditorText(); + requestEditor("editor_input", { + session_id: this.session.session_id, + text: this.editorText, + }, 3000).catch(() => {}); + }, + + format(command) { + if (!this.session || !this.isTextDocument()) return; + if (this.sourceEditor && this.isSourceMode()) { + const selected = this.sourceEditor.getSelectedText(); + const replacement = this.formatReplacement(command, selected); + if (replacement === selected) return; + this.sourceEditor.session.replace(this.sourceEditor.getSelectionRange(), replacement); + this.editorText = this.sourceEditor.getValue(); + this.onSourceInput(); + this.sourceEditor.focus(); + return; + } + const textarea = this._root?.querySelector?.("[data-editor-source]"); + if (!textarea) return; + const start = textarea.selectionStart || 0; + const end = textarea.selectionEnd || start; + const selected = this.editorText.slice(start, end); + const replacement = this.formatReplacement(command, selected); + if (replacement === selected) return; + this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`; + this.onSourceInput(); + globalThis.requestAnimationFrame?.(() => { + textarea.focus(); + textarea.selectionStart = start; + textarea.selectionEnd = start + replacement.length; + }); + }, + + formatReplacement(command, selected = "") { + if (command === "bold") return `**${selected || "text"}**`; + if (command === "italic") return `*${selected || "text"}*`; + if (command === "list") return (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n"); + if (command === "numbered") return (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n"); + if (command === "table") return "| Column | Value |\n| --- | --- |\n| | |"; + return selected; + }, + + queueRender(options = {}) { + if (options.focus) { + this._pendingFocus = true; + this._pendingFocusEnd = options.end !== false; + this._focusAttempts = 0; + } + const render = () => { + if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) { + this._pendingFocus = false; + this._focusAttempts = 0; + } else if (this._pendingFocus && this._focusAttempts < 6) { + this._focusAttempts += 1; + globalThis.setTimeout(render, 45); + } + }; + if (globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame(render); + } else { + globalThis.setTimeout(render, 0); + } + }, + + focusEditor(options = {}) { + if (!this.session || !this.isTextDocument()) return false; + if (this.sourceEditor && this.isSourceMode()) { + this.sourceEditor.focus(); + if (options.end !== false) { + const session = this.sourceEditor.session; + const row = Math.max(0, session.getLength() - 1); + const column = session.getLine(row).length; + this.sourceEditor.moveCursorTo(row, column); + } + return true; + } + const source = this._root?.querySelector?.("[data-editor-source]"); + if (!source) return false; + source.focus?.({ preventScroll: true }); + if (!editorContainsFocus(source)) return false; + if (options.end !== false) placeCaretAtEnd(source); + return true; + }, + + isMarkdown(tab = this.session) { + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); + return ext === "md"; + }, + + isTextDocument(tab = this.session) { + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); + return EDITOR_TEXT_EXTENSIONS.has(ext); + }, + + hasActiveFile(tab = this.session) { + return Boolean(tab && this.isTextDocument(tab)); + }, + + visibleTabs() { + return this.tabs.filter((tab) => this.hasActiveFile(tab)); + }, + + defaultTitle(kind, fmt) { + const date = new Date().toISOString().slice(0, 10); + if (fmt === "md") return `Markdown ${date}`; + if (fmt === "txt") return `Text ${date}`; + return `Text ${date}`; + }, + + tabTitle(tab = {}) { + tab = tab || {}; + return tab.title || tab.document?.basename || basename(tab.path); + }, + + tabLabel(tab = {}) { + tab = tab || {}; + const title = this.tabTitle(tab); + return tab.dirty ? `${title} unsaved` : title; + }, + + tabIcon(tab = {}) { + tab = tab || {}; + const ext = String(tab.extension || tab.document?.extension || "").toLowerCase(); + if (ext === "md") return "article"; + if (ext === "txt") return "description"; + return "draft"; + }, + + async runNewMenuAction(action = "") { + const normalized = String(action || "").trim().toLowerCase(); + if (normalized === "open") return await this.openFileBrowser(); + if (normalized === "markdown") return await this.create("document", "md"); + if (normalized === "text") return await this.create("document", "txt"); + return null; + }, + + installHeaderNewMenu(header = null) { + if (!header || header.querySelector(".editor-header-actions")) return () => {}; + + const root = document.createElement("div"); + root.className = "editor-header-actions surface-modal-new-action"; + root.innerHTML = ` + + + `; + + const button = root.querySelector(".editor-header-new-button"); + const menu = root.querySelector(".editor-new-menu"); + const setOpen = (open) => { + root.classList.toggle("is-open", open); + button?.setAttribute("aria-expanded", open.toString()); + if (menu) menu.hidden = !open; + }; + const onButtonClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + setOpen(!root.classList.contains("is-open")); + }; + const onMarkdownClick = (event) => { + if (!root.contains(event.target)) setOpen(false); + }; + const onMarkdownKeydown = (event) => { + if (event.key === "Escape") setOpen(false); + }; + + button?.addEventListener("click", onButtonClick); + for (const item of root.querySelectorAll("[data-editor-new-action]")) { + item.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + const action = event.currentTarget?.dataset?.editorNewAction || ""; + setOpen(false); + await this.runNewMenuAction(action); + }); + } + document.addEventListener("click", onMarkdownClick); + document.addEventListener("keydown", onMarkdownKeydown); + + placeSurfaceModalHeaderAction(header, root, "new"); + + setOpen(false); + return () => { + button?.removeEventListener("click", onButtonClick); + document.removeEventListener("click", onMarkdownClick); + document.removeEventListener("keydown", onMarkdownKeydown); + root.remove(); + }; + }, + + setupMarkdownModal(element = null) { + const root = element || document.querySelector(".editor-panel"); + const inner = root?.closest?.(".modal-inner"); + const header = inner?.querySelector?.(".modal-header"); + if (!inner || !header || inner.dataset.editorModalReady === "1") return; + inner.dataset.editorModalReady = "1"; + inner.classList.add("editor-modal"); + const floatingCleanup = setupFloatingSurfaceModalChrome({ + root, + modalClass: "editor-modal", + focusButtonClass: "editor-modal-focus-button", + minWidth: 640, + minHeight: 460, + onBoundsChange: () => this.refreshSourceEditorLayout(), + onFocusChange: () => this.refreshSourceEditorLayout(), + }); + const menuCleanup = this.installHeaderNewMenu(header); + this._headerCleanup = () => { + menuCleanup?.(); + floatingCleanup?.(); + delete inner.dataset.editorModalReady; + inner.classList.remove("editor-modal", "is-focus-mode"); + }; + }, + + async handleEditorUrlIntent(intent = {}) { + const editor = editorIntent(intent?.url || ""); + if (!editor) return false; + await openLatestSurface("editor", { + path: editor.path, + refresh: true, + source: intent?.source || "desktop-open", + }); + return true; + }, +}; + +export const store = createStore("editor", model); + +registerUrlHandler((intent) => model.handleEditorUrlIntent(intent)); diff --git a/plugins/_editor/webui/main.html b/plugins/_editor/webui/main.html new file mode 100644 index 0000000000000000000000000000000000000000..54921ed4afb06a4977ce3f8f8d769288f356fe1a --- /dev/null +++ b/plugins/_editor/webui/main.html @@ -0,0 +1,21 @@ + + + Editor + + + + + + diff --git a/plugins/_editor/webui/thumbnail.jpg b/plugins/_editor/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a6ff7e2447260ce2650aef8c303265c63f3c0006 Binary files /dev/null and b/plugins/_editor/webui/thumbnail.jpg differ diff --git a/plugins/_email_integration/AGENTS.md b/plugins/_email_integration/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..8d3d09c7ef0338645a9f3807f3cc19f171324609 --- /dev/null +++ b/plugins/_email_integration/AGENTS.md @@ -0,0 +1,30 @@ +# Email Integration Plugin DOX + +## Purpose + +- Own communicating with Agent Zero through email inboxes and SMTP replies. + +## Ownership + +- `helpers/handler.py` owns mailbox polling, state, dispatch, routing, and reply flow. +- `helpers/imap_client.py` and `helpers/smtp_client.py` own inbound and outbound mail transports. +- `helpers/dispatcher.py`, `prompts/`, and attachment helpers own routing decisions and message formatting. +- `api/`, `default_config.yaml`, `plugin.yaml`, `extensions/`, and `webui/` own tests, settings, metadata, hooks, and config UI. + +## Local Contracts + +- Treat mailbox credentials and downloaded attachments as sensitive. +- Preserve UID/state tracking so restarts do not duplicate old mail. +- Keep SMTP replies threaded and safe around user-visible errors. + +## Work Guidance + +- Coordinate polling, dispatch prompts, and WebUI settings when adding account or routing behavior. + +## Verification + +- Smoke-test connection checks, polling, attachment handling, dispatch routing, and SMTP replies when practical. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_email_integration/README.md b/plugins/_email_integration/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ad85fd1c6642f6d3b9f9fa5cfccb09d9734ffe6d --- /dev/null +++ b/plugins/_email_integration/README.md @@ -0,0 +1,54 @@ +# Email Integration + +Communicate with Agent Zero through email inboxes and send replies back over SMTP. + +## What It Does + +This plugin polls configured mailboxes, downloads incoming messages and attachments, decides whether a message should continue an existing chat or start a new one, and sends replies back by email. + +It supports both: + +- **IMAP inbox polling** +- **Exchange inbox polling** +- **SMTP replies** + +## Main Behavior + +- **Mailbox polling** + - Tracks per-handler mailbox state in `usr/email/state.json`. + - Uses UID tracking for IMAP accounts so only new mail is processed after initialization. +- **Attachment handling** + - Downloads attachments into `usr/email/attachments`. +- **Dispatcher workflow** + - Reuses or creates a background `Email Dispatcher` context. + - Uses model prompts to decide whether an email belongs to an existing chat or should open a new one. + - Supports handler-level model presets for new chats, in addition to the dispatcher's utility/chat routing mode. +- **Thread routing** + - Can continue an existing chat by thread ID found in the email subject. + - Falls back to model-based dispatch if no direct thread match is available. + - Email replies inside an existing Agent Zero thread can use `/project`, `/config`, and `/send` control commands. +- **Notifications and persistence** + - Saves chats after routing and emits notifications about new or continued conversations. + +## Key Files + +- **Core orchestration** + - `helpers/handler.py` manages polling, state persistence, dispatching, and reply flow. +- **Mail helpers** + - `helpers/imap_client.py` handles IMAP and Exchange fetching. + - `helpers/smtp_client.py` handles outbound replies. + - `helpers/dispatcher.py` formats chat summaries and parses dispatcher decisions. +- **API and extensions** + - `api/` and `extensions/` connect the polling loop and UI-facing operations into the main app. + +## Configuration Scope + +- **Settings section**: `external` +- **Per-project config**: `false` +- **Per-agent config**: `false` + +## Plugin Metadata + +- **Name**: `_email_integration` +- **Title**: `Email Integration` +- **Description**: Communicate with Agent Zero via email. Supports IMAP/Exchange inbox polling with SMTP replies. diff --git a/plugins/_email_integration/api/test_connection.py b/plugins/_email_integration/api/test_connection.py new file mode 100644 index 0000000000000000000000000000000000000000..b828537c7df0e983bfc74cc6c8da0fec7248ae3b --- /dev/null +++ b/plugins/_email_integration/api/test_connection.py @@ -0,0 +1,134 @@ +"""Test inbound and outbound email connectivity for a handler config.""" + +import asyncio + +from helpers.api import ApiHandler, Request +from helpers.errors import format_error + +from plugins._email_integration.helpers.imap_client import ( + connect_exchange, + connect_imap, + disconnect_imap, + get_highest_uid, +) +from plugins._email_integration.helpers.smtp_client import SmtpConfig, test_smtp, send_reply + + +class TestConnection(ApiHandler): + + async def process(self, input: dict, request: Request) -> dict: + handler = input.get("handler", {}) + results: list[dict] = [] + account_type = handler.get("account_type", "imap") + + if account_type == "exchange": + await self._test_exchange(handler, results) + else: + await self._test_imap(handler, results) + await self._test_smtp(handler, results) + if all(r["ok"] for r in results): + await self._test_send(handler, results) + + ok = all(r["ok"] for r in results) + return {"success": ok, "results": results} + + async def _test_imap(self, handler: dict, results: list[dict]): + try: + client = await connect_imap( + server=handler.get("imap_server", ""), + port=int(handler.get("imap_port", 993)), + username=handler.get("username", ""), + password=handler.get("password", ""), + ) + await get_highest_uid(client) + await disconnect_imap(client) + results.append({ + "test": "Incoming", + "ok": True, + "message": "Incoming mail looks good.", + }) + except Exception as e: + results.append({ + "test": "Incoming", + "ok": False, + "message": f"Could not reach the inbox: {format_error(e)}", + }) + + async def _test_exchange(self, handler: dict, results: list[dict]): + try: + account = await connect_exchange( + server=handler.get("imap_server", ""), + username=handler.get("username", ""), + password=handler.get("password", ""), + ) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: account.inbox.total_count) + results.append({ + "test": "Incoming", + "ok": True, + "message": "Exchange inbox looks good.", + }) + except Exception as e: + results.append({ + "test": "Incoming", + "ok": False, + "message": f"Could not reach the Exchange inbox: {format_error(e)}", + }) + + def _smtp_config(self, handler: dict) -> SmtpConfig: + smtp_server = handler.get("smtp_server") or handler.get("imap_server", "") + return SmtpConfig( + server=smtp_server, + port=int(handler.get("smtp_port", 587)), + username=handler.get("username", ""), + password=handler.get("password", ""), + ) + + async def _test_smtp(self, handler: dict, results: list[dict]): + try: + error = await test_smtp(self._smtp_config(handler)) + if error: + results.append({ + "test": "Outgoing", + "ok": False, + "message": f"Could not sign in for sending mail: {error}", + }) + else: + results.append({ + "test": "Outgoing", + "ok": True, + "message": "Outgoing mail looks good.", + }) + except Exception as e: + results.append({ + "test": "Outgoing", + "ok": False, + "message": f"Could not sign in for sending mail: {format_error(e)}", + }) + + async def _test_send(self, handler: dict, results: list[dict]): + try: + error = await send_reply( + config=self._smtp_config(handler), + to=handler.get("username", ""), + subject="Agent Zero - Connection Test", + body="This is a test email from Agent Zero email integration.", + ) + if error: + results.append({ + "test": "Send test email", + "ok": False, + "message": f"Could not send the test email: {error}", + }) + else: + results.append({ + "test": "Send test email", + "ok": True, + "message": "Test email sent to this inbox.", + }) + except Exception as e: + results.append({ + "test": "Send test email", + "ok": False, + "message": f"Could not send the test email: {format_error(e)}", + }) diff --git a/plugins/_email_integration/default_config.yaml b/plugins/_email_integration/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4a5a5807e342784f0611ba0a0fd8a152942273b0 --- /dev/null +++ b/plugins/_email_integration/default_config.yaml @@ -0,0 +1,22 @@ +handlers: [] +# Example handler: +# - name: support +# enabled: true +# provider: gmail +# account_type: imap +# imap_server: imap.gmail.com +# imap_port: 993 +# smtp_server: smtp.gmail.com +# smtp_port: 587 +# username: "" +# password: "" +# poll_mode: seconds +# poll_interval_seconds: 15 +# poll_interval_cron: "*/2 * * * *" +# process_unread_days: 0 +# sender_whitelist: [] +# project: "" +# dispatcher_model: utility +# chat_model_preset: "" # Optional preset from Model Configuration for new email chats +# dispatcher_instructions: "" +# agent_instructions: "" diff --git a/plugins/_email_integration/extensions/python/job_loop/_10_email_poll.py b/plugins/_email_integration/extensions/python/job_loop/_10_email_poll.py new file mode 100644 index 0000000000000000000000000000000000000000..c1b2a0c4f51a0190959bbf4a16a3ef3728c926c6 --- /dev/null +++ b/plugins/_email_integration/extensions/python/job_loop/_10_email_poll.py @@ -0,0 +1,103 @@ +"""Per-handler email poll loop with configurable seconds/cron intervals.""" + +import asyncio +from typing import Any + +from crontab import CronTab + +from helpers.extension import Extension +from helpers.localization import Localization +from helpers.errors import format_error +from helpers.print_style import PrintStyle +from helpers import plugins + + +PLUGIN_NAME: str = "_email_integration" +DEFAULT_INTERVAL: int = 15 +MIN_INTERVAL: int = 5 + + +# ------------------------------------------------------------------ +# Extension entry point +# ------------------------------------------------------------------ + +class EmailAutoPoll(Extension): + + async def execute(self, **kwargs: Any) -> None: + # _poll_tasks lives in handler.py (persists across module reloads) + from plugins._email_integration.helpers.handler import _poll_tasks + + config = plugins.get_plugin_config(PLUGIN_NAME) or {} + handlers = config.get("handlers", []) + enabled_names = { + h["name"] for h in handlers if h.get("enabled") and h.get("name") + } + + for name in list(_poll_tasks): + if name not in enabled_names or _poll_tasks[name].done(): + task = _poll_tasks.pop(name, None) + if task and not task.done(): + task.cancel() + + for name in enabled_names: + if name not in _poll_tasks or _poll_tasks[name].done(): + _poll_tasks[name] = asyncio.create_task(_handler_poll_loop(name)) + + +# ------------------------------------------------------------------ +# Per-handler poll loop +# ------------------------------------------------------------------ + +async def _handler_poll_loop(handler_name: str) -> None: + from plugins._email_integration.helpers.handler import ( + _load_state, + _poll_single_handler, + _save_state, + _state_lock, + ) + + last_unread_days = 0 + + while True: + config = plugins.get_plugin_config(PLUGIN_NAME) or {} + handlers = config.get("handlers", []) + handler_cfg = next( + (h for h in handlers if h.get("name") == handler_name and h.get("enabled")), + None, + ) + if handler_cfg is None: + break + + try: + async with _state_lock: + state = _load_state() + # Reset state when process_unread_days is enabled or changed + # so the first-run path processes recent unread via date search. + unread_days = int(handler_cfg.get("process_unread_days", 0)) + if unread_days > 0 and unread_days != last_unread_days: + state.pop(handler_name, None) + last_unread_days = unread_days + await _poll_single_handler(handler_cfg, state) + _save_state(state) + except Exception as e: + PrintStyle.error(f"Email poll error ({handler_name}): {format_error(e)}") + + sleep_sec = _get_sleep_seconds(handler_cfg) + await asyncio.sleep(sleep_sec) + + +# ------------------------------------------------------------------ +# Poll interval +# ------------------------------------------------------------------ + +def _get_sleep_seconds(handler_cfg: dict) -> float: + mode = handler_cfg.get("poll_mode", "seconds") + if mode == "cron": + expr = handler_cfg.get("poll_interval_cron", "*/2 * * * *") + try: + cron = CronTab(expr) + next_sec = cron.next(now=Localization.get().now()) # type: ignore[union-attr] + return max(next_sec, MIN_INTERVAL) + except Exception: + return DEFAULT_INTERVAL + return max(handler_cfg.get("poll_interval_seconds", DEFAULT_INTERVAL), MIN_INTERVAL) diff --git a/plugins/_email_integration/extensions/python/process_chain_end/_55_email_reply.py b/plugins/_email_integration/extensions/python/process_chain_end/_55_email_reply.py new file mode 100644 index 0000000000000000000000000000000000000000..e9b27969436780243ad3eca9cb5ecc82a466a48b --- /dev/null +++ b/plugins/_email_integration/extensions/python/process_chain_end/_55_email_reply.py @@ -0,0 +1,78 @@ +"""Auto-send email reply when agent responds in an email session.""" + +import asyncio +from helpers.extension import Extension +from helpers.print_style import PrintStyle +from agent import AgentContext, LoopData, UserMessage +from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER, CTX_EMAIL_ATTACHMENTS + +MAX_SEND_RETRIES: int = 2 +CTX_SEND_FAILURES: str = "_email_send_failures" + + +class EmailAutoReply(Extension): + + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + if not self.agent or self.agent.number != 0: + return + + context = self.agent.context + if not context.data.get(CTX_EMAIL_HANDLER): + return + + response_text = _extract_last_response(context) + if not response_text: + return + + attachments = context.data.pop(CTX_EMAIL_ATTACHMENTS, []) + if attachments: + PrintStyle.info(f"Email: sending reply with {len(attachments)} attachment(s)") + asyncio.create_task(self._send_reply(context, response_text, attachments)) + + async def _send_reply( + self, context: AgentContext, response_text: str, attachments: list[str], + ): + from plugins._email_integration.helpers.handler import send_email_reply + error = await send_email_reply(context, response_text, attachments) + if not error: + context.data[CTX_SEND_FAILURES] = 0 + return + failures = context.data.get(CTX_SEND_FAILURES, 0) + 1 + context.data[CTX_SEND_FAILURES] = failures + if failures <= MAX_SEND_RETRIES: + _notify_agent_of_failure(context, error, failures) + else: + PrintStyle.error( + f"Email send failed {failures} times, giving up: {error}" + ) + context.log.log( + type="error", + heading="Email send failed (max retries reached)", + content=error, + ) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _extract_last_response(context: AgentContext) -> str: + with context.log._lock: + logs = list(context.log.logs) + if not logs: + return "" + for item in reversed(logs): + if item.type == "response": + return item.content or "" + return "" + + +def _notify_agent_of_failure( + context: AgentContext, error: str, attempt: int, +): + msg = context.agent0.read_prompt( + "fw.email.send_failed.md", error=error, attempt=str(attempt), + max_retries=str(MAX_SEND_RETRIES), + ) + context.log.log(type="error", heading="Email send failed", content=error) + context.communicate(UserMessage(message="", system_message=[msg])) diff --git a/plugins/_email_integration/extensions/python/system_prompt/_20_email_context.py b/plugins/_email_integration/extensions/python/system_prompt/_20_email_context.py new file mode 100644 index 0000000000000000000000000000000000000000..0b4c89dd20875f52fbc54a867aa5a90108f05dad --- /dev/null +++ b/plugins/_email_integration/extensions/python/system_prompt/_20_email_context.py @@ -0,0 +1,42 @@ +"""Inject email conversation context into system prompt for email sessions.""" + +from helpers.extension import Extension +from helpers import plugins +from agent import LoopData +from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER + + +PLUGIN_NAME = "_email_integration" + + +class EmailContextPrompt(Extension): + + async def execute( + self, + system_prompt: list[str] = [], + loop_data: LoopData = LoopData(), + **kwargs, + ): + if not self.agent: + return + + handler_name = self.agent.context.data.get(CTX_EMAIL_HANDLER) + if not handler_name: + return + + system_prompt.append( + self.agent.read_prompt("fw.email.system_context_reply.md") + ) + + config = plugins.get_plugin_config(PLUGIN_NAME) or {} + for h in config.get("handlers", []): + if h.get("name") == handler_name: + instructions = h.get("agent_instructions", "") + if instructions: + system_prompt.append( + self.agent.read_prompt( + "fw.email.user_message_instructions.md", + instructions=instructions, + ) + ) + break diff --git a/plugins/_email_integration/extensions/python/tool_execute_after/_50_email_response.py b/plugins/_email_integration/extensions/python/tool_execute_after/_50_email_response.py new file mode 100644 index 0000000000000000000000000000000000000000..af16f7ae116144bb5a8727d8f8674eb504b3d1dd --- /dev/null +++ b/plugins/_email_integration/extensions/python/tool_execute_after/_50_email_response.py @@ -0,0 +1,60 @@ +"""Intercept response tool in email sessions for attachments and break_loop.""" + +from helpers.extension import Extension +from helpers.print_style import PrintStyle +from helpers.tool import Response +from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER, CTX_EMAIL_ATTACHMENTS + + +class EmailResponseIntercept(Extension): + + async def execute( + self, tool_name: str = "", response: Response | None = None, **kwargs, + ): + if tool_name != "response": + return + if not self.agent: + return + context = self.agent.context + if not context.data.get(CTX_EMAIL_HANDLER): + return + + tool = self.agent.loop_data.current_tool + if not tool: + return + + # Capture attachments for later (process_chain_end) or inline send + attachments = tool.args.get("attachments", []) + if attachments: + context.data[CTX_EMAIL_ATTACHMENTS] = attachments + + # Check break_loop arg from agent + agent_break = tool.args.get("break_loop", True) + if agent_break is False and response: + await self._send_inline(context, tool, response) + + async def _send_inline( + self, context, tool, response: Response, + ): + from plugins._email_integration.helpers.handler import send_email_reply + + agent = self.agent + assert agent is not None + + text = tool.args.get("text", tool.args.get("message", "")) + attachments = context.data.pop(CTX_EMAIL_ATTACHMENTS, []) + + if attachments: + PrintStyle.info(f"Email: sending update with {len(attachments)} attachment(s)") + + error = await send_email_reply(context, text, attachments or None) + + if error: + result = agent.read_prompt("fw.email.update_error.md", error=error) + else: + result = agent.read_prompt("fw.email.update_ok.md") + + # Override response: don't break loop, add result to history + response.break_loop = False + response.message = result + agent.hist_add_tool_result("response", result) diff --git a/plugins/_email_integration/helpers/__init__.py b/plugins/_email_integration/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/plugins/_email_integration/helpers/attachment_reader.py b/plugins/_email_integration/helpers/attachment_reader.py new file mode 100644 index 0000000000000000000000000000000000000000..4d7ca109f74860df88d8b05c891b771e5bd76bdd --- /dev/null +++ b/plugins/_email_integration/helpers/attachment_reader.py @@ -0,0 +1,40 @@ +""" +Read attachment files from execution runtime. + +No agent/tool dependencies. +""" + +import base64 +import os +from typing import TypedDict + + +# ------------------------------------------------------------------ +# Data models +# ------------------------------------------------------------------ + +class AttachmentData(TypedDict): + name: str + content_b64: str + error: str + + +# ------------------------------------------------------------------ +# File reader +# ------------------------------------------------------------------ + +def read_attachment(path: str) -> AttachmentData: + try: + if not os.path.isfile(path): + return AttachmentData( + name="", content_b64="", error=f"file not found: {path}") + name = os.path.basename(path) + with open(path, "rb") as f: + content = f.read() + return AttachmentData( + name=name, + content_b64=base64.b64encode(content).decode(), + error="", + ) + except Exception as e: + return AttachmentData(name="", content_b64="", error=str(e)) diff --git a/plugins/_email_integration/helpers/attachment_writer.py b/plugins/_email_integration/helpers/attachment_writer.py new file mode 100644 index 0000000000000000000000000000000000000000..b2625bbd425f4fd14bd0d37a6b61f741b4f8a9a0 --- /dev/null +++ b/plugins/_email_integration/helpers/attachment_writer.py @@ -0,0 +1,36 @@ +""" +Write attachment files into execution runtime. + +No agent/tool dependencies. +""" + +import base64 +import os +from typing import TypedDict + + +# ------------------------------------------------------------------ +# Data models +# ------------------------------------------------------------------ + +class WriteResult(TypedDict): + path: str + error: str + + +# ------------------------------------------------------------------ +# File writer +# ------------------------------------------------------------------ + +def write_attachment(rel_path: str, content_b64: str) -> WriteResult: + try: + from helpers import files + abs_path = files.get_abs_path(rel_path) + files.make_dirs(abs_path) + + content = base64.b64decode(content_b64) + files.write_file_bin(abs_path, content) + + return WriteResult(path=abs_path, error="") + except Exception as e: + return WriteResult(path="", error=str(e)) \ No newline at end of file diff --git a/plugins/_email_integration/helpers/dispatcher.py b/plugins/_email_integration/helpers/dispatcher.py new file mode 100644 index 0000000000000000000000000000000000000000..a6284175c6732a05957044082b73ab77c7b27464 --- /dev/null +++ b/plugins/_email_integration/helpers/dispatcher.py @@ -0,0 +1,112 @@ +""" +Email dispatch logic — routes inbound emails to chats. + +No agent deps in pure helpers. +""" + +import re +from dataclasses import dataclass +from typing import Literal, TypedDict + +# Pattern for extracting chat thread ID from email subject +# Matches: [a0-xxxxxxxx] at end of subject +_THREAD_ID_RE = re.compile(r"\[a0-([a-zA-Z0-9]+)\]") + +# Context data keys (no underscore prefix — must persist across restarts) +CTX_EMAIL_HANDLER = "email_handler" +CTX_EMAIL_SENDER = "email_sender" +CTX_EMAIL_THREAD_ID = "email_thread_id" +CTX_EMAIL_SUBJECT = "email_subject" +CTX_EMAIL_MESSAGE_ID = "email_message_id" +CTX_EMAIL_REFERENCES = "email_references" +CTX_EMAIL_LAST_BODY = "email_last_body" +# Transient — consumed per-reply, not persisted +CTX_EMAIL_ATTACHMENTS = "_email_response_attachments" + +BODY_PREVIEW_MAX_CHARS: int = 2000 + +DispatchAction = Literal["new_chat", "continue_chat"] + + +@dataclass +class DispatchDecision: + action: DispatchAction + context_id: str = "" + reason: str = "" + + +def extract_thread_id(subject: str) -> str: + match = _THREAD_ID_RE.search(subject) + return match.group(1) if match else "" + + +def build_reply_subject(original_subject: str, thread_id: str) -> str: + clean = _THREAD_ID_RE.sub("", original_subject).strip() + if not clean.lower().startswith("re:"): + clean = f"Re: {clean}" + return f"{clean} [a0-{thread_id}]" + + +class ChatSummary(TypedDict): + context_id: str + thread_id: str + sender: str + subject: str + handler: str + history_preview: str + + +def build_chat_summary(context_id: str, data: dict) -> ChatSummary: + return { + "context_id": context_id, + "thread_id": data.get(CTX_EMAIL_THREAD_ID, ""), + "sender": data.get(CTX_EMAIL_SENDER, ""), + "subject": data.get(CTX_EMAIL_SUBJECT, ""), + "handler": data.get(CTX_EMAIL_HANDLER, ""), + "history_preview": "", + } + + +# ------------------------------------------------------------------ +# Dispatcher prompt builders +# ------------------------------------------------------------------ + +def truncate_body(body: str) -> str: + if len(body) <= BODY_PREVIEW_MAX_CHARS: + return body + return body[:BODY_PREVIEW_MAX_CHARS] + "... (truncated)" + + +def format_chats_list(existing_chats: list[ChatSummary]) -> str: + if not existing_chats: + return "No existing chats for this handler." + sections = [] + for c in existing_chats[:20]: + header = ( + f"- context_id={c['context_id']}" + f" sender={c.get('sender', '')} subject={c.get('subject', '')}" + ) + preview = c.get("history_preview", "") + if preview: + header += f"\n conversation:\n {preview}" + sections.append(header) + return "\n".join(sections) + + +def parse_dispatcher_response(response: str) -> DispatchDecision: + line = response.strip().split("\n")[0].strip() + parts = line.split(None, 2) + + if len(parts) < 2: + return DispatchDecision(action="new_chat", reason="unparseable response") + + action_raw = parts[0].upper() + ctx_id = parts[1] if parts[1] != "_" else "" + reason = parts[2] if len(parts) > 2 else "" + + action_map: dict[str, DispatchAction] = { + "NEW_CHAT": "new_chat", + "CONTINUE": "continue_chat", + } + action = action_map.get(action_raw, "new_chat") + return DispatchDecision(action=action, context_id=ctx_id, reason=reason) diff --git a/plugins/_email_integration/helpers/handler.py b/plugins/_email_integration/helpers/handler.py new file mode 100644 index 0000000000000000000000000000000000000000..4d27e41a53cb980f46ca27fcb29f6c540eedf069 --- /dev/null +++ b/plugins/_email_integration/helpers/handler.py @@ -0,0 +1,622 @@ +""" +Email handler — orchestrates poll, dispatch, and reply. + +Requires agent context. +""" + +import asyncio +import base64 +import json +import os +import uuid + +from agent import Agent, AgentContext, AgentContextType, UserMessage +from helpers import guids, plugins, files, runtime +from helpers import message_queue as mq +from helpers import integration_commands +from helpers.persist_chat import save_tmp_chat +from helpers.print_style import PrintStyle +from helpers.errors import format_error +from initialize import initialize_agent + +from plugins._email_integration.helpers import dispatcher as disp +from plugins._model_config.helpers import model_config +from plugins._email_integration.helpers.imap_client import ( + InboundMessage, + connect_imap, + disconnect_imap, + fetch_new, + fetch_unread_since, + get_highest_uid, + connect_exchange, + fetch_unread_exchange, +) +from plugins._email_integration.helpers.smtp_client import SmtpConfig, send_reply + + +PLUGIN_NAME = "_email_integration" +DOWNLOAD_FOLDER = "usr/email/attachments" +STATE_FILE = "usr/email/state.json" + + +# ------------------------------------------------------------------ +# UID state persistence +# ------------------------------------------------------------------ + +_state_lock = asyncio.Lock() + +# Poll task registry — lives here (not in extension module) because +# extension modules are re-executed on each job_loop tick (cache disabled), +# which would reset module-level state and orphan running tasks. +_poll_tasks: dict[str, asyncio.Task] = {} # type: ignore[type-arg] + +def _load_state() -> dict: + path = files.get_abs_path(STATE_FILE) + if os.path.isfile(path): + try: + return json.loads(files.read_file(path)) + except Exception: + return {} + return {} + + +def _save_state(state: dict): + path = files.get_abs_path(STATE_FILE) + files.make_dirs(path) + files.write_file(path, json.dumps(state)) + + +# ------------------------------------------------------------------ +# Single handler poll (called from per-handler poll loop) +# ------------------------------------------------------------------ + +async def _poll_single_handler(handler_cfg: dict, state: dict): + name = handler_cfg.get("name", "default") + account_type = handler_cfg.get("account_type", "imap") + whitelist = handler_cfg.get("sender_whitelist") or [] + last_uid = state.get(name, {}).get("last_uid", 0) + process_unread_days = int(handler_cfg.get("process_unread_days", 0)) + + if account_type == "exchange": + messages = await _fetch_exchange(handler_cfg, whitelist, process_unread_days) + if messages: + await _dispatch_all(handler_cfg, messages) + return + + client = await connect_imap( + server=handler_cfg.get("imap_server", ""), + port=int(handler_cfg.get("imap_port", 993)), + username=handler_cfg.get("username", ""), + password=handler_cfg.get("password", ""), + ) + try: + # First run: optionally process unread from last N days + if last_uid == 0: + if process_unread_days > 0: + messages, highest = await fetch_unread_since( + client, DOWNLOAD_FOLDER, process_unread_days, whitelist or None, + ) + highest = highest or await get_highest_uid(client) + state[name] = {"last_uid": highest} + if messages: + PrintStyle.info( + f"Email ({name}): processing {len(messages)} unread" + f" from last {process_unread_days} days" + ) + await _dispatch_all(handler_cfg, messages) + else: + PrintStyle.info( + f"Email ({name}): no unread in last {process_unread_days} days" + ) + else: + highest = await get_highest_uid(client) + state[name] = {"last_uid": highest} + PrintStyle.info(f"Email ({name}): initialized, tracking from UID {highest}") + return + + messages, new_uid = await fetch_new( + client, DOWNLOAD_FOLDER, last_uid, whitelist or None, + ) + + if new_uid > last_uid: + state[name] = {"last_uid": new_uid} + + if messages: + PrintStyle.info(f"Email ({name}): {len(messages)} new messages") + await _dispatch_all(handler_cfg, messages) + + finally: + await disconnect_imap(client) + + +async def _fetch_exchange( + cfg: dict, whitelist: list[str], since_days: int = 0, +) -> list[InboundMessage]: + account = await connect_exchange( + server=cfg.get("imap_server", ""), + username=cfg.get("username", ""), + password=cfg.get("password", ""), + ) + return await fetch_unread_exchange( + account, DOWNLOAD_FOLDER, whitelist or None, since_days=since_days, + ) + + +async def _dispatch_all(handler_cfg: dict, messages: list[InboundMessage]): + own_address = (handler_cfg.get("username") or "").lower() + + # Need an agent for dispatcher AI calls + # find existing dispatcher or create new background context + ctx = None + for c in AgentContext._contexts.values(): + if isinstance(c, AgentContext) and c.name == "Email Dispatcher": + ctx = c + break + + if not ctx: + agent_config = initialize_agent() + ctx = AgentContext(agent_config, name="Email Dispatcher", + type=AgentContextType.BACKGROUND) + agent = ctx.agent0 + + for msg in messages: + if own_address and _is_own_email(msg.sender, own_address): + PrintStyle.info(f"Email: skipping self-sent from {msg.sender}") + continue + try: + await _dispatch_message(agent, handler_cfg, msg) + except Exception as e: + PrintStyle.error(f"Email dispatch error: {format_error(e)}") + + +# ------------------------------------------------------------------ +# Dispatch a single inbound message +# ------------------------------------------------------------------ + +async def _dispatch_message(agent: Agent, handler_cfg: dict, msg: InboundMessage): + handler_name = handler_cfg.get("name", "default") + thread_id = disp.extract_thread_id(msg.subject) + + existing = _find_handler_chats(handler_name, msg.sender) + + if await _handle_control_email(handler_cfg, msg, existing, thread_id): + return + + # Fast path: thread ID in subject matches a known chat + if thread_id: + for chat in existing: + if chat["thread_id"] == thread_id: + await _route_to_chat( + agent, handler_cfg, msg, chat["context_id"], + ) + return + + # Dispatcher AI decides + decision = await _call_dispatcher(agent, handler_cfg, msg, existing) + reason = decision.reason or "" + + if decision.action == "continue_chat" and decision.context_id: + ctx = AgentContext.get(decision.context_id) + if ctx: + await _route_to_chat(agent, handler_cfg, msg, decision.context_id) + return + PrintStyle.warning( + f"Dispatcher referenced unknown context {decision.context_id}, starting new chat" + ) + + await _start_new_chat(agent, handler_cfg, msg) + + +async def _call_model( + agent: Agent, handler_cfg: dict, system: str, prompt: str, +): + if handler_cfg.get("dispatcher_model", "utility") == "chat": + from langchain_core.messages import SystemMessage, HumanMessage + messages = [SystemMessage(content=system), HumanMessage(content=prompt)] + response, _ = await agent.call_chat_model(messages) + return response + return await agent.call_utility_model(system=system, message=prompt) + + +async def _call_dispatcher( + agent: Agent, + handler_cfg: dict, + msg: InboundMessage, + existing_chats: list[disp.ChatSummary], +) -> disp.DispatchDecision: + body_preview = disp.truncate_body(msg.body) + chats_text = disp.format_chats_list(existing_chats) + + prompt = agent.read_prompt( + "fw.email.dispatcher_prompt.md", + sender=msg.sender, + subject=msg.subject, + body=body_preview, + chats=chats_text, + ) + + extra = handler_cfg.get("dispatcher_instructions", "") + if extra: + prompt += agent.read_prompt( + "fw.email.dispatcher_extra.md", instructions=extra, + ) + + system = agent.read_prompt("fw.email.dispatcher_system.md") + + try: + response = await _call_model(agent, handler_cfg, system, prompt) + return disp.parse_dispatcher_response(str(response)) + + except Exception as e: + PrintStyle.error(f"Dispatcher error: {format_error(e)}") + return disp.DispatchDecision(action="new_chat", reason="dispatcher error") + + +# ------------------------------------------------------------------ +# Chat creation and routing +# ------------------------------------------------------------------ + +async def _start_new_chat(agent: Agent, handler_cfg: dict, msg: InboundMessage): + from helpers import projects + + handler_name = handler_cfg.get("name", "default") + thread_id = guids.generate_id() + + config = initialize_agent() + context = AgentContext(config, name=f"Email: {msg.subject[:50]}") + + context.data[disp.CTX_EMAIL_HANDLER] = handler_name + context.data[disp.CTX_EMAIL_SENDER] = msg.sender + context.data[disp.CTX_EMAIL_THREAD_ID] = thread_id + context.data[disp.CTX_EMAIL_SUBJECT] = msg.subject + context.data[disp.CTX_EMAIL_LAST_BODY] = msg.body + context.data[disp.CTX_EMAIL_MESSAGE_ID] = msg.message_id + + refs_list = [] + if msg.references: + for r in msg.references.split(): + if r not in refs_list: + refs_list.append(r) + if msg.message_id and msg.message_id not in refs_list: + refs_list.append(msg.message_id) + + context.data[disp.CTX_EMAIL_REFERENCES] = " ".join(refs_list) + + project = handler_cfg.get("project", "") + if project: + projects.activate_project(context.id, project) + + _apply_handler_model_preset(context, handler_cfg) + save_tmp_chat(context) + + user_msg = _build_user_message(agent, msg, handler_cfg) + system_ctx = agent.read_prompt("fw.email.system_context.md") + + msg_id = str(uuid.uuid4()) + mq.log_user_message(context, user_msg, msg.attachments or [], message_id=msg_id, source=" (email)") + context.communicate(UserMessage( + message=user_msg, + system_message=[system_ctx], + attachments=msg.attachments, + id=msg_id, + )) + + PrintStyle.success(f"Email: new chat {context.id} for '{msg.subject}' from {msg.sender}") + + +async def _route_to_chat( + agent: Agent, + handler_cfg: dict, + msg: InboundMessage, + context_id: str, +): + context = AgentContext.get(context_id) + if not context: + return + + context.data[disp.CTX_EMAIL_MESSAGE_ID] = msg.message_id + context.data[disp.CTX_EMAIL_LAST_BODY] = msg.body + if not context.get_data("chat_model_override"): + _apply_handler_model_preset(context, handler_cfg) + + refs = context.data.get(disp.CTX_EMAIL_REFERENCES, "") + refs_list = refs.split() if refs else [] + + if msg.references: + for r in msg.references.split(): + if r not in refs_list: + refs_list.append(r) + + if msg.message_id and msg.message_id not in refs_list: + refs_list.append(msg.message_id) + + context.data[disp.CTX_EMAIL_REFERENCES] = " ".join(refs_list) + + user_msg = _build_user_message(agent, msg, handler_cfg) + msg_id = str(uuid.uuid4()) + mq.log_user_message(context, user_msg, msg.attachments or [], message_id=msg_id, source=" (email)") + context.communicate(UserMessage( + message=user_msg, + attachments=msg.attachments, + id=msg_id, + )) + + save_tmp_chat(context) + PrintStyle.info(f"Email: continuing chat {context_id}") + + +async def _handle_control_email( + handler_cfg: dict, + msg: InboundMessage, + existing_chats: list[disp.ChatSummary], + thread_id: str, +) -> bool: + parsed = integration_commands.parse_command(msg.body) + if not parsed: + return False + + target_context_id = "" + if thread_id: + for chat in existing_chats: + if chat["thread_id"] == thread_id: + target_context_id = chat["context_id"] + break + + if not target_context_id: + if len(existing_chats) == 1: + target_context_id = existing_chats[0]["context_id"] + elif len(existing_chats) > 1: + await _send_control_email_reply( + handler_cfg, + msg, + "Multiple Agent Zero email chats match this sender. Reply inside the thread you want to control.", + thread_id=thread_id, + ) + return True + else: + await _send_control_email_reply( + handler_cfg, + msg, + "No matching Agent Zero email chat was found. Reply inside an existing Agent Zero email thread to use /project, /config, or /send.", + thread_id=thread_id, + ) + return True + + context = AgentContext.get(target_context_id) + if not context: + await _send_control_email_reply( + handler_cfg, + msg, + "The matching Agent Zero email chat is no longer available. Send a normal email to start a fresh thread.", + thread_id=thread_id, + ) + return True + + response = integration_commands.try_handle_command(context, msg.body) + if response is None: + return False + + await _send_control_email_reply( + handler_cfg, + msg, + response, + thread_id=context.data.get(disp.CTX_EMAIL_THREAD_ID, "") or thread_id, + ) + return True + + +def _apply_handler_model_preset(context: AgentContext, handler_cfg: dict) -> None: + preset_name = str(handler_cfg.get("chat_model_preset", "") or "").strip() + if not preset_name: + return + if not model_config.is_chat_override_allowed(context.agent0): + PrintStyle.warning( + f"Email ({handler_cfg.get('name', 'default')}): chat override is disabled," + f" cannot apply preset '{preset_name}'" + ) + return + if not model_config.get_preset_by_name(preset_name): + PrintStyle.warning( + f"Email ({handler_cfg.get('name', 'default')}): preset '{preset_name}' was not found" + ) + return + context.set_data("chat_model_override", {"preset_name": preset_name}) + + +async def _send_control_email_reply( + handler_cfg: dict, + msg: InboundMessage, + body: str, + *, + thread_id: str = "", +) -> str | None: + smtp_cfg = SmtpConfig( + server=handler_cfg.get("smtp_server", handler_cfg.get("imap_server", "")), + port=int(handler_cfg.get("smtp_port", 587)), + username=handler_cfg.get("username", ""), + password=handler_cfg.get("password", ""), + ) + + subject = _build_control_reply_subject(msg.subject, thread_id) + references = _merge_references(msg.references, msg.message_id) + + return await send_reply( + config=smtp_cfg, + to=msg.sender, + subject=subject, + body=body, + in_reply_to=msg.message_id, + references=references, + attachments=None, + ) + + +def _build_control_reply_subject(subject: str, thread_id: str) -> str: + if thread_id: + return disp.build_reply_subject(subject, thread_id) + cleaned = subject.strip() + if not cleaned.lower().startswith("re:"): + cleaned = f"Re: {cleaned}" + return cleaned + + +def _merge_references(existing: str, message_id: str) -> str: + refs = [] + for ref in (existing or "").split(): + if ref and ref not in refs: + refs.append(ref) + if message_id and message_id not in refs: + refs.append(message_id) + return " ".join(refs) + + +# ------------------------------------------------------------------ +# Chat discovery +# ------------------------------------------------------------------ + +HISTORY_PREVIEW_MAX_CHARS: int = 500 + + +def _find_handler_chats(handler_name: str, sender: str) -> list[disp.ChatSummary]: + results = [] + for ctx_id, ctx in AgentContext._contexts.items(): + if not isinstance(ctx, AgentContext): + continue + data = ctx.data + if data.get(disp.CTX_EMAIL_HANDLER) != handler_name: + continue + if data.get(disp.CTX_EMAIL_SENDER, "").lower() != sender.lower(): + continue + summary = disp.build_chat_summary(ctx_id, data) + summary["history_preview"] = _get_history_preview(ctx) + results.append(summary) + + results.sort(key=lambda c: c["context_id"], reverse=True) + return results[:20] + + +def _get_history_preview(ctx: AgentContext) -> str: + try: + history = ctx.agent0.history + text = history.output_text(human_label="user", ai_label="agent") + if not text: + return "(empty)" + if len(text) > HISTORY_PREVIEW_MAX_CHARS: + return "..." + text[-HISTORY_PREVIEW_MAX_CHARS:] + return text + except Exception: + return "(unavailable)" + + +# ------------------------------------------------------------------ +# Sender helpers +# ------------------------------------------------------------------ + +def _is_own_email(sender: str, own_address: str) -> bool: + sender_lower = sender.lower() + if "<" in sender_lower: + start = sender_lower.index("<") + 1 + end = sender_lower.index(">", start) + return sender_lower[start:end].strip() == own_address + return sender_lower.strip() == own_address + + +# ------------------------------------------------------------------ +# Message builders +# ------------------------------------------------------------------ + +def _build_user_message(agent: Agent, msg: InboundMessage, handler_cfg: dict) -> str: + recipient = handler_cfg.get("username", "") + return agent.read_prompt( + "fw.email.user_message.md", + sender=msg.sender, + recipient=recipient, + subject=msg.subject, + body=msg.body, + ) + + +# ------------------------------------------------------------------ +# Reply sending (called from process_chain_end extension) +# ------------------------------------------------------------------ + +async def send_email_reply( + context: AgentContext, + response_text: str, + attachments: list[str] | None = None, +) -> str | None: + handler_name = context.data.get(disp.CTX_EMAIL_HANDLER) + if not handler_name: + return "No email handler configured" + + cfg = _get_handler_config(handler_name) + if not cfg: + return f"Handler config not found for '{handler_name}'" + + sender = context.data.get(disp.CTX_EMAIL_SENDER, "") + original_subject = context.data.get(disp.CTX_EMAIL_SUBJECT, "") + thread_id = context.data.get(disp.CTX_EMAIL_THREAD_ID, "") + original_msg_id = context.data.get(disp.CTX_EMAIL_MESSAGE_ID, "") + references = context.data.get(disp.CTX_EMAIL_REFERENCES, "") + + subject = disp.build_reply_subject(original_subject, thread_id) + + smtp_cfg = SmtpConfig( + server=cfg.get("smtp_server", cfg.get("imap_server", "")), + port=int(cfg.get("smtp_port", 587)), + username=cfg.get("username", ""), + password=cfg.get("password", ""), + ) + + # Read attachment files via RFC (they live in the execution runtime) + attachment_data = await _read_attachments_via_rfc(attachments) + + last_body = context.data.get(disp.CTX_EMAIL_LAST_BODY, "").strip() + if last_body: + quoted = "\n> " + "\n> ".join(last_body.splitlines()) + response_text = f"{response_text}\n\nOn previous message:\n{quoted}" + + return await send_reply( + config=smtp_cfg, + to=sender, + subject=subject, + body=response_text, + in_reply_to=original_msg_id, + references=references, + attachments=attachment_data or None, + ) + + +# ------------------------------------------------------------------ +# Attachment reading (via RFC into execution runtime) +# ------------------------------------------------------------------ + +async def _read_attachments_via_rfc( + paths: list[str] | None, +) -> list[tuple[str, bytes]]: + if not paths: + return [] + + from plugins._email_integration.helpers.attachment_reader import read_attachment + + results: list[tuple[str, bytes]] = [] + for path in paths: + data = await runtime.call_development_function(read_attachment, path) + if data["error"]: + PrintStyle.error(f"Email attachment: {data['error']}") + continue + results.append((data["name"], base64.b64decode(data["content_b64"]))) + return results + + +# ------------------------------------------------------------------ +# Config lookup +# ------------------------------------------------------------------ + +def _get_handler_config(handler_name: str) -> dict | None: + config = plugins.get_plugin_config(PLUGIN_NAME) or {} + handlers = config.get("handlers", []) + for h in handlers: + if h.get("name") == handler_name: + return h + return None diff --git a/plugins/_email_integration/helpers/imap_client.py b/plugins/_email_integration/helpers/imap_client.py new file mode 100644 index 0000000000000000000000000000000000000000..f88456d7ab72f6bc8bb56b761cade078d0aed1ec --- /dev/null +++ b/plugins/_email_integration/helpers/imap_client.py @@ -0,0 +1,502 @@ +""" +IMAP/Exchange email reader. + +No agent/tool dependencies. +""" + +import asyncio +import email +import os +import re +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from email.header import decode_header +from email.message import Message as EmailMessage +from fnmatch import fnmatch + +import html2text +from bs4 import BeautifulSoup +from imapclient import IMAPClient + +from helpers import files +from helpers.errors import format_error +from helpers.print_style import PrintStyle + + +# ------------------------------------------------------------------ +# Data models +# ------------------------------------------------------------------ + +@dataclass +class InboundMessage: + sender: str + subject: str + body: str + attachments: list[str] = field(default_factory=list) + message_id: str = "" + in_reply_to: str = "" + references: str = "" + + +# ------------------------------------------------------------------ +# IMAP connection +# ------------------------------------------------------------------ + +async def connect_imap( + server: str, + port: int = 993, + username: str = "", + password: str = "", + ssl: bool = True, + timeout: int = 30, +) -> IMAPClient: + loop = asyncio.get_event_loop() + + def _sync(): + client = IMAPClient(server, port=port, ssl=ssl, timeout=timeout) + client._imap._maxline = 100000 # type: ignore[attr-defined] + client.login(username, password) + return client + + return await loop.run_in_executor(None, _sync) + + +async def disconnect_imap(client: IMAPClient) -> None: + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, client.logout) + except Exception as e: + PrintStyle.error(f"IMAP disconnect error: {format_error(e)}") + + +# ------------------------------------------------------------------ +# Fetch messages +# ------------------------------------------------------------------ + +async def fetch_new( + client: IMAPClient, + download_folder: str, + last_uid: int = 0, + sender_whitelist: list[str] | None = None, + max_messages: int = 10, +) -> tuple[list[InboundMessage], int]: + """Fetch emails newer than last_uid. Returns (messages, new_last_uid).""" + loop = asyncio.get_event_loop() + + def _search(): + client.select_folder("INBOX") + # Use Gmail category filter if supported, otherwise plain UNSEEN + try: + return client.gmail_search("category:primary is:unread") + except Exception: + return client.search(["UNSEEN"]) # type: ignore[arg-type] + + msg_ids = await loop.run_in_executor(None, _search) + if not msg_ids: + return [], last_uid + + # Filter out already-seen UIDs + if last_uid > 0: + msg_ids = [uid for uid in msg_ids if uid > last_uid] + + if not msg_ids: + return [], last_uid + + new_last_uid = max(msg_ids) + + # Cap to most recent + if len(msg_ids) > max_messages: + PrintStyle.standard( + f"Email: {len(msg_ids)} new, processing latest {max_messages}" + ) + msg_ids = msg_ids[-max_messages:] + else: + PrintStyle.standard(f"Email: found {len(msg_ids)} new messages") + + results: list[InboundMessage] = [] + + for msg_id in msg_ids: + try: + msg = await _fetch_single(client, msg_id, download_folder, sender_whitelist) + if msg: + results.append(msg) + except Exception as e: + PrintStyle.error(f"Email: error processing message {msg_id}: {format_error(e)}") + return results, new_last_uid + + +async def get_highest_uid(client: IMAPClient) -> int: + """Get the highest UID in inbox without fetching any messages.""" + loop = asyncio.get_event_loop() + + def _search(): + client.select_folder("INBOX") + uids = client.search(["ALL"]) # type: ignore[arg-type] + return max(uids) if uids else 0 + + return await loop.run_in_executor(None, _search) + + +async def fetch_unread_since( + client: IMAPClient, + download_folder: str, + days: int, + sender_whitelist: list[str] | None = None, + max_messages: int = 10, +) -> tuple[list[InboundMessage], int]: + """Fetch unread emails from the last N days. Returns (messages, highest_uid).""" + loop = asyncio.get_event_loop() + since_date = datetime.now() - timedelta(days=days) + + def _search(): + client.select_folder("INBOX") + try: + return client.gmail_search( + f"category:primary is:unread after:{since_date.strftime('%Y/%m/%d')}" + ) + except Exception: + return client.search(["UNSEEN", "SINCE", since_date.date()]) # type: ignore[arg-type] + + msg_ids = await loop.run_in_executor(None, _search) + if not msg_ids: + return [], 0 + + highest_uid = max(msg_ids) + + if len(msg_ids) > max_messages: + PrintStyle.standard( + f"Email: {len(msg_ids)} unread, processing latest {max_messages}" + ) + msg_ids = msg_ids[-max_messages:] + else: + PrintStyle.standard( + f"Email: found {len(msg_ids)} unread messages from last {days} days" + ) + + results: list[InboundMessage] = [] + for msg_id in msg_ids: + try: + msg = await _fetch_single(client, msg_id, download_folder, sender_whitelist) + if msg: + results.append(msg) + except Exception as e: + PrintStyle.error( + f"Email: error processing message {msg_id}: {format_error(e)}" + ) + + return results, highest_uid + + +async def _fetch_single( + client: IMAPClient, + msg_id: int, + download_folder: str, + sender_whitelist: list[str] | None, +) -> InboundMessage | None: + loop = asyncio.get_event_loop() + + def _sync_fetch(): + data = client.fetch([msg_id], ["RFC822"])[msg_id] + # Explicitly mark as read — RFC822 fetch doesn't always set \Seen on all servers + client.add_flags([msg_id], [b"\\Seen"]) + return data + + raw = await loop.run_in_executor(None, _sync_fetch) + email_data = raw.get(b"RFC822") + if not email_data: + return None + + email_msg = email.message_from_bytes(email_data) # type: ignore[arg-type] + + sender = _decode_header(email_msg.get("From", "")) + if _is_noreply(sender): + return None + if sender_whitelist and not _matches_whitelist(sender, sender_whitelist): + return None + + subject = _decode_header(email_msg.get("Subject", "")) + message_id = email_msg.get("Message-ID", "") + in_reply_to = email_msg.get("In-Reply-To", "") + references = email_msg.get("References", "") + + body, attachments = await _parse_body(email_msg, download_folder) + + return InboundMessage( + sender=sender, + subject=subject, + body=body, + attachments=attachments, + message_id=message_id, + in_reply_to=in_reply_to, + references=references, + ) + + +# ------------------------------------------------------------------ +# Exchange connection +# ------------------------------------------------------------------ + +async def connect_exchange( + server: str, + username: str, + password: str, +): + from exchangelib import Account, Configuration, Credentials, DELEGATE + + loop = asyncio.get_event_loop() + + def _sync(): + creds = Credentials(username=username, password=password) + config = Configuration(server=server, credentials=creds) + return Account( + primary_smtp_address=username, + config=config, + autodiscover=False, + access_type=DELEGATE, + ) + + return await loop.run_in_executor(None, _sync) + + +async def fetch_unread_exchange( + account, + download_folder: str, + sender_whitelist: list[str] | None = None, + since_days: int = 0, +) -> list[InboundMessage]: + from exchangelib import Q + + loop = asyncio.get_event_loop() + + def _sync(): + q = Q(is_read=False) + if since_days > 0: + since = datetime.now(tz=account.default_timezone) - timedelta(days=since_days) + q &= Q(datetime_received__gte=since) + return list(account.inbox.filter(q)) + + items = await loop.run_in_executor(None, _sync) + results: list[InboundMessage] = [] + + for item in items: + sender = str(item.sender.email_address) if item.sender else "" + if _is_noreply(sender): + continue + if sender_whitelist and not _matches_whitelist(sender, sender_whitelist): + continue + + body = str(item.text_body or item.body or "") + if item.body and str(item.body).strip().startswith("<"): + body = _html_to_text(str(item.body)) + + attachment_paths: list[str] = [] + if item.attachments: + for att in item.attachments: + if hasattr(att, "content") and att.name: + path = await _save_attachment(att.name, att.content, download_folder) + attachment_paths.append(path) + + results.append(InboundMessage( + sender=sender, + subject=str(item.subject or ""), + body=body, + attachments=attachment_paths, + message_id=str(getattr(item, "message_id", "") or ""), + in_reply_to=str(getattr(item, "in_reply_to", "") or ""), + references="", + )) + + return results + + +# ------------------------------------------------------------------ +# Parsing helpers +# ------------------------------------------------------------------ + +async def _parse_body( + email_msg: EmailMessage, + download_folder: str, +) -> tuple[str, list[str]]: + body = "" + attachments: list[str] = [] + cid_map: dict[str, str] = {} + body_parts: list[str] = [] + + if email_msg.is_multipart(): + for part in email_msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get("Content-Disposition", "")) + + if part.get_content_maintype() == "multipart": + continue + + if "attachment" in disposition or part.get("Content-ID"): + filename = part.get_filename() + if filename: + filename = _decode_header(filename) + content = part.get_payload(decode=True) + if isinstance(content, bytes): + path = await _save_attachment(filename, content, download_folder) + attachments.append(path) + cid = part.get("Content-ID") + if cid: + cid_map[cid.strip("<>")] = path + + if not cid: + body_parts.append(f"\n[attachment://{path}]\n") + + elif content_type == "text/plain": + if not body: + charset = part.get_content_charset() or "utf-8" + payload = part.get_payload(decode=True) + body = payload.decode(charset, errors="ignore") if isinstance(payload, bytes) else "" + body_parts.append(body) + + elif content_type == "text/html": + if not body: + charset = part.get_content_charset() or "utf-8" + payload = part.get_payload(decode=True) + html = payload.decode(charset, errors="ignore") if isinstance(payload, bytes) else "" + body = _html_to_text(html, cid_map) + body_parts.append(body) + + if len(body_parts) > 1: + body = "".join(body_parts) + else: + content_type = email_msg.get_content_type() + charset = email_msg.get_content_charset() or "utf-8" + content = email_msg.get_payload(decode=True) + if isinstance(content, bytes): + if content_type == "text/html": + body = _html_to_text(content.decode(charset, errors="ignore")) + else: + body = content.decode(charset, errors="ignore") + + body = _strip_quoted_reply(body) + return body, attachments + + +def _strip_quoted_reply(text: str) -> str: + """Remove quoted reply chains (e.g. 'On ... wrote:' + '>' lines).""" + if not text: + return text + lines = text.splitlines() + cut = len(lines) + for i, line in enumerate(lines): + # Match "On wrote:" pattern + if re.match(r"^On .+ wrote:\s*$", line.strip()): + # Verify next non-empty lines are quoted + rest = [l for l in lines[i + 1:] if l.strip()] + if not rest or rest[0].strip().startswith(">"): + cut = i + break + # Also strip trailing blank lines before the cut + while cut > 0 and not lines[cut - 1].strip(): + cut -= 1 + return "\n".join(lines[:cut]).strip() + + +def _html_to_text(html_content: str, cid_map: dict[str, str] | None = None) -> str: + if cid_map: + soup = BeautifulSoup(html_content, "html.parser") + for img in soup.find_all("img"): + src = str(img.get("src", "")) # type: ignore + if src.startswith("cid:"): + cid = src[4:] + if cid in cid_map: + img.replace_with(soup.new_string(f"[attachment://{cid_map[cid]}]")) + html_content = str(soup) + + h = html2text.HTML2Text() + h.ignore_links = False + h.ignore_images = False + h.ignore_emphasis = False + h.body_width = 0 + text = h.handle(html_content) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + return text + + +async def _save_attachment(filename: str, content: bytes, download_folder: str) -> str: + filename = files.safe_file_name(filename) + name, ext = os.path.splitext(filename) + unique = f"{name}_{uuid.uuid4().hex[:8]}{ext}" + rel_path = os.path.join(download_folder, unique) + from helpers import runtime + from plugins._email_integration.helpers.attachment_writer import write_attachment + + import base64 + content_b64 = base64.b64encode(content).decode() + + result = await runtime.call_development_function( + write_attachment, rel_path, content_b64 + ) + if result.get("error"): + from helpers.print_style import PrintStyle + PrintStyle.error(f"Failed to save attachment {filename}: {result['error']}") + + return result.get("path", files.get_abs_path(rel_path)) + + +def _decode_header(header: str) -> str: + if not header: + return "" + parts = [] + for part, encoding in decode_header(header): + if isinstance(part, bytes): + parts.append(part.decode(encoding or "utf-8", errors="ignore")) + else: + parts.append(str(part)) + return " ".join(parts) + + +def _is_noreply(sender: str) -> bool: + addr = sender.lower() + match = re.search(r"<([^>]+)>", addr) + if match: + addr = match.group(1) + local = addr.split("@")[0] if "@" in addr else addr + return local in ( + "noreply", "no-reply", "no_reply", + "donotreply", "do-not-reply", "do_not_reply", + "mailer-daemon", "postmaster", + ) + + +def _matches_whitelist(sender: str, whitelist: list[str]) -> bool: + sender_email = _extract_email_from_sender(sender.lower()) + for pattern in whitelist: + if fnmatch(sender_email, pattern.lower()): + return True + return False + + +def _extract_email_from_sender(sender: str) -> str: + """Extract email address from sender string. + + Handles formats like: + - "email@example.com" + - "Name " + - "\"Display Name\" " + + Uses content inside angle brackets as authoritative to prevent spoofing + by fake emails in the display name (e.g., "John ceo@company.com "). + """ + import re + # Look for email inside angle brackets - this is the authoritative source + match = re.search(r"<([^>]+)>", sender) + if match: + email = match.group(1).strip() + # Validate it looks like an email + if re.match(r"^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$", email): + return email + + # No angle brackets - extract email from the whole string + # This handles plain "email@example.com" or malformed input + email_match = re.search(r"[^\s<>]+@[^\s<>]+\.[^\s<>]+", sender) + if email_match: + return email_match.group(0) + + # Fallback: return the whole string (will likely fail pattern match) + return sender diff --git a/plugins/_email_integration/helpers/smtp_client.py b/plugins/_email_integration/helpers/smtp_client.py new file mode 100644 index 0000000000000000000000000000000000000000..7058f88d8536ebd8b287f18489124143468d60c2 --- /dev/null +++ b/plugins/_email_integration/helpers/smtp_client.py @@ -0,0 +1,144 @@ +""" +SMTP email sender. + +No agent/tool dependencies. +""" + +import asyncio +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +from dataclasses import dataclass + +from helpers.errors import format_error +from helpers.print_style import PrintStyle + + +# ------------------------------------------------------------------ +# Data models +# ------------------------------------------------------------------ + +SMTP_TIMEOUT: int = 30 + +@dataclass +class SmtpConfig: + server: str + port: int = 587 + username: str = "" + password: str = "" + use_tls: bool = True + + +# ------------------------------------------------------------------ +# Send reply +# ------------------------------------------------------------------ + +async def send_reply( + config: SmtpConfig, + to: str, + subject: str, + body: str, + in_reply_to: str = "", + references: str = "", + attachments: list[tuple[str, bytes]] | None = None, +) -> str | None: + loop = asyncio.get_event_loop() + + def _sync_send(): + import markdown + + html_body = markdown.markdown(body, extensions=['extra', 'nl2br']) + html_content = f""" + + + + + + {html_body} + + + """ + + if attachments: + msg = MIMEMultipart("mixed") + + alt_part = MIMEMultipart("alternative") + alt_part.attach(MIMEText(body, "plain", "utf-8")) + alt_part.attach(MIMEText(html_content, "html", "utf-8")) + msg.attach(alt_part) + + for filename, content in attachments: + part = MIMEBase("application", "octet-stream") + part.set_payload(content) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f"attachment; filename={filename}", + ) + msg.attach(part) + else: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(body, "plain", "utf-8")) + msg.attach(MIMEText(html_content, "html", "utf-8")) + + msg["From"] = config.username + msg["To"] = to + msg["Subject"] = subject + + if in_reply_to: + msg["In-Reply-To"] = in_reply_to + msg["References"] = references or in_reply_to + + if config.use_tls: + with smtplib.SMTP(config.server, config.port, timeout=SMTP_TIMEOUT) as server: + server.ehlo() + server.starttls() + server.ehlo() + server.login(config.username, config.password) + server.send_message(msg) + else: + with smtplib.SMTP_SSL(config.server, config.port, timeout=SMTP_TIMEOUT) as server: + server.login(config.username, config.password) + server.send_message(msg) + + try: + await loop.run_in_executor(None, _sync_send) + PrintStyle.success(f"Email sent to {to}: {subject}") + return None + except Exception as e: + error = format_error(e) + PrintStyle.error(f"Email send failed: {error}") + return error + + +# ------------------------------------------------------------------ +# Connection test (auth only, no email sent) +# ------------------------------------------------------------------ + +async def test_smtp(config: SmtpConfig) -> str | None: + loop = asyncio.get_event_loop() + + def _sync_test(): + if config.use_tls: + with smtplib.SMTP(config.server, config.port, timeout=SMTP_TIMEOUT) as server: + server.ehlo() + server.starttls() + server.ehlo() + server.login(config.username, config.password) + else: + with smtplib.SMTP_SSL(config.server, config.port, timeout=SMTP_TIMEOUT) as server: + server.login(config.username, config.password) + + try: + await loop.run_in_executor(None, _sync_test) + return None + except Exception as e: + return format_error(e) diff --git a/plugins/_email_integration/plugin.yaml b/plugins/_email_integration/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..642647e4d7f636f3ba029b613a449f407fb2e19c --- /dev/null +++ b/plugins/_email_integration/plugin.yaml @@ -0,0 +1,8 @@ +name: _email_integration +title: Email Integration +description: Communicate with Agent Zero via email. Supports IMAP/Exchange inbox polling with SMTP replies. +version: 1.0.0 +settings_sections: + - external +per_project_config: false +per_agent_config: false diff --git a/plugins/_email_integration/prompts/fw.email.dispatcher_extra.md b/plugins/_email_integration/prompts/fw.email.dispatcher_extra.md new file mode 100644 index 0000000000000000000000000000000000000000..f3b12512e6763ebcf7d8cf3787d306ceabab9596 --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.dispatcher_extra.md @@ -0,0 +1,3 @@ + +## Additional Instructions +{{instructions}} diff --git a/plugins/_email_integration/prompts/fw.email.dispatcher_prompt.md b/plugins/_email_integration/prompts/fw.email.dispatcher_prompt.md new file mode 100644 index 0000000000000000000000000000000000000000..d8cc08972c6e4ab795555a12d9c97bf9a30f6ae4 --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.dispatcher_prompt.md @@ -0,0 +1,8 @@ +## New Email +From: {{sender}} +Subject: {{subject}} + +{{body}} + +## Existing Chats +{{chats}} diff --git a/plugins/_email_integration/prompts/fw.email.dispatcher_system.md b/plugins/_email_integration/prompts/fw.email.dispatcher_system.md new file mode 100644 index 0000000000000000000000000000000000000000..ca3a6f1af903902a62c6f3d3e6c96ab5aa421bc1 --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.dispatcher_system.md @@ -0,0 +1,10 @@ +route inbound emails to agent chats +input new email sender subject body and existing chats with history +relates to existing chat same topic same sender reply CONTINUE context_id +else reply NEW_CHAT +history determines topic relevance +respond exactly one line ACTION context_id reason + +examples +NEW_CHAT _ new request from user +CONTINUE ctx_abc123 same topic about deployment diff --git a/plugins/_email_integration/prompts/fw.email.poll_error.md b/plugins/_email_integration/prompts/fw.email.poll_error.md new file mode 100644 index 0000000000000000000000000000000000000000..4f4ab59d9cec3fd3b65b210d071b47e33d26d77d --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.poll_error.md @@ -0,0 +1 @@ +email poll error: {{error}} diff --git a/plugins/_email_integration/prompts/fw.email.poll_ok.md b/plugins/_email_integration/prompts/fw.email.poll_ok.md new file mode 100644 index 0000000000000000000000000000000000000000..410c45a19be08ab7b03c83a71855e4cbc659587a --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.poll_ok.md @@ -0,0 +1 @@ +email poll complete: {{result}} diff --git a/plugins/_email_integration/prompts/fw.email.send_failed.md b/plugins/_email_integration/prompts/fw.email.send_failed.md new file mode 100644 index 0000000000000000000000000000000000000000..d9f69f725705602b16cb189184fe486b198608d1 --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.send_failed.md @@ -0,0 +1,4 @@ +email send failed (attempt {{attempt}}/{{max_retries}}): {{error}} +previous response was NOT delivered to user +retry without problematic content or inform user via alternative means +if attachment caused the issue, retry without it diff --git a/plugins/_email_integration/prompts/fw.email.system_context.md b/plugins/_email_integration/prompts/fw.email.system_context.md new file mode 100644 index 0000000000000000000000000000000000000000..6feceaa7b8b615ae979fb7d1d3810092b5a0cd2f --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.system_context.md @@ -0,0 +1,4 @@ +email session user communicates via email +response tool sends email dont use python +break_loop true > stop working and wait for user reply +break_loop false > only for mid-task progress updates then keep working \ No newline at end of file diff --git a/plugins/_email_integration/prompts/fw.email.system_context_reply.md b/plugins/_email_integration/prompts/fw.email.system_context_reply.md new file mode 100644 index 0000000000000000000000000000000000000000..643917a77ede5c5e4ba604aeb67f6d3ca179bd15 --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.system_context_reply.md @@ -0,0 +1,34 @@ +# Email session behavior +user communicates via email +response tool = send email to user +dont use code to send email +break_loop true > stop working and wait for user reply +break_loop false > only for mid-task progress updates then keep working +include file paths in attachments array +multiple files zip first attach single archive +usage: + +~~~json +{ + ... + "tool_name": "response", + "tool_args": { + "text": "working on it...", + "break_loop": false + } +} +~~~ + +~~~json +{ + ... + "tool_name": "response", + "tool_args": { + "text": "Here is...", + "attachments": [ + "/path/file.zip" + ], + "break_loop": true + } +} +~~~ diff --git a/plugins/_email_integration/prompts/fw.email.update_error.md b/plugins/_email_integration/prompts/fw.email.update_error.md new file mode 100644 index 0000000000000000000000000000000000000000..5059622871494aae097ecfcb643f3f29a510a479 --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.update_error.md @@ -0,0 +1 @@ +email update failed: {{error}} diff --git a/plugins/_email_integration/prompts/fw.email.update_ok.md b/plugins/_email_integration/prompts/fw.email.update_ok.md new file mode 100644 index 0000000000000000000000000000000000000000..35f2c49fac0918e722d20f60db2f7f0e992fe30e --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.update_ok.md @@ -0,0 +1 @@ +email update sent, continue working diff --git a/plugins/_email_integration/prompts/fw.email.user_message.md b/plugins/_email_integration/prompts/fw.email.user_message.md new file mode 100644 index 0000000000000000000000000000000000000000..9adb9716968a2c85af0d193e2f599397ab41fa3f --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.user_message.md @@ -0,0 +1,4 @@ +[Email from {{sender}} to {{recipient}}] +Subject: {{subject}} + +{{body}} diff --git a/plugins/_email_integration/prompts/fw.email.user_message_instructions.md b/plugins/_email_integration/prompts/fw.email.user_message_instructions.md new file mode 100644 index 0000000000000000000000000000000000000000..e4d908b6037d29bd849380067db89912af7454af --- /dev/null +++ b/plugins/_email_integration/prompts/fw.email.user_message_instructions.md @@ -0,0 +1,2 @@ +# Email custom rules +{{instructions}} diff --git a/plugins/_email_integration/webui/config.html b/plugins/_email_integration/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..014de89c14d2ed7cc48cacaee3f9afa155db76bb --- /dev/null +++ b/plugins/_email_integration/webui/config.html @@ -0,0 +1,1012 @@ + + + + Email Integration + + + + +
+ +
+ + + + + diff --git a/plugins/_email_integration/webui/email-config-store.js b/plugins/_email_integration/webui/email-config-store.js new file mode 100644 index 0000000000000000000000000000000000000000..33b0884e6706a57e310ffbb3f62b61cdb2f2c190 --- /dev/null +++ b/plugins/_email_integration/webui/email-config-store.js @@ -0,0 +1,500 @@ +import { createStore } from "/js/AlpineStore.js"; +import * as API from "/js/api.js"; + +const API_BASE = "/plugins/_email_integration"; +const GMAIL_APP_PASSWORDS_URL = "https://support.google.com/mail/answer/185833?hl=en"; +const PRESETS = { + "": { + label: "Choose a provider", + account_type: "imap", + imap_server: "", + imap_port: 993, + smtp_server: "", + smtp_port: 587, + }, + gmail: { + label: "Gmail", + account_type: "imap", + imap_server: "imap.gmail.com", + imap_port: 993, + smtp_server: "smtp.gmail.com", + smtp_port: 587, + }, + icloud: { + label: "iCloud Mail", + account_type: "imap", + imap_server: "imap.mail.me.com", + imap_port: 993, + smtp_server: "smtp.mail.me.com", + smtp_port: 587, + }, + microsoft365: { + label: "Outlook / Microsoft 365", + account_type: "imap", + imap_server: "outlook.office365.com", + imap_port: 993, + smtp_server: "smtp.office365.com", + smtp_port: 587, + }, + yahoo: { + label: "Yahoo Mail", + account_type: "imap", + imap_server: "imap.mail.yahoo.com", + imap_port: 993, + smtp_server: "smtp.mail.yahoo.com", + smtp_port: 587, + }, + exchange: { + label: "Exchange", + account_type: "exchange", + imap_server: "outlook.office365.com", + imap_port: 993, + smtp_server: "smtp.office365.com", + smtp_port: 587, + }, + "custom-imap": { + label: "Custom IMAP", + account_type: "imap", + imap_server: "", + imap_port: 993, + smtp_server: "", + smtp_port: 587, + }, +}; + +const PROVIDER_OPTIONS = [ + { + value: "gmail", + label: "Gmail", + hint: "Google app password", + logo: "gmail", + logoText: "M", + }, + { + value: "icloud", + label: "iCloud Mail", + hint: "Apple app-specific password", + logo: "icloud", + logoText: "cloud", + }, + { + value: "microsoft365", + label: "Outlook / Microsoft 365", + hint: "Microsoft mailbox preset", + logo: "microsoft365", + logoText: "", + }, + { + value: "yahoo", + label: "Yahoo Mail", + hint: "Yahoo app password", + logo: "yahoo", + logoText: "Y!", + }, + { + value: "exchange", + label: "Exchange", + hint: "Organization Exchange server", + logo: "exchange", + logoText: "X", + }, + { + value: "custom-imap", + label: "Custom IMAP", + hint: "Manual server settings", + logo: "custom-imap", + logoText: "dns", + }, +]; + +function ensureConfig(config) { + if (!config || typeof config !== "object") return; + if (!Array.isArray(config.handlers)) config.handlers = []; +} + +export const store = createStore("emailConfig", { + config: null, + context: null, + editing: null, + testing: null, + testResults: null, + testResultsFor: null, + providerPickerOpen: null, + guideOpen: false, + didInit: false, + projects: [], + modelPresets: [], + presets: PRESETS, + + get providerOptions() { + return PROVIDER_OPTIONS; + }, + + get handlers() { + ensureConfig(this.config); + return Array.isArray(this.config?.handlers) ? this.config.handlers : []; + }, + + async init(config, context = null) { + if (!config || typeof config !== "object") return; + + this.config = config || null; + this.context = context; + this.didInit = false; + ensureConfig(this.config); + this.editing = this.handlers.length === 1 ? 0 : null; + this.testing = null; + this.testResults = null; + this.testResultsFor = null; + this.providerPickerOpen = null; + this.guideOpen = this.handlers.length === 0 && window.innerWidth > 720; + if (this.handlers.length === 0) this._startInitialHandlerFlow(); + if (this.editing !== null && !this.hasProvider(this.handlers[this.editing])) { + this.providerPickerOpen = this.editing; + } + this.didInit = true; + + const [projectsResult, presetsResult] = await Promise.allSettled([ + API.callJsonApi("projects", { action: "list" }), + API.callJsonApi("/plugins/_model_config/model_presets", { action: "get" }), + ]); + + this.projects = projectsResult.status === "fulfilled" ? (projectsResult.value.data || []) : []; + this.modelPresets = presetsResult.status === "fulfilled" && Array.isArray(presetsResult.value.presets) + ? presetsResult.value.presets + : []; + }, + + cleanup() { + this.config = null; + this.context = null; + this.editing = null; + this.testing = null; + this.testResults = null; + this.testResultsFor = null; + this.providerPickerOpen = null; + this.guideOpen = false; + this.didInit = false; + }, + + newHandler() { + return { + name: "", + enabled: false, + provider: "", + account_type: "imap", + imap_server: "", + imap_port: 993, + smtp_server: "", + smtp_port: 587, + username: "", + password: "", + poll_mode: "seconds", + poll_interval_seconds: 60, + poll_interval_cron: "*/2 * * * *", + process_unread_days: 0, + sender_whitelist: [], + project: "", + dispatcher_model: "utility", + chat_model_preset: "", + dispatcher_instructions: "", + agent_instructions: "", + }; + }, + + addHandler() { + ensureConfig(this.config); + this.config.handlers.push(this.newHandler()); + this.editing = this.config.handlers.length - 1; + this.providerPickerOpen = this.editing; + this.testResults = null; + this.testResultsFor = null; + }, + + removeHandler(idx) { + this.handlers.splice(idx, 1); + if (this.editing === idx) this.editing = null; + if (this.editing !== null && this.editing > idx) this.editing -= 1; + if (this.providerPickerOpen === idx) this.providerPickerOpen = null; + if (this.providerPickerOpen !== null && this.providerPickerOpen > idx) this.providerPickerOpen -= 1; + if (this.testResultsFor === idx) { + this.testResults = null; + this.testResultsFor = null; + } + }, + + toggleEditing(idx) { + this.editing = this.editing === idx ? null : idx; + if (this.editing === null) { + this.providerPickerOpen = null; + } else if (!this.hasProvider(this.handlers[idx])) { + this.providerPickerOpen = idx; + } + if (this.testResultsFor !== idx) { + this.testResults = null; + this.testResultsFor = null; + } + }, + + providerValue(handler) { + if (!handler || typeof handler !== "object") return ""; + const explicitProvider = String(handler.provider || "").trim(); + if (explicitProvider && this.presets[explicitProvider]) return explicitProvider; + + const incoming = String(handler.imap_server || "").trim().toLowerCase(); + const outgoing = String(handler.smtp_server || "").trim().toLowerCase(); + const accountType = handler.account_type || "imap"; + + if (!incoming && !outgoing && !handler.username && !handler.password) return ""; + if (accountType === "exchange") return "exchange"; + if (incoming === "imap.gmail.com" || outgoing === "smtp.gmail.com") return "gmail"; + if (incoming === "imap.mail.me.com" || outgoing === "smtp.mail.me.com") return "icloud"; + if (incoming === "outlook.office365.com" || outgoing === "smtp.office365.com") return "microsoft365"; + if (incoming === "imap.mail.yahoo.com" || outgoing === "smtp.mail.yahoo.com") return "yahoo"; + return "custom-imap"; + }, + + hasProvider(handler) { + return Boolean(this.providerValue(handler)); + }, + + showProviderChoices(handler, idx) { + return !this.hasProvider(handler) || this.providerPickerOpen === idx; + }, + + changeProvider(idx) { + this.providerPickerOpen = this.providerPickerOpen === idx ? null : idx; + }, + + providerLabel(value) { + return this.presets[value]?.label || "Custom IMAP"; + }, + + selectProvider(handler, value, idx = null) { + this.applyProvider(handler, value); + this.providerPickerOpen = null; + this.testResults = null; + this.testResultsFor = null; + if (idx !== null) this.editing = idx; + }, + + applyProvider(handler, value) { + const preset = this.presets[value]; + if (!preset) return; + const previous = this.providerValue(handler); + + handler.account_type = preset.account_type; + handler.provider = value; + + if (value === "custom-imap") { + if (previous !== "custom-imap") { + handler.imap_server = ""; + handler.smtp_server = ""; + } + if (!handler.imap_port) handler.imap_port = 993; + if (!handler.smtp_port) handler.smtp_port = 587; + return; + } + + handler.imap_server = preset.imap_server; + handler.imap_port = preset.imap_port; + handler.smtp_server = preset.smtp_server; + handler.smtp_port = preset.smtp_port; + }, + + providerHint(handler) { + const provider = this.providerValue(handler); + if (provider === "gmail") return "Use a Google App Password. A regular Gmail password usually will not work here."; + if (provider === "icloud") return "Use an app-specific password from your Apple account settings."; + if (provider === "microsoft365") return "Most Outlook and Microsoft 365 inboxes work with this preset."; + if (provider === "yahoo") return "Yahoo Mail usually works best with an app password."; + if (provider === "exchange") return "Choose Exchange only if your organization requires it. For the simplest setup, try Outlook / Microsoft 365 first."; + if (provider === "custom-imap") return "Bring your own incoming and outgoing mail server details."; + return "How to start: turn on inbox, pick your provider, then add your email address and password."; + }, + + providerHelpUrl(handler) { + return this.providerValue(handler) === "gmail" ? GMAIL_APP_PASSWORDS_URL : ""; + }, + + providerHelpLabel(handler) { + if (this.providerValue(handler) !== "gmail") return ""; + return "Google's guide to create a Gmail App Password"; + }, + + showManualServers(handler) { + return this.providerValue(handler) === "custom-imap"; + }, + + showExchangeServer(handler) { + return this.providerValue(handler) === "exchange"; + }, + + incomingLabel(handler) { + return this.showExchangeServer(handler) ? "Exchange server" : "Incoming mail server"; + }, + + incomingDescription(handler) { + return this.showExchangeServer(handler) + ? "The Exchange or Microsoft 365 server for this inbox" + : "The IMAP server for incoming mail"; + }, + + incomingPlaceholder(handler) { + return this.showExchangeServer(handler) ? "outlook.office365.com" : "imap.your-provider.com"; + }, + + scheduleLabel(handler) { + const value = this.frequencyValue(handler); + if (value === "15") return "Checks every 15 seconds"; + if (value === "30") return "Checks every 30 seconds"; + if (value === "60") return "Checks every minute"; + if (value === "300") return "Checks every 5 minutes"; + if (value === "900") return "Checks every 15 minutes"; + return "Uses a custom schedule"; + }, + + frequencyValue(handler) { + if (handler.poll_mode !== "seconds") return "custom"; + const seconds = Number(handler.poll_interval_seconds || 0); + if ([15, 30, 60, 300, 900].includes(seconds)) return String(seconds); + return "custom"; + }, + + applyFrequency(handler, value) { + if (value === "custom") { + if (handler.poll_mode !== "cron") handler.poll_mode = "seconds"; + if (!handler.poll_interval_seconds) handler.poll_interval_seconds = 60; + return; + } + handler.poll_mode = "seconds"; + handler.poll_interval_seconds = Number(value); + }, + + frequencyHint(handler) { + return this.frequencyValue(handler) === "custom" + ? "You are using a custom schedule. You can change the raw timing in Advanced." + : this.scheduleLabel(handler); + }, + + whitelistText(handler) { + return (handler.sender_whitelist || []).join(", "); + }, + + setWhitelist(handler, value) { + handler.sender_whitelist = value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry); + }, + + slugify(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + }, + + maybeAutoname(handler) { + const current = String(handler.name || "").trim(); + if (current && !/^handler_\d+$/.test(current)) return; + const email = String(handler.username || "").trim(); + const localPart = email.split("@")[0] || ""; + const nextName = this.slugify(localPart); + if (nextName) handler.name = nextName; + }, + + missingBits(handler) { + const missing = []; + const provider = this.providerValue(handler); + if (!provider) missing.push("provider"); + if (!handler.username) missing.push("email address"); + if (!handler.password) missing.push("password"); + if ((this.showManualServers(handler) || this.showExchangeServer(handler)) && !handler.imap_server) { + missing.push(this.showExchangeServer(handler) ? "Exchange server" : "incoming server"); + } + if (this.showManualServers(handler) && !handler.smtp_server) missing.push("outgoing server"); + return missing; + }, + + canTest(handler) { + return this.missingBits(handler).length === 0; + }, + + statusLabel(handler) { + if (!handler.username && !this.providerValue(handler)) return "New"; + if (handler.enabled && this.canTest(handler)) return "Live"; + if (this.canTest(handler)) return "Ready"; + return "Needs info"; + }, + + statusTone(handler) { + if (!handler.username && !this.providerValue(handler)) return "muted"; + if (handler.enabled && this.canTest(handler)) return "success"; + if (this.canTest(handler)) return "ready"; + return "warning"; + }, + + handlerTitle(handler, idx) { + return handler.username || handler.name || `Inbox ${idx + 1}`; + }, + + handlerSubtitle(handler) { + const pieces = []; + const provider = this.providerValue(handler); + if (provider) pieces.push(this.providerLabel(provider)); + pieces.push(this.scheduleLabel(handler).replace("Checks ", "")); + if (handler.project) pieces.push(`Project: ${handler.project}`); + return pieces.join(" · "); + }, + + testButtonLabel(handler, idx) { + if (this.testing === idx) return "Checking..."; + if (this.canTest(handler)) return "Check setup"; + return "Fill in the basics first"; + }, + + testIntro(handler) { + if (this.providerValue(handler) === "exchange") { + return "We will check the inbox, check outgoing mail, then send a test email to this address."; + } + return "We will check incoming mail, check outgoing mail, then send a test email to this inbox."; + }, + + resultTitle(result) { + return result.test || "Check"; + }, + + resultMessage(result) { + return result.message || (result.ok ? "Done." : "Something went wrong."); + }, + + _startInitialHandlerFlow() { + this.addHandler(); + if (!this.context) return; + const toComparableJson = typeof this.context._toComparableJson === "function" + ? this.context._toComparableJson.bind(this.context) + : JSON.stringify; + this.context.settingsSnapshotJson = toComparableJson(this.context.settings); + }, + + async testConnection(idx) { + const handler = this.handlers[idx]; + if (!handler || !this.canTest(handler)) return; + + this.testing = idx; + this.testResults = null; + this.testResultsFor = idx; + + try { + this.testResults = await API.callJsonApi(`${API_BASE}/test_connection`, { handler }); + } catch (error) { + this.testResults = { + success: false, + results: [{ test: "Connection", ok: false, message: String(error) }], + }; + } + + this.testing = null; + }, +}); diff --git a/plugins/_email_integration/webui/thumbnail.jpg b/plugins/_email_integration/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..99b6985d3dc8660d131c1c8817f5fda6caf8d213 Binary files /dev/null and b/plugins/_email_integration/webui/thumbnail.jpg differ diff --git a/plugins/_error_retry/AGENTS.md b/plugins/_error_retry/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..ae1a15541f2874002a08d6808cffd78e57beeb77 --- /dev/null +++ b/plugins/_error_retry/AGENTS.md @@ -0,0 +1,30 @@ +# Error Retry Plugin DOX + +## Purpose + +- Own retry behavior for unexpected critical exceptions in agent loops. + +## Ownership + +- `extensions/` owns retry counter reset and critical-exception retry hooks. +- `default_config.yaml` owns retry count defaults. +- `webui/config.html` owns settings UI. +- `plugin.yaml` and `README.md` own metadata and behavior notes. + +## Local Contracts + +- Do not retry controlled flow exceptions such as `HandledException` and `RepairableException`. +- Keep retry counts scoped per monologue. +- Preserve clear agent-facing history injection when retrying a critical exception. + +## Work Guidance + +- Keep retry behavior conservative and observable through logs/UI. + +## Verification + +- Test or simulate critical, handled, and repairable exception paths after hook changes. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_error_retry/README.md b/plugins/_error_retry/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b2cf4caff05c5b2ad005ea8b724dc9531afa39df --- /dev/null +++ b/plugins/_error_retry/README.md @@ -0,0 +1,39 @@ +# Error Retry + +Retry after an unexpected critical exception so the agent has a chance to recover instead of failing immediately. + +## What It Does + +This plugin hooks into the agent lifecycle and automatically retries the current loop when an unhandled critical exception occurs. + +It does **not** retry exceptions that are already treated as controlled agent flow, such as: + +- `HandledException` +- `RepairableException` + +## Main Behavior + +- **Counter reset per monologue** + - Clears the retry counter at the start of a new monologue. +- **Critical exception retry** + - On an unexpected exception, logs a warning, waits briefly, injects an agent-facing critical error message into history, and suppresses the original exception while retries remain. +- **Configurable retry count** + - Uses the plugin's `retries` setting to control how many times recovery is attempted per monologue. The default is `1`. + +## Key Files + +- `extensions/python/_functions/agent/Agent/monologue/start/_10_reset_critical_exception_counter.py` +- `extensions/python/_functions/agent/Agent/handle_exception/end/_80_retry_critical_exception.py` + +## Configuration Scope + +- **Settings section**: `agent` +- **Per-project config**: `true` +- **Per-agent config**: `true` +- **Always enabled**: `false` + +## Plugin Metadata + +- **Name**: `_error_retry` +- **Title**: `Error Retry` +- **Description**: Retry on critical exceptions before failing. diff --git a/plugins/_error_retry/default_config.yaml b/plugins/_error_retry/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..915fe45816aff1953ddd9dbd685b1766697b5e84 --- /dev/null +++ b/plugins/_error_retry/default_config.yaml @@ -0,0 +1,2 @@ +retries: 1 +try_clear_embeds: true \ No newline at end of file diff --git a/plugins/_error_retry/extensions/python/_functions/agent/Agent/handle_exception/end/_80_retry_critical_exception.py b/plugins/_error_retry/extensions/python/_functions/agent/Agent/handle_exception/end/_80_retry_critical_exception.py new file mode 100644 index 0000000000000000000000000000000000000000..e920d29a7ee103d89b1eb3dcc001cb1a9e152f42 --- /dev/null +++ b/plugins/_error_retry/extensions/python/_functions/agent/Agent/handle_exception/end/_80_retry_critical_exception.py @@ -0,0 +1,106 @@ +import asyncio +from datetime import datetime, timezone +import litellm +from helpers.extension import Extension +from agent import LoopData +from helpers.localization import Localization +from helpers.errors import RepairableException, HandledException +from helpers import errors, plugins +from helpers.print_style import PrintStyle + +from plugins._error_retry.extensions.python._functions.agent.Agent.monologue.start._10_reset_critical_exception_counter import DATA_NAME_COUNTER + +DEFAULT_MAX_RETRIES = 1 +RETRY_DELAY_SECONDS = 3 + + +def normalize_max_retries(value, default: int = DEFAULT_MAX_RETRIES) -> int: + if value is None or isinstance(value, bool): + return default + + try: + return max(0, int(value)) + except (TypeError, ValueError): + return default + + +class RetryCriticalException(Extension): + async def execute(self, data: dict = {}, **kwargs): + if not self.agent: + return + + exception = data.get("exception") + if not exception: + self.agent.set_data(DATA_NAME_COUNTER, 0) # reset counter if exception has been handled + return + + if isinstance(exception, (HandledException, RepairableException)): + self.agent.set_data(DATA_NAME_COUNTER, 0) # reset counter if exception has been handled + return + + cfg = plugins.get_plugin_config("_error_retry", agent=self.agent) or {} + max_retries = normalize_max_retries(cfg.get("retries")) + + counter = self.agent.get_data(DATA_NAME_COUNTER) or 0 + if counter >= max_retries: + self.when_critical(data) + return + + self.agent.set_data(DATA_NAME_COUNTER, counter + 1) + + error_message = errors.format_error(exception) + import uuid as _uuid + msg_id = str(_uuid.uuid4()) + self.agent.context.log.log( + type="warning", + heading="Critical error occurred, retrying...", + content=error_message, + id=msg_id, + ) + PrintStyle(font_color="orange", padding=True).print( + "Critical error occurred, retrying..." + ) + await asyncio.sleep(RETRY_DELAY_SECONDS) + await self.agent.handle_intervention() + agent_facing_error = self.agent.read_prompt( + "fw.msg_critical_error.md", error_message=error_message + ) + self.agent.hist_add_warning(message=agent_facing_error, id=msg_id) + PrintStyle(font_color="orange", padding=True).print(agent_facing_error) + + data["exception"] = None + + + def when_critical(self, data: dict = {}): + if not self.agent: + return + + self.try_clear_embeds(data) + + def try_clear_embeds(self, data: dict = {}): + """Try to clear embeds before failing on LiteLLM errors""" + + if not self.agent: + return + + exc = data.get("exception") + if not isinstance(exc, litellm.exceptions.BadRequestError): + return + + cfg = plugins.get_plugin_config("_error_retry", agent=self.agent) or {} + if not cfg.get("try_clear_embeds", False): + return + + removed = self.agent.history.remove_all_embeds() + if removed <= 0: + return + + data["exception"] = None + self.agent.context.log.log( + type="warning", + heading="Cleared embedded media from history", + content=f"Persistent LiteLLM bad request detected. Removed {removed} embedded media messages from history to help recover.", + ) + PrintStyle(font_color="orange", padding=True).print( + f"Cleared {removed} embedded media messages from history after persistent LiteLLM bad request." + ) diff --git a/plugins/_error_retry/extensions/python/_functions/agent/Agent/monologue/start/_10_reset_critical_exception_counter.py b/plugins/_error_retry/extensions/python/_functions/agent/Agent/monologue/start/_10_reset_critical_exception_counter.py new file mode 100644 index 0000000000000000000000000000000000000000..72daad6a69201b07aa41956991cad01e806ab5ae --- /dev/null +++ b/plugins/_error_retry/extensions/python/_functions/agent/Agent/monologue/start/_10_reset_critical_exception_counter.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e087722fecd301913a0519ee7fc4db1a721b92f62e28dd3b5be04c71a05dcef +size 569 diff --git a/plugins/_error_retry/plugin.yaml b/plugins/_error_retry/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..338ab7b015f1df432baa805be8bcacdd7f6f16d0 --- /dev/null +++ b/plugins/_error_retry/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d10a94418bbc91638763fa18a7e96474d0ba7649631b785d9458be4e6c273fb +size 209 diff --git a/plugins/_error_retry/webui/config.html b/plugins/_error_retry/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..3fe51a0f4a262001cc8275a2109ff8c6745f2299 --- /dev/null +++ b/plugins/_error_retry/webui/config.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13d5b427a1f91c1171c305397d7b132d64dc4fab7ac42ed15908c76ba500ea01 +size 1947 diff --git a/plugins/_error_retry/webui/thumbnail.jpg b/plugins/_error_retry/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..82f8bf9fb81c7879b6645c13bfc3be49cf729e4f --- /dev/null +++ b/plugins/_error_retry/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30b3d5b5253cea7358ab0f1aa894f090df2f0d8714e0eff9db74bddc1a11eb6d +size 8784 diff --git a/plugins/_goal/AGENTS.md b/plugins/_goal/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..0c56367b89ae4d492a2c0061d29296b07f10e974 --- /dev/null +++ b/plugins/_goal/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fab3e60aae654833e551f9501daba5ca3bb007bc2bc737864fca2ec80ed21d8c +size 3087 diff --git a/plugins/_goal/__init__.py b/plugins/_goal/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fd481aab725cd00018d4e32426f317aa39787c65 --- /dev/null +++ b/plugins/_goal/__init__.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b +size 1 diff --git a/plugins/_goal/api/__init__.py b/plugins/_goal/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fd481aab725cd00018d4e32426f317aa39787c65 --- /dev/null +++ b/plugins/_goal/api/__init__.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b +size 1 diff --git a/plugins/_goal/api/goal.py b/plugins/_goal/api/goal.py new file mode 100644 index 0000000000000000000000000000000000000000..7766a71c914f7f3f0f02a1df06cdf274943012dc --- /dev/null +++ b/plugins/_goal/api/goal.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e93b44a8b15c74d84997568b1f6b708f1755ee3a398831af9099b9ffa839c76 +size 2632 diff --git a/plugins/_goal/commands/goal.command.yaml b/plugins/_goal/commands/goal.command.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0361086ae35249eb51737cc738ea4a73eaf1c646 --- /dev/null +++ b/plugins/_goal/commands/goal.command.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8be59e0d835dfaa648594b13b75852b1cb70f677adcf822395414807575bd34 +size 203 diff --git a/plugins/_goal/commands/goal_command.py b/plugins/_goal/commands/goal_command.py new file mode 100644 index 0000000000000000000000000000000000000000..0eb1778ecafe9983f9cdce562c218eb7c3ecb5e3 --- /dev/null +++ b/plugins/_goal/commands/goal_command.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbfe925d06e1f5f3ae3251b458616e291f7582f53b56abc7f2ddf226a37e31f2 +size 3603 diff --git a/plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py b/plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py new file mode 100644 index 0000000000000000000000000000000000000000..35d6af94ada7d33c868a1ecb25db2f06a400bf19 --- /dev/null +++ b/plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:615ddccba2b6777ff63b3832cf43956f4fc3ca34787363dfa53ee4daf8578014 +size 934 diff --git a/plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js b/plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js new file mode 100644 index 0000000000000000000000000000000000000000..ca680889386f1a6b4164553794bc52fe156a8b52 --- /dev/null +++ b/plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32455f6f6a851b5f8806595b50726686ab9807de7237c60979da74227addd9f1 +size 581 diff --git a/plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html b/plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html new file mode 100644 index 0000000000000000000000000000000000000000..53466251ee5d01ec08ad13d0a63dd03713cbeb4e --- /dev/null +++ b/plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:652571c5116b82d7a91bba037b8f46b47cf179a06d31b7d719b8e8e889a019e9 +size 5992 diff --git a/plugins/_goal/extensions/webui/initFw_end/goal-menu-injector.js b/plugins/_goal/extensions/webui/initFw_end/goal-menu-injector.js new file mode 100644 index 0000000000000000000000000000000000000000..d3d014660dfc48f0d3a95c05ab04408e4cb4638d --- /dev/null +++ b/plugins/_goal/extensions/webui/initFw_end/goal-menu-injector.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:309561c5898eecaa3bf99798aaa312bd6653aca895446aa84176962846e2d939 +size 1565 diff --git a/plugins/_goal/plugin.yaml b/plugins/_goal/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..410bbf4df2a1de1f814c3f3acd2990b61e2f267f --- /dev/null +++ b/plugins/_goal/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc7a1733ec4672e353f053ef0da06e6ef43a0117299372ed334dba3cb86f57fa +size 117 diff --git a/plugins/_goal/prompts/agent.extras.goal.md b/plugins/_goal/prompts/agent.extras.goal.md new file mode 100644 index 0000000000000000000000000000000000000000..77604ca1cfefe58129acad184d6bc6c20936e3fa --- /dev/null +++ b/plugins/_goal/prompts/agent.extras.goal.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:202415daf1055b877b805ed340937f54087c0b3d62c1ab31ce34489840fd74ba +size 661 diff --git a/plugins/_goal/prompts/agent.system.tool.goal.md b/plugins/_goal/prompts/agent.system.tool.goal.md new file mode 100644 index 0000000000000000000000000000000000000000..101fd0fce74beae887a9f268aaf6e29fd5239fc5 --- /dev/null +++ b/plugins/_goal/prompts/agent.system.tool.goal.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:923ffe70914eaee41e47f5cdee8638cdcf168e00b98b0ddbbee99a4b77377303 +size 1020 diff --git a/plugins/_goal/tests/conftest.py b/plugins/_goal/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..43dcb4127fd6c35aca51030daa89db79b5d9e5c1 --- /dev/null +++ b/plugins/_goal/tests/conftest.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf088258a9aca9212c52aea2e20a90255bc614ffa2418b1f8ca7c1380d8fee40 +size 173 diff --git a/plugins/_goal/tests/test_goal_plugin.py b/plugins/_goal/tests/test_goal_plugin.py new file mode 100644 index 0000000000000000000000000000000000000000..22986f125405053979b8a8557055c042c163d5ae --- /dev/null +++ b/plugins/_goal/tests/test_goal_plugin.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:665ea0ab3cf4fd6771be5e073e3cd058ca0edf61bc5ad9d124ab9fd1272300b8 +size 10873 diff --git a/plugins/_goal/tools/__init__.py b/plugins/_goal/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fd481aab725cd00018d4e32426f317aa39787c65 --- /dev/null +++ b/plugins/_goal/tools/__init__.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b +size 1 diff --git a/plugins/_goal/tools/goal.py b/plugins/_goal/tools/goal.py new file mode 100644 index 0000000000000000000000000000000000000000..095e3e2b228c99739b9f460662ee7485416956e3 --- /dev/null +++ b/plugins/_goal/tools/goal.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54cee76de0961d49d6d06983ff2d50f31f5973c7374af2c3e02e70ca521f23b9 +size 10803 diff --git a/plugins/_goal/tools/response.py b/plugins/_goal/tools/response.py new file mode 100644 index 0000000000000000000000000000000000000000..8b52fb93536b348a09818d44529d7d4c11c98c5a --- /dev/null +++ b/plugins/_goal/tools/response.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7743b322c20511217e0f50ce9d4654b250b47a80a60304f8747556b844eaa002 +size 1230 diff --git a/plugins/_goal/webui/goal-store.js b/plugins/_goal/webui/goal-store.js new file mode 100644 index 0000000000000000000000000000000000000000..0dcc2080ea3819d93c527ea268cf9f8d5e0a27fa --- /dev/null +++ b/plugins/_goal/webui/goal-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48eb30a166a45fc5063c82585098fda401b4dbe8ea771baa0149d1a214e750c4 +size 6039 diff --git a/plugins/_goal/webui/thumbnail.png b/plugins/_goal/webui/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..9bc931e998db88273deb001e9de85902e160ae9a --- /dev/null +++ b/plugins/_goal/webui/thumbnail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6f1ab23c0cbccf40e321fc41d0eee0886e15faa6829cc2521d036518872e814 +size 17502 diff --git a/plugins/_infection_check/.toggle-0 b/plugins/_infection_check/.toggle-0 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/plugins/_infection_check/AGENTS.md b/plugins/_infection_check/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..dbf6b34b3175a189d0dffeabba84c56b9065ef23 --- /dev/null +++ b/plugins/_infection_check/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5365c9f154ab14fcf48b1390b6f240f9f9a761e66561a77e983b2163c3540db +size 900 diff --git a/plugins/_infection_check/README.md b/plugins/_infection_check/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3b5d421a1889e29871ce4ca354d8417b2c304f48 --- /dev/null +++ b/plugins/_infection_check/README.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d0939509ef8525077894212815cbb752f8f13507b871e1fadea0a872828191b +size 4280 diff --git a/plugins/_infection_check/default_config.yaml b/plugins/_infection_check/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5e9f69534760bb95b987009943c3a0ba0fccc0e7 --- /dev/null +++ b/plugins/_infection_check/default_config.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7bfcd4e6f6868cdd23e2da0f7589c8166a893e2f088e342967a1b9ab6380442 +size 4042 diff --git a/plugins/_infection_check/extensions/python/reasoning_stream_chunk/_50_infection_collect.py b/plugins/_infection_check/extensions/python/reasoning_stream_chunk/_50_infection_collect.py new file mode 100644 index 0000000000000000000000000000000000000000..a9a2634c90e0461d650e32dc07da9de2c33a778b --- /dev/null +++ b/plugins/_infection_check/extensions/python/reasoning_stream_chunk/_50_infection_collect.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ae8a9fe594865532735d8da0d98cf3a5e87ef0a59c82db0f0ab42d52c05fc38 +size 405 diff --git a/plugins/_infection_check/extensions/python/response_stream/_50_infection_analyze.py b/plugins/_infection_check/extensions/python/response_stream/_50_infection_analyze.py new file mode 100644 index 0000000000000000000000000000000000000000..9f66096d8d296bcc0bb7693e53478c52b5d0d1ab --- /dev/null +++ b/plugins/_infection_check/extensions/python/response_stream/_50_infection_analyze.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dc8eb8f9262b9a2145a6c1ab231d2e9ed5f9885092225c71db77c8aa7071f45 +size 589 diff --git a/plugins/_infection_check/extensions/python/response_stream_chunk/_50_infection_collect.py b/plugins/_infection_check/extensions/python/response_stream_chunk/_50_infection_collect.py new file mode 100644 index 0000000000000000000000000000000000000000..20470b36f68b706f77996b7cb1ccff9a50bed682 --- /dev/null +++ b/plugins/_infection_check/extensions/python/response_stream_chunk/_50_infection_collect.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4a9e5bf79638e9be071ba65b70ce4ad157f87c5b331d47e259d80a944db6d7a +size 403 diff --git a/plugins/_infection_check/extensions/python/response_stream_end/_50_infection_analyze.py b/plugins/_infection_check/extensions/python/response_stream_end/_50_infection_analyze.py new file mode 100644 index 0000000000000000000000000000000000000000..2d444db421acae52f4eae3c05677944054d1dbd4 --- /dev/null +++ b/plugins/_infection_check/extensions/python/response_stream_end/_50_infection_analyze.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a1b5dd8294c0e2c664d5d1dac8ea22f65562521605d437cf066fe9d22a3f9b1 +size 338 diff --git a/plugins/_infection_check/extensions/python/tool_execute_before/_50_infection_check.py b/plugins/_infection_check/extensions/python/tool_execute_before/_50_infection_check.py new file mode 100644 index 0000000000000000000000000000000000000000..288c609859943462870aedbea3e964c4423a9ee5 --- /dev/null +++ b/plugins/_infection_check/extensions/python/tool_execute_before/_50_infection_check.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:512b014e0a35414b13099dc4054ce087be31a4a5a28a59c616de7468a8486dd8 +size 377 diff --git a/plugins/_infection_check/helpers/checker.py b/plugins/_infection_check/helpers/checker.py new file mode 100644 index 0000000000000000000000000000000000000000..ef745ba552784007863685b41c958e83fa628302 --- /dev/null +++ b/plugins/_infection_check/helpers/checker.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:905a51860e885e702f77db5f3206c29a390844cda88a293aa33e691e8c036ca7 +size 14127 diff --git a/plugins/_infection_check/plugin.yaml b/plugins/_infection_check/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5105167227944a2e170e3f34624eb578e4639947 --- /dev/null +++ b/plugins/_infection_check/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe1b8503a8dd8f5ec9879d192a252697ddc814e1a33a5f87aa670d4b3a3aa5ee +size 208 diff --git a/plugins/_infection_check/webui/config.html b/plugins/_infection_check/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..689c7ae6d35bbbd67e4a3f1db28c980098bebb0b --- /dev/null +++ b/plugins/_infection_check/webui/config.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edd645507522005bffc28783da9fd818cc5f6ff0e0d77591147e01753eb14005 +size 2842 diff --git a/plugins/_infection_check/webui/thumbnail.jpg b/plugins/_infection_check/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ef0bc5f8e5046f49aff3bcf12f2524e98cea18a2 --- /dev/null +++ b/plugins/_infection_check/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:391c4a4259cd8e412a061d79abb647f598f4d22758e0169b30f81d19b975fa73 +size 19972 diff --git a/plugins/_kokoro_tts/AGENTS.md b/plugins/_kokoro_tts/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..eef3c1525cd5da4133e8a2c7c624d71fa3455571 --- /dev/null +++ b/plugins/_kokoro_tts/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fdc7a6d5a3403f13c87fa3ac38e6103914ec3179dd6683a0fbecf83c93b16398 +size 1091 diff --git a/plugins/_kokoro_tts/README.md b/plugins/_kokoro_tts/README.md new file mode 100644 index 0000000000000000000000000000000000000000..039a7b2bd434b000d74d22e25e47c99fcc5c32ed --- /dev/null +++ b/plugins/_kokoro_tts/README.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c0164d700a75a44fab10fb907d5c993d64473281a212970b962809c5d8dfe8c +size 876 diff --git a/plugins/_kokoro_tts/api/status.py b/plugins/_kokoro_tts/api/status.py new file mode 100644 index 0000000000000000000000000000000000000000..ad67b5f4b9b55bbaaf45d4f155933813276d4f76 --- /dev/null +++ b/plugins/_kokoro_tts/api/status.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37ae2ef7aeb9665f85555e59e8cfdd4ceee5c9f1ac3d32e8d08d3a01e96f0cd8 +size 1030 diff --git a/plugins/_kokoro_tts/api/synthesize.py b/plugins/_kokoro_tts/api/synthesize.py new file mode 100644 index 0000000000000000000000000000000000000000..18e8b0912f38d96c088f64ef06b8d02e4b2e2976 --- /dev/null +++ b/plugins/_kokoro_tts/api/synthesize.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:413a946e8b8196b6af59ee6204ce32d2e03c53ad1e82f36a6bebdccf08eae0a4 +size 785 diff --git a/plugins/_kokoro_tts/default_config.yaml b/plugins/_kokoro_tts/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..99ff0f9c516ffd8220042f804bc2a482f86b7e09 --- /dev/null +++ b/plugins/_kokoro_tts/default_config.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfc3c9c8beb52a787c288e19432dcc72d7b863696957664b6bb652f87e074ddb +size 52 diff --git a/plugins/_kokoro_tts/extensions/webui/page-head/runtime.html b/plugins/_kokoro_tts/extensions/webui/page-head/runtime.html new file mode 100644 index 0000000000000000000000000000000000000000..04539af4d20d72f337363830e2931fe1f378ff7f --- /dev/null +++ b/plugins/_kokoro_tts/extensions/webui/page-head/runtime.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60d1281765fb3c74f265fd80c6bf05fce97ffcff8d11867576fe6cf3639660cd +size 131 diff --git a/plugins/_kokoro_tts/extensions/webui/voice-settings-main/kokoro-card.html b/plugins/_kokoro_tts/extensions/webui/voice-settings-main/kokoro-card.html new file mode 100644 index 0000000000000000000000000000000000000000..18fb292aeb16ae38424ca6719b13cabe29ae071e --- /dev/null +++ b/plugins/_kokoro_tts/extensions/webui/voice-settings-main/kokoro-card.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff50d58f50fac389129419419806f29ad701775db537e210c5ce37a219760ddf +size 2784 diff --git a/plugins/_kokoro_tts/helpers/__init__.py b/plugins/_kokoro_tts/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5e87bc456ad3b681b681d3d3f46ff37471992495 --- /dev/null +++ b/plugins/_kokoro_tts/helpers/__init__.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d0648b8b26acb9ac97cbd473b12e8be9eb2522838bc342b5fe7e7e61b3ee6d7 +size 45 diff --git a/plugins/_kokoro_tts/helpers/migration.py b/plugins/_kokoro_tts/helpers/migration.py new file mode 100644 index 0000000000000000000000000000000000000000..bc1b04e0cee1c400143b385f9e452b47d2f3eb78 --- /dev/null +++ b/plugins/_kokoro_tts/helpers/migration.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3261f16118167129cb2cfac22a1b105e4dec7c4c3cfbac7e2de1f08fa32df7de +size 1516 diff --git a/plugins/_kokoro_tts/helpers/runtime.py b/plugins/_kokoro_tts/helpers/runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..4a95bd237a3d50a6642a734c71dbf8fe199f1498 --- /dev/null +++ b/plugins/_kokoro_tts/helpers/runtime.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b9eb80ffb2c0b12c85683fe11c46fd3508906619005af59e937dec81c1fd5b2 +size 5299 diff --git a/plugins/_kokoro_tts/hooks.py b/plugins/_kokoro_tts/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..c381a63fa12842d84befcc094be79ffb314c8d62 --- /dev/null +++ b/plugins/_kokoro_tts/hooks.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1927d6fb02db0ff2415b3b2b7d109c4d7b710d28dfae70af88aed13c21aa26b4 +size 355 diff --git a/plugins/_kokoro_tts/plugin.yaml b/plugins/_kokoro_tts/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b2b97d4062d4e56f6fe446e4a3fa1e7cb6ab2fa7 --- /dev/null +++ b/plugins/_kokoro_tts/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78d17944b691d16c710028474bfa97e03748b0c79d6867fd0db900661f633c23 +size 254 diff --git a/plugins/_kokoro_tts/webui/config.html b/plugins/_kokoro_tts/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..02ce96ca834df6c809bac0cef2825555aca28d68 --- /dev/null +++ b/plugins/_kokoro_tts/webui/config.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4f5e3f78cff0d172a091e377b66ef0152733960fce91673373f96542ed4867e +size 11297 diff --git a/plugins/_kokoro_tts/webui/kokoro-tts-store.js b/plugins/_kokoro_tts/webui/kokoro-tts-store.js new file mode 100644 index 0000000000000000000000000000000000000000..1298b661eed5b267c8da4b91bbac495d42103666 --- /dev/null +++ b/plugins/_kokoro_tts/webui/kokoro-tts-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d52d1634bd7d05863c30f3d2e2a9d3f2c36da7f8e0fd646d2ae464a69b0d674 +size 3613 diff --git a/plugins/_kokoro_tts/webui/main.html b/plugins/_kokoro_tts/webui/main.html new file mode 100644 index 0000000000000000000000000000000000000000..d222559f6048cac3c53fdb0de7119dd59ef51c56 --- /dev/null +++ b/plugins/_kokoro_tts/webui/main.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71f2da3268316e8e627a336d2ca4ccdf437b25c0098ef324d318ab8cd5969913 +size 3722 diff --git a/plugins/_kokoro_tts/webui/thumbnail.jpg b/plugins/_kokoro_tts/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6677ce8750ba538918895dd2354bbeb87089fd77 --- /dev/null +++ b/plugins/_kokoro_tts/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3e524a946b10ac5b01a0d60c8584cba2691ae179687a0d31d530045447a9f17 +size 7825 diff --git a/plugins/_memory/AGENTS.md b/plugins/_memory/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..be4e6c0e08f6fe9f1c94b7e585e9f2ce14b6dfad --- /dev/null +++ b/plugins/_memory/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:608ddaa1d424377f837aa8d90a5848ad3e9129c813a298bd956a0c07a33e5160 +size 1805 diff --git a/plugins/_memory/README.md b/plugins/_memory/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2907c401e0157a94e0bc2e18eac24bd6e2e61545 --- /dev/null +++ b/plugins/_memory/README.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dc9e78f0cb4183b9d3b8e512f4016e53f40492b57f08cc0a8ccf510b38cc083 +size 2161 diff --git a/plugins/_memory/api/import_knowledge.py b/plugins/_memory/api/import_knowledge.py new file mode 100644 index 0000000000000000000000000000000000000000..8687e563cde43c0aacd55ad071a3578d8ea49064 --- /dev/null +++ b/plugins/_memory/api/import_knowledge.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58bc49e42b81b459ab4884919f937ee98f3fc403a8615dcac8ac1a5b1b98cebc +size 1784 diff --git a/plugins/_memory/api/knowledge_path_get.py b/plugins/_memory/api/knowledge_path_get.py new file mode 100644 index 0000000000000000000000000000000000000000..91e1932944e8291a780a0d31d15cbd496e05ee75 --- /dev/null +++ b/plugins/_memory/api/knowledge_path_get.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df161aed45d64ef52c96f28d4c8291d707a4a014a524ccf61f2b768c95755757 +size 868 diff --git a/plugins/_memory/api/knowledge_reindex.py b/plugins/_memory/api/knowledge_reindex.py new file mode 100644 index 0000000000000000000000000000000000000000..22d1a8222a1fc5a882336def3f2d9b7c50a69119 --- /dev/null +++ b/plugins/_memory/api/knowledge_reindex.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:021a821b130f417fbee031e023725af5eae52e1363d6672e2664de2a394ff823 +size 612 diff --git a/plugins/_memory/api/memory_dashboard.py b/plugins/_memory/api/memory_dashboard.py new file mode 100644 index 0000000000000000000000000000000000000000..1548dbeb8470f6fb791df9349d5453b4c80ad5a4 --- /dev/null +++ b/plugins/_memory/api/memory_dashboard.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c5a004c91ef4b378f2d4faa0dc02c04c1c398f68a6814197e916449e58c1e75 +size 11351 diff --git a/plugins/_memory/default_config.yaml b/plugins/_memory/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dc356677099d14fac3b50fbf1b00fb95e5d46635 --- /dev/null +++ b/plugins/_memory/default_config.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1527294627940d51c0f0185f7a7538a83d9158112657b51345a363ecbf213c8 +size 536 diff --git a/plugins/_memory/extensions/python/embedding_model_changed/_10_memory_reload.py b/plugins/_memory/extensions/python/embedding_model_changed/_10_memory_reload.py new file mode 100644 index 0000000000000000000000000000000000000000..37b856106bfd66c708a9c239e1c982a403865338 --- /dev/null +++ b/plugins/_memory/extensions/python/embedding_model_changed/_10_memory_reload.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df23aa5fa9f1f9f168d8e4aa882810345202c61e0da27453064d6dc394113789 +size 269 diff --git a/plugins/_memory/extensions/python/message_loop_prompts_after/_50_recall_memories.py b/plugins/_memory/extensions/python/message_loop_prompts_after/_50_recall_memories.py new file mode 100644 index 0000000000000000000000000000000000000000..596cfc90a26dd4dcee6d6667b4c814a729d9d9c8 --- /dev/null +++ b/plugins/_memory/extensions/python/message_loop_prompts_after/_50_recall_memories.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0d3917699d78e248329cad9ebf9f3b1c7db5fed03305dfa7a77d96a88a34e2e +size 11410 diff --git a/plugins/_memory/extensions/python/message_loop_prompts_after/_91_recall_wait.py b/plugins/_memory/extensions/python/message_loop_prompts_after/_91_recall_wait.py new file mode 100644 index 0000000000000000000000000000000000000000..cde6d81c2a902d22df8d71e616d04b673eff6e16 --- /dev/null +++ b/plugins/_memory/extensions/python/message_loop_prompts_after/_91_recall_wait.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c375c5e5d9acd146f74fe936c8582f8f82f3de69d4f5030a9a511cb793db987 +size 1788 diff --git a/plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py b/plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py new file mode 100644 index 0000000000000000000000000000000000000000..4825dd1bdcf26a91fbd7671860e11e7c13ca72d4 --- /dev/null +++ b/plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c421de9e8daa629e4dc472c1879f307e79ce64068c89b3cb030078f71895350 +size 9630 diff --git a/plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py b/plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py new file mode 100644 index 0000000000000000000000000000000000000000..56a99f89dc6fb42f51d6b3dc861f6663ddcd890e --- /dev/null +++ b/plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44e52d4a4c8e5e13a1f37f46a8d652d634417b0727adfd59b3961c5893743726 +size 9582 diff --git a/plugins/_memory/extensions/python/monologue_start/_10_memory_init.py b/plugins/_memory/extensions/python/monologue_start/_10_memory_init.py new file mode 100644 index 0000000000000000000000000000000000000000..9fdc5ff45f76edc8073d3224e1f53a8033af02fc --- /dev/null +++ b/plugins/_memory/extensions/python/monologue_start/_10_memory_init.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8592f7f513d444d11277827c42fe8a6d80b385e613b2cc2fb6ad3221627f509e +size 376 diff --git a/plugins/_memory/extensions/python/system_prompt/_20_behaviour_prompt.py b/plugins/_memory/extensions/python/system_prompt/_20_behaviour_prompt.py new file mode 100644 index 0000000000000000000000000000000000000000..d826f18d3167bdca75439f7eef099c8f318b792a --- /dev/null +++ b/plugins/_memory/extensions/python/system_prompt/_20_behaviour_prompt.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23556e464d4bbd6dbc31cebc60514161fb8d20461abe94eaac6e25a4137146b0 +size 986 diff --git a/plugins/_memory/extensions/webui/_sidebar-quick-actions-main-start/memory-entry.html b/plugins/_memory/extensions/webui/_sidebar-quick-actions-main-start/memory-entry.html new file mode 100644 index 0000000000000000000000000000000000000000..4637fd9995b4270ad9cf5f21e92b0a6e807ba3cb --- /dev/null +++ b/plugins/_memory/extensions/webui/_sidebar-quick-actions-main-start/memory-entry.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd30cdd2d4fcd38073ca99cca422540f522d57a77e2dd8bd4ddd4fe532c5bde2 +size 271 diff --git a/plugins/_memory/extensions/webui/sidebar-quick-actions-dropdown-start/memory-entry.html b/plugins/_memory/extensions/webui/sidebar-quick-actions-dropdown-start/memory-entry.html new file mode 100644 index 0000000000000000000000000000000000000000..2332ffb223ed3951db3059eced950476c167ca3e --- /dev/null +++ b/plugins/_memory/extensions/webui/sidebar-quick-actions-dropdown-start/memory-entry.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6f8c5e9b5e05955924bad420ec96536f8d2364493c643e02a3e507289faf2d0 +size 368 diff --git a/plugins/_memory/helpers/knowledge_import.py b/plugins/_memory/helpers/knowledge_import.py new file mode 100644 index 0000000000000000000000000000000000000000..b78194c13b48fb393beeb44b72932d49d3890009 --- /dev/null +++ b/plugins/_memory/helpers/knowledge_import.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:434ceb39861f892ccdff534a0030fd668eec07f2b556e7b8ebfdcad9416e7d1a +size 7768 diff --git a/plugins/_memory/helpers/memory.py b/plugins/_memory/helpers/memory.py new file mode 100644 index 0000000000000000000000000000000000000000..1f278c6e292a83834f529cabdc3c113603be12fe --- /dev/null +++ b/plugins/_memory/helpers/memory.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25daaf04cd781629bf1acfb25c1936d5a40e6364731d6c77d3048d87d164a1a2 +size 26601 diff --git a/plugins/_memory/helpers/memory_consolidation.py b/plugins/_memory/helpers/memory_consolidation.py new file mode 100644 index 0000000000000000000000000000000000000000..a22f2cdc63c33dc680f8006ad8badb764fd0d9c4 --- /dev/null +++ b/plugins/_memory/helpers/memory_consolidation.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3132deeb205b1406f402e4c801f16145ce313ff5edafa2f80104a5450d1e2f7e +size 32995 diff --git a/plugins/_memory/helpers/memory_quality.py b/plugins/_memory/helpers/memory_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..9a788a34207583119016e71f4e77332ce7c42cc5 --- /dev/null +++ b/plugins/_memory/helpers/memory_quality.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0949be3886a9069abb7afa6d243d1c2a4c910e9a973926ac6eaa2c2522e554a +size 3010 diff --git a/plugins/_memory/plugin.yaml b/plugins/_memory/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..29e1eebd7b2cfe569fbc8d587e353a55fc6d73b9 --- /dev/null +++ b/plugins/_memory/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4d5cb5d14cefdb06b708edc5431b8978dcd3bcd5cb63a5bb3f0332f0b9f7099 +size 195 diff --git a/plugins/_memory/prompts/agent.system.memories.md b/plugins/_memory/prompts/agent.system.memories.md new file mode 100644 index 0000000000000000000000000000000000000000..a2da841858583922e4b4ebdc31c6661677602d7f --- /dev/null +++ b/plugins/_memory/prompts/agent.system.memories.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a97fcd580d1fd3ac60681cd60f351713ec0bcfb0ee673a681cc09e3f0b38d4b +size 633 diff --git a/plugins/_memory/prompts/agent.system.solutions.md b/plugins/_memory/prompts/agent.system.solutions.md new file mode 100644 index 0000000000000000000000000000000000000000..85b58774443d2fecfbdb5d1af4cb34c17f668e35 --- /dev/null +++ b/plugins/_memory/prompts/agent.system.solutions.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:559693738641a0112c670fee3db9252b5bfb3ad35c1990a1daf450949859c3f8 +size 168 diff --git a/plugins/_memory/prompts/agent.system.tool.behaviour.md b/plugins/_memory/prompts/agent.system.tool.behaviour.md new file mode 100644 index 0000000000000000000000000000000000000000..6648ac8c5b9c981f49f6d7203243904cabd40758 --- /dev/null +++ b/plugins/_memory/prompts/agent.system.tool.behaviour.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e05cef3866fded6e5a6538c91fa44cf0035fcd2ba3db7d5d44f58a12d6ccbf9 +size 482 diff --git a/plugins/_memory/prompts/agent.system.tool.memory.md b/plugins/_memory/prompts/agent.system.tool.memory.md new file mode 100644 index 0000000000000000000000000000000000000000..75cdd4a0beb323fca119407ad05ff8a171b4728b --- /dev/null +++ b/plugins/_memory/prompts/agent.system.tool.memory.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b69f25a44fc9623cc45d8ae9865d9f976757203f484d98df84f827824fb512e8 +size 1606 diff --git a/plugins/_memory/prompts/fw.memory.hist_suc.sys.md b/plugins/_memory/prompts/fw.memory.hist_suc.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..aa336f3830588d15c633cd9c57ea315d18b828e8 --- /dev/null +++ b/plugins/_memory/prompts/fw.memory.hist_suc.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:076f93cddbd5055167eb782b50dd6b8b3986e20857898fb63160d315c63bfeb6 +size 1468 diff --git a/plugins/_memory/prompts/fw.memory.hist_sum.sys.md b/plugins/_memory/prompts/fw.memory.hist_sum.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..c4575c6fa6b09aff6d905ae434dc9451ecf1c55f --- /dev/null +++ b/plugins/_memory/prompts/fw.memory.hist_sum.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c40b63c0fb58f9de3b96a4abfa5aa1c3b6b0431611dfca4d94b8f145c9d6339 +size 1019 diff --git a/plugins/_memory/prompts/fw.memory_saved.md b/plugins/_memory/prompts/fw.memory_saved.md new file mode 100644 index 0000000000000000000000000000000000000000..45ae1db77a59a197fa81620520231718bf8c7051 --- /dev/null +++ b/plugins/_memory/prompts/fw.memory_saved.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cab0c7734f4eb0b528df7e5e6939a1d09b49fdfc7950d5108d30339c2e7c08c8 +size 34 diff --git a/plugins/_memory/prompts/memory.consolidation.msg.md b/plugins/_memory/prompts/memory.consolidation.msg.md new file mode 100644 index 0000000000000000000000000000000000000000..8aadc56a9d3d812134cb738737eb4e91b01a872f --- /dev/null +++ b/plugins/_memory/prompts/memory.consolidation.msg.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c407a6acdc0f006959fbd1643f324c7249598a6b967326452e95160dc662a37 +size 282 diff --git a/plugins/_memory/prompts/memory.consolidation.sys.md b/plugins/_memory/prompts/memory.consolidation.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..6f31d58fb5d85b2ab0c782c1d82f327e9bb55858 --- /dev/null +++ b/plugins/_memory/prompts/memory.consolidation.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:246aa0e0c1319847cf632d2a74fe6523d9a909c0742f28146fb8d3fe6a936e0b +size 7486 diff --git a/plugins/_memory/prompts/memory.keyword_extraction.msg.md b/plugins/_memory/prompts/memory.keyword_extraction.msg.md new file mode 100644 index 0000000000000000000000000000000000000000..f9a787945e4ed6df69cf640a6df351765fe006d2 --- /dev/null +++ b/plugins/_memory/prompts/memory.keyword_extraction.msg.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f033449ecf2d2dc20fae2940b354e04d1f402db5b5c025522fa8970c9455574 +size 118 diff --git a/plugins/_memory/prompts/memory.keyword_extraction.sys.md b/plugins/_memory/prompts/memory.keyword_extraction.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..c9553187ce81808d47df5032c17a8fbe44131f8c --- /dev/null +++ b/plugins/_memory/prompts/memory.keyword_extraction.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:218831a1c8a8fc21638283702f8678bb2160a5defb3dfda1f5e961681164e6fb +size 1854 diff --git a/plugins/_memory/prompts/memory.memories_filter.msg.md b/plugins/_memory/prompts/memory.memories_filter.msg.md new file mode 100644 index 0000000000000000000000000000000000000000..9820e38db1e6c65546d3a316c8ecf5cd8483e266 --- /dev/null +++ b/plugins/_memory/prompts/memory.memories_filter.msg.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89a1676564375becddbc0c8464fe49e7569a224e4d90420ca746af3b87254de7 +size 211 diff --git a/plugins/_memory/prompts/memory.memories_filter.sys.md b/plugins/_memory/prompts/memory.memories_filter.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..cd5c01438128605955cb8a61365ccc9fb950d557 --- /dev/null +++ b/plugins/_memory/prompts/memory.memories_filter.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64ccc669b830072e0a1ac9583fba68b550c95fb4b6f9e3c659cc135e4ad46814 +size 2031 diff --git a/plugins/_memory/prompts/memory.memories_query.msg.md b/plugins/_memory/prompts/memory.memories_query.msg.md new file mode 100644 index 0000000000000000000000000000000000000000..b003a0609680d348929d37d47cdb5a463163956a --- /dev/null +++ b/plugins/_memory/prompts/memory.memories_query.msg.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e946881b0853ff0fa10b67743ee769618b01e7cf495417abd4a7b8a173b0b58d +size 122 diff --git a/plugins/_memory/prompts/memory.memories_query.sys.md b/plugins/_memory/prompts/memory.memories_query.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..13499f70c42718ecc0d3f77b6b1cc2605e0a1f93 --- /dev/null +++ b/plugins/_memory/prompts/memory.memories_query.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c920bf29d1b70e706ce80cf44cbfa98f5305c554511a106c40ec03c96af75fb4 +size 903 diff --git a/plugins/_memory/prompts/memory.memories_sum.sys.md b/plugins/_memory/prompts/memory.memories_sum.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..831a0c813e5b0d2467786d6cd31a468f89995035 --- /dev/null +++ b/plugins/_memory/prompts/memory.memories_sum.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1575c22d2cfed416517362cfc3508d6f3c4a6aa9646f3dc9e17c91d55ec5f17a +size 4631 diff --git a/plugins/_memory/prompts/memory.recall_delay_msg.md b/plugins/_memory/prompts/memory.recall_delay_msg.md new file mode 100644 index 0000000000000000000000000000000000000000..43bcf0da5d4190f7ff1bc4ffc092b76b68eafa51 --- /dev/null +++ b/plugins/_memory/prompts/memory.recall_delay_msg.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e40822eed7d3b9b42a7ecbbeef668e04b98f33b678542fa9f7e7fbb12fee0a44 +size 150 diff --git a/plugins/_memory/prompts/memory.solutions_query.sys.md b/plugins/_memory/prompts/memory.solutions_query.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..0a792968420ee356a445a5686f07d2cc04151f82 --- /dev/null +++ b/plugins/_memory/prompts/memory.solutions_query.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f4c169e9383bb6c58e625f2ca8271007058fe593ea0a2273069d813d5f917bf +size 594 diff --git a/plugins/_memory/prompts/memory.solutions_sum.sys.md b/plugins/_memory/prompts/memory.solutions_sum.sys.md new file mode 100644 index 0000000000000000000000000000000000000000..1830bd41eda981271c350f529b288486aba5b0cf --- /dev/null +++ b/plugins/_memory/prompts/memory.solutions_sum.sys.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50ab277dcdf410f6e8c88940eec953f2ac342962b1c011b9e2aedba5cfe667bf +size 2329 diff --git a/plugins/_memory/tools/behaviour_adjustment.py b/plugins/_memory/tools/behaviour_adjustment.py new file mode 100644 index 0000000000000000000000000000000000000000..3463e1db81634e3c594bc7f63f8a88ae2ca6424a --- /dev/null +++ b/plugins/_memory/tools/behaviour_adjustment.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a60b29f83d614ccfd3688bef16755fc40cfb4a24ecb1f4e52639331406cd1df5 +size 3070 diff --git a/plugins/_memory/tools/memory_delete.py b/plugins/_memory/tools/memory_delete.py new file mode 100644 index 0000000000000000000000000000000000000000..0ff8798c0728edd46098162eb9941518424b5b03 --- /dev/null +++ b/plugins/_memory/tools/memory_delete.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5399f6e6c750b7c357a657c5ab719f5a7ab74367644bab401c70acf47acfca89 +size 495 diff --git a/plugins/_memory/tools/memory_forget.py b/plugins/_memory/tools/memory_forget.py new file mode 100644 index 0000000000000000000000000000000000000000..d1a31c6e4a6e82ae1bcd8ff311a94a44deb85285 --- /dev/null +++ b/plugins/_memory/tools/memory_forget.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61ee32d36ce30adcecf383c64d327ad77e6908547e52c50b1f6d20f5c7911e38 +size 668 diff --git a/plugins/_memory/tools/memory_load.py b/plugins/_memory/tools/memory_load.py new file mode 100644 index 0000000000000000000000000000000000000000..af2513b92209ac418a38e8c29b3d2bb4e85b091b --- /dev/null +++ b/plugins/_memory/tools/memory_load.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f7bfc11a32098f2835f7e30603d65e1d110b94636172381d01b575454fc3ca3 +size 938 diff --git a/plugins/_memory/tools/memory_save.py b/plugins/_memory/tools/memory_save.py new file mode 100644 index 0000000000000000000000000000000000000000..36d7585bf8b462239564a14127151ccdac3af40a --- /dev/null +++ b/plugins/_memory/tools/memory_save.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63f9480aa302bd410cc162c2636fa8321eaad5c0fa827b7cdd8900bd6b083ee0 +size 512 diff --git a/plugins/_memory/webui/config.html b/plugins/_memory/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..0c360f24558face766271f2974ba459e52ee1efb --- /dev/null +++ b/plugins/_memory/webui/config.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3c0ea30af6a202c0347ff17ab8fd43901f1d79344ceabe7fca1abf069b8f47d +size 13121 diff --git a/plugins/_memory/webui/main.html b/plugins/_memory/webui/main.html new file mode 100644 index 0000000000000000000000000000000000000000..faeca9cd667bbf2859e624e592224f4c0eae1c49 --- /dev/null +++ b/plugins/_memory/webui/main.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:493a51eae98b05f34d3bc6b86fc99075c96bbd1f95e14180136e4881c3df8728 +size 292 diff --git a/plugins/_memory/webui/memory-dashboard-store.js b/plugins/_memory/webui/memory-dashboard-store.js new file mode 100644 index 0000000000000000000000000000000000000000..7e59e85fc02024f28928d6dfb63acdd68a2711a5 --- /dev/null +++ b/plugins/_memory/webui/memory-dashboard-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c70082ca1c9c611b05c15ef8c73640305c800895f7fc0b84d470743d916725ef +size 20483 diff --git a/plugins/_memory/webui/memory-dashboard.html b/plugins/_memory/webui/memory-dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..20f198642186984c58a6b7c3732b531ca14a1b43 --- /dev/null +++ b/plugins/_memory/webui/memory-dashboard.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:932b28bb892a4c15f579ae65cdf1da7bb3b8a217ed1bd8cb03cf78c09d18d000 +size 31343 diff --git a/plugins/_memory/webui/memory-detail-modal.html b/plugins/_memory/webui/memory-detail-modal.html new file mode 100644 index 0000000000000000000000000000000000000000..1e943d953a74021e29b589d32f042f5465f7017f --- /dev/null +++ b/plugins/_memory/webui/memory-detail-modal.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5601b992bc581372a7e8caae11ad485054a57453487660aeca0a12acd6b2a823 +size 16720 diff --git a/plugins/_memory/webui/thumbnail.jpg b/plugins/_memory/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f4042dff26b4fe6d0e1b5a7530124badb4891876 --- /dev/null +++ b/plugins/_memory/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68482da4322148bbede71a9ba5386bc86389ad5411b815ab0d90291a0ced318a +size 19214 diff --git a/plugins/_migrate_agents/AGENTS.md b/plugins/_migrate_agents/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..c1252fa3f2e36ca309a9965a0e7dee8ef0956e5d --- /dev/null +++ b/plugins/_migrate_agents/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee74c483599eb0b14ca70dd684fd524a6e7a9c9c24f6dc9aa25dfe33b7b228f3 +size 1759 diff --git a/plugins/_migrate_agents/README.md b/plugins/_migrate_agents/README.md new file mode 100644 index 0000000000000000000000000000000000000000..75a8719abfe1742a105119b74af78427cd771f7a --- /dev/null +++ b/plugins/_migrate_agents/README.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5edfd3ba2948dc0cde0f71ff65c6ac1eb09b691ab4f9bfa73e8c977dd10c4c52 +size 3711 diff --git a/plugins/_migrate_agents/api/migration_import.py b/plugins/_migrate_agents/api/migration_import.py new file mode 100644 index 0000000000000000000000000000000000000000..ecff8e6cdf9f335c492c3857f83562d02885a87e --- /dev/null +++ b/plugins/_migrate_agents/api/migration_import.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63b5fde3b36a7fb94b1ca15087bf1cac0027b9cc997d6f055398f61ae469ec54 +size 6942 diff --git a/plugins/_migrate_agents/api/migration_import.py.dox.md b/plugins/_migrate_agents/api/migration_import.py.dox.md new file mode 100644 index 0000000000000000000000000000000000000000..c18b05df7340f47f5210cb175ce019a2abd547f8 --- /dev/null +++ b/plugins/_migrate_agents/api/migration_import.py.dox.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d857785cbfc3d5c4f92db6ad9a38aa968378606eadcfa5e284205fe09e7a358f +size 762 diff --git a/plugins/_migrate_agents/api/migration_preview.py b/plugins/_migrate_agents/api/migration_preview.py new file mode 100644 index 0000000000000000000000000000000000000000..3d632e04a7c48890d889e24b2406a5e8ab25c03d --- /dev/null +++ b/plugins/_migrate_agents/api/migration_preview.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de837ed62671dcb4b3ec7d6014f1726130ff0bf2a65805e34df04576d37b1763 +size 1055 diff --git a/plugins/_migrate_agents/api/migration_preview.py.dox.md b/plugins/_migrate_agents/api/migration_preview.py.dox.md new file mode 100644 index 0000000000000000000000000000000000000000..d1a3c9a1eebcaae12a5347cdf7f002d162d51f1d --- /dev/null +++ b/plugins/_migrate_agents/api/migration_preview.py.dox.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0317fff45cbaa4b2c3cc2b6137b0b050960cf2c0c0a157f38b9f158ce9d37e9d +size 551 diff --git a/plugins/_migrate_agents/helpers/__init__.py b/plugins/_migrate_agents/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..380aab8c7e5aa7572c59569a28f2b79c9da7b55f --- /dev/null +++ b/plugins/_migrate_agents/helpers/__init__.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb52c8ecb3f635f8cbed74cc65e2b49ec3b0fb1f63f03889e6a36cc3f4477ff7 +size 41 diff --git a/plugins/_migrate_agents/helpers/migration.py b/plugins/_migrate_agents/helpers/migration.py new file mode 100644 index 0000000000000000000000000000000000000000..3b908ae1a714d555969301eefc610f1a4f28fe2e --- /dev/null +++ b/plugins/_migrate_agents/helpers/migration.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df7fcac93bd89a161c646184f00ae55977a279581f1953f7d733d9e0daaebd6e +size 34471 diff --git a/plugins/_migrate_agents/plugin.yaml b/plugins/_migrate_agents/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6117f90cfbde78ad24939d2e4af816e5245d61a3 --- /dev/null +++ b/plugins/_migrate_agents/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de0c48563db6fbf3c50b6820a9d9b2155011166544a18e63755a02ece52fc280 +size 299 diff --git a/plugins/_migrate_agents/tests/test_import_helpers.py b/plugins/_migrate_agents/tests/test_import_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..8bd435d10161d18e843acfb5814f710898e80777 --- /dev/null +++ b/plugins/_migrate_agents/tests/test_import_helpers.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b548d849348e05d0e8e7e4639d3a7216f12551efa3d50b4342e0912d40e21ffd +size 1307 diff --git a/plugins/_migrate_agents/tests/test_migration.py b/plugins/_migrate_agents/tests/test_migration.py new file mode 100644 index 0000000000000000000000000000000000000000..93b2f0d650ceada63aa4aea501423bc560a413da --- /dev/null +++ b/plugins/_migrate_agents/tests/test_migration.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69ad0e2cfaf044a5965b336bd35d096bf6a5baa4b3c386177cf337c9b37854fe +size 10056 diff --git a/plugins/_migrate_agents/webui/assets/ATTRIBUTION.md b/plugins/_migrate_agents/webui/assets/ATTRIBUTION.md new file mode 100644 index 0000000000000000000000000000000000000000..8112607f670a3788a6c2819ec9b9ef1a5a0abdc8 --- /dev/null +++ b/plugins/_migrate_agents/webui/assets/ATTRIBUTION.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44dd6e228284a0fe5652dbd2125e21692aed3b33a09d4aca125e55cc385b66c6 +size 350 diff --git a/plugins/_migrate_agents/webui/assets/claude.svg b/plugins/_migrate_agents/webui/assets/claude.svg new file mode 100644 index 0000000000000000000000000000000000000000..5d0fbce5f33001a1a53d7cfc36c12a3adca528d5 --- /dev/null +++ b/plugins/_migrate_agents/webui/assets/claude.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c156209ad5008df828d3b17f8be508dae6f962bebe09a77abcb522f5f60f4f2f +size 425 diff --git a/plugins/_migrate_agents/webui/assets/codex.svg b/plugins/_migrate_agents/webui/assets/codex.svg new file mode 100644 index 0000000000000000000000000000000000000000..b86e189a4dd7e7a1390adf044d3d55a8c79ff5e3 --- /dev/null +++ b/plugins/_migrate_agents/webui/assets/codex.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dd98226ffe0bdf982325b6c1558846569c44722e116a7eb3921199c58ee5869 +size 1770 diff --git a/plugins/_migrate_agents/webui/assets/hermes.svg b/plugins/_migrate_agents/webui/assets/hermes.svg new file mode 100644 index 0000000000000000000000000000000000000000..b769d0a4c8ea7e564a79f2e331db436fd38045f8 --- /dev/null +++ b/plugins/_migrate_agents/webui/assets/hermes.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25c074d19ca9390d7135ea1db07b7bd801e8f1c6fca29bfee39ceab2355211b2 +size 19787 diff --git a/plugins/_migrate_agents/webui/assets/openclaw.svg b/plugins/_migrate_agents/webui/assets/openclaw.svg new file mode 100644 index 0000000000000000000000000000000000000000..50a3ad7e9060f51165d883de981e2aa3925973ec --- /dev/null +++ b/plugins/_migrate_agents/webui/assets/openclaw.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1306bfe679579f395e2639ea931e490ecf1383896c8702f6ce383e89eb0715d3 +size 2097 diff --git a/plugins/_migrate_agents/webui/assets/opencode.svg b/plugins/_migrate_agents/webui/assets/opencode.svg new file mode 100644 index 0000000000000000000000000000000000000000..1b922617144c0ed97de5dd7376ccf9e9d44e6723 --- /dev/null +++ b/plugins/_migrate_agents/webui/assets/opencode.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3a0c0250a98228067b8a6d2e82a4a098473835b9631d30b48853f3e7aaf3551 +size 236 diff --git a/plugins/_migrate_agents/webui/main.html b/plugins/_migrate_agents/webui/main.html new file mode 100644 index 0000000000000000000000000000000000000000..321a0dde3dddce6c0e22da3c7832708f25ae4d71 --- /dev/null +++ b/plugins/_migrate_agents/webui/main.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2e3f392536ede794561b9a98caf7a1bde787cd8f9a857bb778d0a43b8588114 +size 25485 diff --git a/plugins/_migrate_agents/webui/migrate-agents-store.js b/plugins/_migrate_agents/webui/migrate-agents-store.js new file mode 100644 index 0000000000000000000000000000000000000000..b1b88b8ac9b9d2f1fa95be4f0429d1edb388a5e3 --- /dev/null +++ b/plugins/_migrate_agents/webui/migrate-agents-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45487da85277236bf0df8dc36364c927bc9dd82f15c4495d78c182f4f9bac570 +size 9902 diff --git a/plugins/_migrate_agents/webui/thumbnail.webp b/plugins/_migrate_agents/webui/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..dafd2de8fd01d934444eb2413113254bd374971d --- /dev/null +++ b/plugins/_migrate_agents/webui/thumbnail.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4594208f659ad0eefa229ebc2c775dd2a70fe6db68d286e3f0645b204b1f810f +size 3738 diff --git a/plugins/_model_config/AGENTS.md b/plugins/_model_config/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..357e05f5936d434ad2c5141ac630ad97a8f774a5 --- /dev/null +++ b/plugins/_model_config/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57fd7f98245caff5132a43b425c76801736912d69f443900246f4f5500a9da25 +size 6187 diff --git a/plugins/_model_config/README.md b/plugins/_model_config/README.md new file mode 100644 index 0000000000000000000000000000000000000000..26e7c700504281d423750c4147dba3076648774c --- /dev/null +++ b/plugins/_model_config/README.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c746752df26eacc05eccebd575513d7911568f8889e62a65c056d415df02322a +size 3975 diff --git a/plugins/_model_config/api/api_keys.py b/plugins/_model_config/api/api_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..0dd768424e1346feeadf2593e4069c424b19b570 --- /dev/null +++ b/plugins/_model_config/api/api_keys.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad1694bc686d9ec6300d9a9c160f162b67f7a9b46c0584829850db31266c4a72 +size 2102 diff --git a/plugins/_model_config/api/model_config_get.py b/plugins/_model_config/api/model_config_get.py new file mode 100644 index 0000000000000000000000000000000000000000..e9250c2ae1e0bfdbef8d5e9e881e6719d6fcf12f --- /dev/null +++ b/plugins/_model_config/api/model_config_get.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46bbdfcc6d3072e3d7dcad34ff73967343237f404920abdd2d42a58eebba6475 +size 3029 diff --git a/plugins/_model_config/api/model_config_set.py b/plugins/_model_config/api/model_config_set.py new file mode 100644 index 0000000000000000000000000000000000000000..d06881e6bde3fc89a8ebf047aac887be4938fc62 --- /dev/null +++ b/plugins/_model_config/api/model_config_set.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c28ca5ee0f7eba05406c1c504061988567ecd3375d2223e1c35263abcc57f93 +size 3380 diff --git a/plugins/_model_config/api/model_override.py b/plugins/_model_config/api/model_override.py new file mode 100644 index 0000000000000000000000000000000000000000..0ae0514490c7c4831ed795fa84b88df3f4172a19 --- /dev/null +++ b/plugins/_model_config/api/model_override.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc55bcd44970da39561cf91fd81cc6fff930e4ee2f284af33c88126113c6412a +size 4523 diff --git a/plugins/_model_config/api/model_presets.py b/plugins/_model_config/api/model_presets.py new file mode 100644 index 0000000000000000000000000000000000000000..188f256ba0dbeba420c95f239de7ee5a0c4b7266 --- /dev/null +++ b/plugins/_model_config/api/model_presets.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ce1b8cf01bf186ebba38a2f945b7a0469117b8a139aa218e95aa7480fbc78c9 +size 10939 diff --git a/plugins/_model_config/api/model_search.py b/plugins/_model_config/api/model_search.py new file mode 100644 index 0000000000000000000000000000000000000000..afdfdec18e1778a96908671dbfa020d5e300191e --- /dev/null +++ b/plugins/_model_config/api/model_search.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c7922bd8179191b6a91ff06a97d1949bd83dad3f5d29a1b2142f32393d0a67e +size 10270 diff --git a/plugins/_model_config/default_config.yaml b/plugins/_model_config/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..539f87b22dc4823138cad697dd31e751e1667525 --- /dev/null +++ b/plugins/_model_config/default_config.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0aacc1daf7a2764ad56e37f659dc198a022567a4ef2ca915fdc5794ae645f573 +size 24 diff --git a/plugins/_model_config/extensions/python/_functions/agent/Agent/get_chat_model/start/_10_model_config.py b/plugins/_model_config/extensions/python/_functions/agent/Agent/get_chat_model/start/_10_model_config.py new file mode 100644 index 0000000000000000000000000000000000000000..c643af8d08e8f8f504ff370283727eb0ca6c2d7f --- /dev/null +++ b/plugins/_model_config/extensions/python/_functions/agent/Agent/get_chat_model/start/_10_model_config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fe0213d3ca2fd0b7a79cacedf4a2fed44d74750a42e817cb2df1581e5247982 +size 281 diff --git a/plugins/_model_config/extensions/python/_functions/agent/Agent/get_embedding_model/start/_10_model_config.py b/plugins/_model_config/extensions/python/_functions/agent/Agent/get_embedding_model/start/_10_model_config.py new file mode 100644 index 0000000000000000000000000000000000000000..620b0d8f7a156ad1d01071ba0edff6094b2f47c7 --- /dev/null +++ b/plugins/_model_config/extensions/python/_functions/agent/Agent/get_embedding_model/start/_10_model_config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd5311c04ab3ea688769c6a7fc9ffa752d711ecc8bb416b89b9643c96abb97b8 +size 296 diff --git a/plugins/_model_config/extensions/python/_functions/agent/Agent/get_utility_model/start/_10_model_config.py b/plugins/_model_config/extensions/python/_functions/agent/Agent/get_utility_model/start/_10_model_config.py new file mode 100644 index 0000000000000000000000000000000000000000..256ff54c2f46ce126a723ffae8694db641bed39d --- /dev/null +++ b/plugins/_model_config/extensions/python/_functions/agent/Agent/get_utility_model/start/_10_model_config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c12e6eaf61466a36ddd37290e82ac28cc3ca7561bbe1d2d21a8cfa06f40c62f +size 290 diff --git a/plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py b/plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py new file mode 100644 index 0000000000000000000000000000000000000000..7931936add474e344730e0671405512a4189afa6 --- /dev/null +++ b/plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:807d6b94fda028a120f580f3ba3af3fe3cc0115a346a5fe4fb7adde20a6a7aff +size 531 diff --git a/plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py b/plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py new file mode 100644 index 0000000000000000000000000000000000000000..7089e3a6ac6b336d74ec93942f737d81a9a10815 --- /dev/null +++ b/plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9761ebc322c8735e7cd5b49e43f2fccb1163c54453bbe023bc3d60f72a77fd87 +size 600 diff --git a/plugins/_model_config/extensions/python/banners/_20_missing_api_key.py b/plugins/_model_config/extensions/python/banners/_20_missing_api_key.py new file mode 100644 index 0000000000000000000000000000000000000000..c90f15b33aceed44cbd23ff161a5b51b547ce3fa --- /dev/null +++ b/plugins/_model_config/extensions/python/banners/_20_missing_api_key.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15ca0a99dacc719bf9ca0853441a24d42e86a7a8eb31bbb87436c4b3eb76be01 +size 1253 diff --git a/plugins/_model_config/extensions/python/startup_migration/_10_migrate_model_config.py b/plugins/_model_config/extensions/python/startup_migration/_10_migrate_model_config.py new file mode 100644 index 0000000000000000000000000000000000000000..0fb2a137a5feb1c7abf64032cee991f958865f07 --- /dev/null +++ b/plugins/_model_config/extensions/python/startup_migration/_10_migrate_model_config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3076c32c2c2a96aa9823918c4a48f4e626e597900e7aee177ac23846fa6a4c4 +size 16248 diff --git a/plugins/_model_config/extensions/python/startup_migration/_20_bootstrap_model_presets.py b/plugins/_model_config/extensions/python/startup_migration/_20_bootstrap_model_presets.py new file mode 100644 index 0000000000000000000000000000000000000000..b62f67f698002447ed64e7767a0b18ed4ea55980 --- /dev/null +++ b/plugins/_model_config/extensions/python/startup_migration/_20_bootstrap_model_presets.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9967091149b68564cdf90c989b4b91ee7561b30304472854bad956d9e8a6ef9d +size 2603 diff --git a/plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js b/plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js new file mode 100644 index 0000000000000000000000000000000000000000..498ad2ded33dced5c5d73d9a7b6d37caf582c11f --- /dev/null +++ b/plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da7aad69e2abe0f9072712e132354aaa5360d86a36b5f41b6275a2311e35099c +size 1381 diff --git a/plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html b/plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html new file mode 100644 index 0000000000000000000000000000000000000000..b360b3193154209ec0e405b420b5f32906180586 --- /dev/null +++ b/plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbf59758ae61715e9f9aa7aad03169b67062c6385cb629d9d7b0f4ab431a2616 +size 17258 diff --git a/plugins/_model_config/helpers/model_config.py b/plugins/_model_config/helpers/model_config.py new file mode 100644 index 0000000000000000000000000000000000000000..4c6397d9032feda01c230312beada5625dbb72f2 --- /dev/null +++ b/plugins/_model_config/helpers/model_config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77dcdd34466037d1636250a89eff1610836efbd1ad8be01fcf86953228f87fa2 +size 34539 diff --git a/plugins/_model_config/hooks.py b/plugins/_model_config/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..bb25703bada17e5b37fbacc4c503f9f0daebcc8e --- /dev/null +++ b/plugins/_model_config/hooks.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce77111c199f433032a6eccba1ab4673518bad7341f7609d7282a07814bec071 +size 764 diff --git a/plugins/_model_config/mode_presets_fallback.yaml b/plugins/_model_config/mode_presets_fallback.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a83b30cf279bb5e65d763d3b94cec21b8487aab2 --- /dev/null +++ b/plugins/_model_config/mode_presets_fallback.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cfa85097f68709888a66e7756b4d34d3a7df81c98e46f17ed01e385a7e3ca00 +size 1049 diff --git a/plugins/_model_config/plugin.yaml b/plugins/_model_config/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..408338d57339f124f9ead6ec29a9174dd15447da --- /dev/null +++ b/plugins/_model_config/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff70181c52c4bd8a63eb27c050702357b2b2fd266653c19de914b9c38599bd65 +size 277 diff --git a/plugins/_model_config/provider_metadata.yaml b/plugins/_model_config/provider_metadata.yaml new file mode 100644 index 0000000000000000000000000000000000000000..506ddd312ae05a34de18a52389200d07fdac2576 --- /dev/null +++ b/plugins/_model_config/provider_metadata.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5d37d6a09ad290b543af4c71eb9bd89a9a4d3e2d6580e7d744130df464020ee +size 462 diff --git a/plugins/_model_config/webui/api-keys-mixin.js b/plugins/_model_config/webui/api-keys-mixin.js new file mode 100644 index 0000000000000000000000000000000000000000..e27df843e29e403e4954a7e08824d03e053d3cef --- /dev/null +++ b/plugins/_model_config/webui/api-keys-mixin.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c832f922fdfa2858fb1c1b850179edd60221e58331f115f37f8f7959841b4724 +size 6054 diff --git a/plugins/_model_config/webui/api-keys.html b/plugins/_model_config/webui/api-keys.html new file mode 100644 index 0000000000000000000000000000000000000000..fdd6edb78d1687c68456059d4f13da3d808b1376 --- /dev/null +++ b/plugins/_model_config/webui/api-keys.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6bd057f64aa18bd96aa1e17b27a5b5f7ffdd6521bcd54928f41422291c0eae0 +size 4189 diff --git a/plugins/_model_config/webui/config.html b/plugins/_model_config/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..8175cd5d9f468b9ddd3c90067e020d386f3756e1 --- /dev/null +++ b/plugins/_model_config/webui/config.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67896f77b79bbd73198862f5dd1aa233d23a8ef0406c4e8dde96dcaf83d8f9f6 +size 737 diff --git a/plugins/_model_config/webui/main.html b/plugins/_model_config/webui/main.html new file mode 100644 index 0000000000000000000000000000000000000000..5b1048d24f882ee1c80b5fa80a4c2d0fc4799aec --- /dev/null +++ b/plugins/_model_config/webui/main.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:664977ca9e301ad56e681eaf4357ac2c977c84eca9a6ae4f740e2b3ed99f2feb +size 7919 diff --git a/plugins/_model_config/webui/model-config-store.js b/plugins/_model_config/webui/model-config-store.js new file mode 100644 index 0000000000000000000000000000000000000000..d92308efe9460fa708d93e14aeeda256f2396e77 --- /dev/null +++ b/plugins/_model_config/webui/model-config-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:244f33622e3901a954f0c0975b2108d4509034e95de767b359da812a15b36c31 +size 32461 diff --git a/plugins/_model_config/webui/model-field.html b/plugins/_model_config/webui/model-field.html new file mode 100644 index 0000000000000000000000000000000000000000..256012deab69489e415916cffb4958f187a20402 --- /dev/null +++ b/plugins/_model_config/webui/model-field.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4dc378260202749f405e659e539df9bf0c7a5fd28d254e054739603a1713c79 +size 18075 diff --git a/plugins/_model_config/webui/models-summary.html b/plugins/_model_config/webui/models-summary.html new file mode 100644 index 0000000000000000000000000000000000000000..91e470ecddb1eaea5e2952dc18366d470ae78214 --- /dev/null +++ b/plugins/_model_config/webui/models-summary.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6203767b318d06ce06e9ecf58e835cd01be6a8480e7ae71791e81f82fdb68ec4 +size 1327 diff --git a/plugins/_model_config/webui/preset-overview.html b/plugins/_model_config/webui/preset-overview.html new file mode 100644 index 0000000000000000000000000000000000000000..da286434f73b5c7986ec8211bb130eeb222e33aa --- /dev/null +++ b/plugins/_model_config/webui/preset-overview.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef3b2bc2d1dd10d69e1a88014a09b98177ad1912c988fa1513ea3efd9f891b76 +size 4331 diff --git a/plugins/_model_config/webui/switcher-mixin.js b/plugins/_model_config/webui/switcher-mixin.js new file mode 100644 index 0000000000000000000000000000000000000000..46661d5006aee999761f97f1388dd38f13b07484 --- /dev/null +++ b/plugins/_model_config/webui/switcher-mixin.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b1e555e2e93f8c2398bb2f45961bb8c74b4d139b058590000fe0e0f9751c8cc +size 10471 diff --git a/plugins/_model_config/webui/thumbnail.jpg b/plugins/_model_config/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9abd3b50b86c20da71e652831f83eb2323008593 --- /dev/null +++ b/plugins/_model_config/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a7105c6fa74aa3316c44e9ffb62078aa533e15e5bc7c04041875e289946e98d +size 14020 diff --git a/plugins/_oauth/AGENTS.md b/plugins/_oauth/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..f97838c09c5dc51e19cc81a073a5fe0abf831623 --- /dev/null +++ b/plugins/_oauth/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a23a7412bc08297fa15869d544a2ea1fc4794ded69baac7b5584b7c6b72a99c8 +size 8379 diff --git a/plugins/_oauth/README.md b/plugins/_oauth/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8b145b6ac1d58d7fa45b16757c3ca21307a50521 --- /dev/null +++ b/plugins/_oauth/README.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9680604b06be228f7ae23f0e95a384d9bde9b8afa943f0ad05b851a6341bec79 +size 3174 diff --git a/plugins/_oauth/api/disconnect.py b/plugins/_oauth/api/disconnect.py new file mode 100644 index 0000000000000000000000000000000000000000..60a5e3d2a41af0d84412448a52deda42a4abcc44 --- /dev/null +++ b/plugins/_oauth/api/disconnect.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf407802bb16da7045e97870c576173b963667f81543a64e38f2a5d499710c5b +size 1459 diff --git a/plugins/_oauth/api/manual_callback.py b/plugins/_oauth/api/manual_callback.py new file mode 100644 index 0000000000000000000000000000000000000000..6459e1063c7faa7d515b953a0e6f47baa31933df --- /dev/null +++ b/plugins/_oauth/api/manual_callback.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0df6a1fef7c8c95bc8493578d6db7c7b362c49b5e6890a39f1b3be7484096c98 +size 1068 diff --git a/plugins/_oauth/api/models.py b/plugins/_oauth/api/models.py new file mode 100644 index 0000000000000000000000000000000000000000..1d3d20c694709ae3ef8eb18d9d56939b46c7ca3b --- /dev/null +++ b/plugins/_oauth/api/models.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcf133c786b6e6773deb326827add57acc376a0777119fc4e6f2503c56b9abb6 +size 1744 diff --git a/plugins/_oauth/api/poll_device_login.py b/plugins/_oauth/api/poll_device_login.py new file mode 100644 index 0000000000000000000000000000000000000000..c37f273d36f15a2413c7b0edeb05958116855d4d --- /dev/null +++ b/plugins/_oauth/api/poll_device_login.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8525deb02e894e46cf943306d8f82d77abb9b1c8739d098a064d022dc1b577f +size 1064 diff --git a/plugins/_oauth/api/start_device_login.py b/plugins/_oauth/api/start_device_login.py new file mode 100644 index 0000000000000000000000000000000000000000..8156f699ec1b25f058b855187fc790d799bb126f --- /dev/null +++ b/plugins/_oauth/api/start_device_login.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a902bb8418253273aaaaabeb121735f2c9ffeb513f73118788e412399454a5f5 +size 1066 diff --git a/plugins/_oauth/api/start_login.py b/plugins/_oauth/api/start_login.py new file mode 100644 index 0000000000000000000000000000000000000000..01319ed250af6672c1ca00e1a233dd26889db5bf --- /dev/null +++ b/plugins/_oauth/api/start_login.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87bc83b5e4b0fd9a4482fb60eb084d043cfc6fc032bdce2b4b52a7564c40b4c6 +size 1629 diff --git a/plugins/_oauth/api/status.py b/plugins/_oauth/api/status.py new file mode 100644 index 0000000000000000000000000000000000000000..ec8ff306c2cbc149cf3c17efd84a09cb22f85a4f --- /dev/null +++ b/plugins/_oauth/api/status.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c1ed59fa0ddec339f28b78ae221d7cb15ea7d008d7743d068907de52a668fb3 +size 545 diff --git a/plugins/_oauth/conf/model_providers.yaml b/plugins/_oauth/conf/model_providers.yaml new file mode 100644 index 0000000000000000000000000000000000000000..aaa3d58c2d11577a07210bfa69b27bc25927a5c3 --- /dev/null +++ b/plugins/_oauth/conf/model_providers.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d7558837f14687f39ecaf98b88303f47e7f3c11ec3e048c1bb3f6ef3ea1ebbe +size 984 diff --git a/plugins/_oauth/default_config.yaml b/plugins/_oauth/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a114e86403137c38809cb2efc9caec998da4be48 --- /dev/null +++ b/plugins/_oauth/default_config.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20290ba82d82566683e4f89c395a38a3c58e27310a5d2ec56729b867425d2e22 +size 1585 diff --git a/plugins/_oauth/extensions/python/_functions/models/get_api_key/end/_20_oauth_account_dummy_key.py b/plugins/_oauth/extensions/python/_functions/models/get_api_key/end/_20_oauth_account_dummy_key.py new file mode 100644 index 0000000000000000000000000000000000000000..6b59f204fb0359ddd128fd6c74136eb225e4861e --- /dev/null +++ b/plugins/_oauth/extensions/python/_functions/models/get_api_key/end/_20_oauth_account_dummy_key.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2603e248c7cb73b51ae34ae3cfbac7d233c819beda235306c57ae40dbce40e60 +size 1164 diff --git a/plugins/_oauth/extensions/python/startup_migration/_20_oauth_routes.py b/plugins/_oauth/extensions/python/startup_migration/_20_oauth_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..f11300a9a6932f5d57bb1c60b3dcc3e9a58783b1 --- /dev/null +++ b/plugins/_oauth/extensions/python/startup_migration/_20_oauth_routes.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:074ec39c6b622368a094eb770c3b8934b882f685952ca6b42114a94b49081721 +size 249 diff --git a/plugins/_oauth/helpers/__init__.py b/plugins/_oauth/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..eae26ca88191ea3bdcd224bf772c469473ae4df2 --- /dev/null +++ b/plugins/_oauth/helpers/__init__.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d3435947453d446d0abdf43c442d77767c20ed5594fb4866bceb40d71451f62 +size 40 diff --git a/plugins/_oauth/helpers/codex.py b/plugins/_oauth/helpers/codex.py new file mode 100644 index 0000000000000000000000000000000000000000..04fc3b2aa264b5154f4630e5e3f7ef30aa495aea --- /dev/null +++ b/plugins/_oauth/helpers/codex.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5dbc608cd573d6b71b01875dacea11cab23a7aa87307cb77569dc512226ab7f +size 55373 diff --git a/plugins/_oauth/helpers/config.py b/plugins/_oauth/helpers/config.py new file mode 100644 index 0000000000000000000000000000000000000000..118587a90d00fdd95b7965707d4ab865b3357fef --- /dev/null +++ b/plugins/_oauth/helpers/config.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca714c648533a067d8b26d69f33437eda6cffc9898f295ed6d6647e7836af5f3 +size 5584 diff --git a/plugins/_oauth/helpers/providers/__init__.py b/plugins/_oauth/helpers/providers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7b755b424c08728296c8447224fa1a1d5d4f35b2 --- /dev/null +++ b/plugins/_oauth/helpers/providers/__init__.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3db198180e4d4a44d542c214a7996f6c6d32d6cbc3dac1c6b446021bf22945f2 +size 1061 diff --git a/plugins/_oauth/helpers/providers/base.py b/plugins/_oauth/helpers/providers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..169420014739a506efcab4f8d2da17f3aa9379a2 --- /dev/null +++ b/plugins/_oauth/helpers/providers/base.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:083cac74d73146c074c63bf9e34b634de8b7220e8badd3a517e8931721237f18 +size 5500 diff --git a/plugins/_oauth/helpers/providers/codex.py b/plugins/_oauth/helpers/providers/codex.py new file mode 100644 index 0000000000000000000000000000000000000000..a6f7e6f0fd73e1565c334b8838d8796f561c58b1 --- /dev/null +++ b/plugins/_oauth/helpers/providers/codex.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21cfd25b531dac0d2e0d02fe88e5f73831be628b0e425c6b9dfd005eec6b8233 +size 8601 diff --git a/plugins/_oauth/helpers/providers/common.py b/plugins/_oauth/helpers/providers/common.py new file mode 100644 index 0000000000000000000000000000000000000000..ead050696cd9c7ba23d420128eff9af3e4664b17 --- /dev/null +++ b/plugins/_oauth/helpers/providers/common.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28121f9f5d3461259a08b29bd6c7b292420b881f42d2a6139e79e5c4579a4fb6 +size 3818 diff --git a/plugins/_oauth/helpers/providers/gemini_api.py b/plugins/_oauth/helpers/providers/gemini_api.py new file mode 100644 index 0000000000000000000000000000000000000000..ec20240676e8d8cea9cc7a51c6367e270570de41 --- /dev/null +++ b/plugins/_oauth/helpers/providers/gemini_api.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d92bb5a3985bcfbaf488ff90812890686d0a79e8e349719aff03b78d82282c4 +size 24130 diff --git a/plugins/_oauth/helpers/providers/github_copilot.py b/plugins/_oauth/helpers/providers/github_copilot.py new file mode 100644 index 0000000000000000000000000000000000000000..1b4c502260d0c74176d91420737e0b9c66163c42 --- /dev/null +++ b/plugins/_oauth/helpers/providers/github_copilot.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8bfb3058a395308c96ada0a59fb71e1231467b7251adafebfbdd54df05ce954 +size 23580 diff --git a/plugins/_oauth/helpers/providers/registry.py b/plugins/_oauth/helpers/providers/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..80d6f57d6be4cf8fdaa8c848cc8e4e445b331323 --- /dev/null +++ b/plugins/_oauth/helpers/providers/registry.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:851489ca919b278fe7fa8b9d0fcc8c60a89758d3b711d4349bca297bc589b9f5 +size 1279 diff --git a/plugins/_oauth/helpers/providers/xai_grok.py b/plugins/_oauth/helpers/providers/xai_grok.py new file mode 100644 index 0000000000000000000000000000000000000000..ada5c50ed8f0286ade84f94d3a78ff272eefef07 --- /dev/null +++ b/plugins/_oauth/helpers/providers/xai_grok.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f9533aa27ea15f6e11f9b91619566510b378f97001b77fadc1cc846d431651d +size 19818 diff --git a/plugins/_oauth/helpers/route_bootstrap.py b/plugins/_oauth/helpers/route_bootstrap.py new file mode 100644 index 0000000000000000000000000000000000000000..03f136eb276bb59dac46ddb0d5150fed212902c5 --- /dev/null +++ b/plugins/_oauth/helpers/route_bootstrap.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d45aa07527725b00a8d0ec66f9a610cb0b3c2610caddbe23879e07c061f1f014 +size 804 diff --git a/plugins/_oauth/helpers/routes.py b/plugins/_oauth/helpers/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..a7493664b68d196364e18b7a4ec4856aa31673c2 --- /dev/null +++ b/plugins/_oauth/helpers/routes.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63f652b9c9320695c6061faa2b2059fbef9ff1809e20df6741ef5ab6ad34e7c4 +size 19680 diff --git a/plugins/_oauth/helpers/state.py b/plugins/_oauth/helpers/state.py new file mode 100644 index 0000000000000000000000000000000000000000..32b026e45f7cca55bb17f8a861b4ac47dfee2bf3 --- /dev/null +++ b/plugins/_oauth/helpers/state.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6710aafff96c9ee7bef6ac7b25983fc00db925bacbc398e3690f7562d03e563 +size 3529 diff --git a/plugins/_oauth/helpers/summary.py b/plugins/_oauth/helpers/summary.py new file mode 100644 index 0000000000000000000000000000000000000000..82bab09df282c27fb47ee6b90f4df2e54be51177 --- /dev/null +++ b/plugins/_oauth/helpers/summary.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cc9a4e4dddca28faca88614f63379bffccf4456dd8013fabb788b997458fed0 +size 5094 diff --git a/plugins/_oauth/helpers/usage_plans.py b/plugins/_oauth/helpers/usage_plans.py new file mode 100644 index 0000000000000000000000000000000000000000..9f07e8fe9c9c54d771509ba69542eadc29701183 --- /dev/null +++ b/plugins/_oauth/helpers/usage_plans.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4278e29ec1143a5039f628e507684044e1a7b1ec11c401453fdebaa53f7c5b26 +size 7043 diff --git a/plugins/_oauth/plugin.yaml b/plugins/_oauth/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8ce3180c391969c478e5877f0063525b9a561355 --- /dev/null +++ b/plugins/_oauth/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cab65edadb7cfd4ef74d355bf6d3240749d2c740b55d388a1eeee1f437e7e298 +size 281 diff --git a/plugins/_oauth/webui/config.html b/plugins/_oauth/webui/config.html new file mode 100644 index 0000000000000000000000000000000000000000..4f61ea15eae8736b68e45e880410fc85db054cea --- /dev/null +++ b/plugins/_oauth/webui/config.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbd17089250f4447fd7d27f08bfac0205504f90dde151bf3a9c0581e54b6569c +size 40323 diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js new file mode 100644 index 0000000000000000000000000000000000000000..d059e523921a43aa960c9e2576d6d4e329a11f40 --- /dev/null +++ b/plugins/_oauth/webui/oauth-config-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:955210f42a72ed65f29a0bd0a1d4459dde72ac32ac58ae8049bdb028d6d33de0 +size 39432 diff --git a/plugins/_oauth/webui/thumbnail.jpg b/plugins/_oauth/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..00b00070d0a91662d61ed6e35b6f3cdee15eed10 --- /dev/null +++ b/plugins/_oauth/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:690d588ce0daac0f5ce4625ede5cd860a1e9dc4256971666d5cfe4b26fd4a9cc +size 17334 diff --git a/plugins/_office/AGENTS.md b/plugins/_office/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..94510781070e5e40850461dbcbf744b05e3d1e93 --- /dev/null +++ b/plugins/_office/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2f15611a7704f5c32969c38521e9237ad1d732d88d5f6ac23ab9cc8d96bd1a9 +size 1219 diff --git a/plugins/_office/api/office_session.py b/plugins/_office/api/office_session.py new file mode 100644 index 0000000000000000000000000000000000000000..5d895703cbac18b6e816fd85f55aaed25b86d3ed --- /dev/null +++ b/plugins/_office/api/office_session.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16a7c03559c8c7d1782a9dd15158d3573fbac297309f23cf081f298e230e28ab +size 7607 diff --git a/plugins/_office/api/ws_office.py b/plugins/_office/api/ws_office.py new file mode 100644 index 0000000000000000000000000000000000000000..13d3fad91e755bfde7d08e555444755328bee134 --- /dev/null +++ b/plugins/_office/api/ws_office.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50b6e76865d8a8966cf4fbea378e6d4d0d9f5cb772c8a11187fce6a1a9ed80d3 +size 4020 diff --git a/plugins/_office/extensions/python/message_loop_prompts_after/_55_include_office_canvas_context.py b/plugins/_office/extensions/python/message_loop_prompts_after/_55_include_office_canvas_context.py new file mode 100644 index 0000000000000000000000000000000000000000..dd71f871094dfd7b957cc4c58b4d402f09c6d0af --- /dev/null +++ b/plugins/_office/extensions/python/message_loop_prompts_after/_55_include_office_canvas_context.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17fac4d6ee746d2c3417072a3bee442ff3167e168ffdbe60c105e543c7be4ce5 +size 285 diff --git a/plugins/_office/extensions/python/startup_migration/_20_office_routes.py b/plugins/_office/extensions/python/startup_migration/_20_office_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..6e6451ef2cf810725599f60879bd367c13dc5e9c --- /dev/null +++ b/plugins/_office/extensions/python/startup_migration/_20_office_routes.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a34ee728543f532037509778a95a9eb0ddcd4def2184d29a4364694976b54f37 +size 1589 diff --git a/plugins/_office/extensions/python/webui_ws_disconnect/_50_office.py b/plugins/_office/extensions/python/webui_ws_disconnect/_50_office.py new file mode 100644 index 0000000000000000000000000000000000000000..2257420e7722b0e71922b423cc0c510a13435278 --- /dev/null +++ b/plugins/_office/extensions/python/webui_ws_disconnect/_50_office.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77ed111725862549183e0d6b6331c8f34421f5a499738d9f3f50742a5a6a2da1 +size 588 diff --git a/plugins/_office/extensions/python/webui_ws_event/_50_office.py b/plugins/_office/extensions/python/webui_ws_event/_50_office.py new file mode 100644 index 0000000000000000000000000000000000000000..6b3a7437c03749df8388ca2cc6f2ddf51cb84bac --- /dev/null +++ b/plugins/_office/extensions/python/webui_ws_event/_50_office.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7045ce4f0b741bc358949e536fdede30be44db92d6ab83644bcef408ffd82d0e +size 1471 diff --git a/plugins/_office/extensions/webui/get_tool_message_handler/office-artifact-handler.js b/plugins/_office/extensions/webui/get_tool_message_handler/office-artifact-handler.js new file mode 100644 index 0000000000000000000000000000000000000000..c3296c94b6a48c888006f8efb4d55d01f0624f58 --- /dev/null +++ b/plugins/_office/extensions/webui/get_tool_message_handler/office-artifact-handler.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eabe5a3251bc0a5916ab19ff44c5249b7f4db1d8fbd8df1749c5fd621f297a17 +size 649 diff --git a/plugins/_office/extensions/webui/lib/document-actions.js b/plugins/_office/extensions/webui/lib/document-actions.js new file mode 100644 index 0000000000000000000000000000000000000000..0151eaf05aa9fed4606d0e6e1a3d56a04b942225 --- /dev/null +++ b/plugins/_office/extensions/webui/lib/document-actions.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b8248f443e401c2e6215852866b8bb35313852868ce237c37f2cc26282355fd +size 8277 diff --git a/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js new file mode 100644 index 0000000000000000000000000000000000000000..15cdb58df924f3c26dd4e50943c290ea7e2be7fb --- /dev/null +++ b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d82b22f76846d8b97e9e59e6ef0806d51633ce288bc0ced8a550d076a09cd2eb +size 8429 diff --git a/plugins/_office/extensions/webui/set_messages_after_loop/document-response-file-cards.js b/plugins/_office/extensions/webui/set_messages_after_loop/document-response-file-cards.js new file mode 100644 index 0000000000000000000000000000000000000000..42ff86568f671367b7d2314b64b6d6c554c11d1b --- /dev/null +++ b/plugins/_office/extensions/webui/set_messages_after_loop/document-response-file-cards.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f8a6ca197397bd6b613db315b4d5fe2882c9d90f0d5b7854d4eaaa43ea0d2e4 +size 6111 diff --git a/plugins/_office/helpers/artifact_editor.py b/plugins/_office/helpers/artifact_editor.py new file mode 100644 index 0000000000000000000000000000000000000000..56a9484a0a5c504dc904c1a8bce24ff05899beb4 --- /dev/null +++ b/plugins/_office/helpers/artifact_editor.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64dac78aca6e8e47643edfc6a98179f0e3a82eaec7cc80fc4d31cde124276a4d +size 70731 diff --git a/plugins/_office/helpers/canvas_context.py b/plugins/_office/helpers/canvas_context.py new file mode 100644 index 0000000000000000000000000000000000000000..94be79c7c6fc24451403db934ec4e2cc5a948a84 --- /dev/null +++ b/plugins/_office/helpers/canvas_context.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45cd136cc0ca0903258e56c4954c2605385b56c92f9064474ec76c4cdb5c6608 +size 2114 diff --git a/plugins/_office/helpers/document_store.py b/plugins/_office/helpers/document_store.py new file mode 100644 index 0000000000000000000000000000000000000000..d8f1fd399041499526b6411e2aaeb3bddae514ad --- /dev/null +++ b/plugins/_office/helpers/document_store.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5601f5360ba171be966a9135f44cb77360ac0f5b2aa2fd1626ac0f3b67dba420 +size 41480 diff --git a/plugins/_office/helpers/libreoffice.py b/plugins/_office/helpers/libreoffice.py new file mode 100644 index 0000000000000000000000000000000000000000..70384e3404ea5aa80ce044b57fedeacba9f6cb94 --- /dev/null +++ b/plugins/_office/helpers/libreoffice.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dddda249a80c77c585a801ed50f6165581c7d4f8572d4e9efbe261dbc3c19608 +size 5722 diff --git a/plugins/_office/helpers/pptx_writer.py b/plugins/_office/helpers/pptx_writer.py new file mode 100644 index 0000000000000000000000000000000000000000..dc9206a6e2038a2c806aec168ce19f29f7524657 --- /dev/null +++ b/plugins/_office/helpers/pptx_writer.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0a15ab2b10460c4060195b9570c63106f3c59f09fcf0675a056f83412a73775 +size 8998 diff --git a/plugins/_office/hooks.py b/plugins/_office/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..2f5998e40c56cc45a35beb0ccc9c6bf701aa20f1 --- /dev/null +++ b/plugins/_office/hooks.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:916869590ccc025a8afa34714a171c5dbb50480127b2a036d911987b6bb2956b +size 15630 diff --git a/plugins/_office/plugin.yaml b/plugins/_office/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ef2b890fa872e7df8b0b3ed0954de95607c33246 --- /dev/null +++ b/plugins/_office/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:621be72d902ae532770d312b98e0db31fa8447c0fc7533e042ac901382741c0e +size 242 diff --git a/plugins/_office/prompts/agent.extras.office_canvas.md b/plugins/_office/prompts/agent.extras.office_canvas.md new file mode 100644 index 0000000000000000000000000000000000000000..c861c6d77df6f826d4d145768c57a82c5b1d2638 --- /dev/null +++ b/plugins/_office/prompts/agent.extras.office_canvas.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93236374e2cebd2930e71cf18b40a600e17a5a637e9e4241c6dcb8960335e24d +size 36 diff --git a/plugins/_office/prompts/agent.system.tool.office_artifact.md b/plugins/_office/prompts/agent.system.tool.office_artifact.md new file mode 100644 index 0000000000000000000000000000000000000000..4c06e0329b17941c7d95b3eb434985e68453a91e --- /dev/null +++ b/plugins/_office/prompts/agent.system.tool.office_artifact.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ccf89024fad41a83ee871ef373e660f012c2362991cae0e3efec352692bf8db +size 1862 diff --git a/plugins/_office/skills/calc-spreadsheets/SKILL.md b/plugins/_office/skills/calc-spreadsheets/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..6bcd4e8602efbaad2249a8d764c35a5f84843518 --- /dev/null +++ b/plugins/_office/skills/calc-spreadsheets/SKILL.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f6364679a58dfc8ed1ceeb61db3474edd7bd221c06942535d0df442c334b395 +size 2522 diff --git a/plugins/_office/skills/impress-presentations/SKILL.md b/plugins/_office/skills/impress-presentations/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..503331843b4e2ce722d936a6c741f512a6a5645c --- /dev/null +++ b/plugins/_office/skills/impress-presentations/SKILL.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab8a8289e647e5c3f2634220311dbdcc6cf6ab7252715093657026d90e00505a +size 2158 diff --git a/plugins/_office/skills/markdown-documents/SKILL.md b/plugins/_office/skills/markdown-documents/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..b741caa93f2520ae97605f095540b05bc6d2b66d --- /dev/null +++ b/plugins/_office/skills/markdown-documents/SKILL.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d445a659c2efc15a56b8ec65cb827c0f7977c87761e944c738cec321a4f95dc7 +size 2597 diff --git a/plugins/_office/skills/office-artifacts/SKILL.md b/plugins/_office/skills/office-artifacts/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ed60a4fa6e6b45de8fc38c601b43235fe19f0909 --- /dev/null +++ b/plugins/_office/skills/office-artifacts/SKILL.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdd96a6450014e765edd855e6295fdfcb74d779bf8876b967cd1b2d5a3e7fd5d +size 6965 diff --git a/plugins/_office/skills/writer-documents/SKILL.md b/plugins/_office/skills/writer-documents/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..bef46f33ea6db44ab21e3feea76ed5c2a344062a --- /dev/null +++ b/plugins/_office/skills/writer-documents/SKILL.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44b288832fa1279c9c0fed43d69694b6619bf19def730075eb6dfbb99c74daad +size 2217 diff --git a/plugins/_office/tools/office_artifact.py b/plugins/_office/tools/office_artifact.py new file mode 100644 index 0000000000000000000000000000000000000000..8412e4282a9d6ae285acc35decc0d60a81a229ef --- /dev/null +++ b/plugins/_office/tools/office_artifact.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef4ced542ca1ef061ee6b2ff25545198cced72e4d6c5778cf2d0098c696186f3 +size 13777 diff --git a/plugins/_office/webui/main.html b/plugins/_office/webui/main.html new file mode 100644 index 0000000000000000000000000000000000000000..edda460d70d77b2fc95bfae0071e2fa788502983 --- /dev/null +++ b/plugins/_office/webui/main.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b60e82cdaa3e22bbb7fe13863856c5355bf67952545a8101b99ddb761b67d74 +size 315 diff --git a/plugins/_office/webui/office-panel.html b/plugins/_office/webui/office-panel.html new file mode 100644 index 0000000000000000000000000000000000000000..3f9b2b3ac6d6d5449ff2f2928ea734a4d7c150ea --- /dev/null +++ b/plugins/_office/webui/office-panel.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8b3beba3a2ab6e9d10471932bff165d7e412afee41dcfac76cb0f2a23ffdb4c +size 6097 diff --git a/plugins/_office/webui/office-store.js b/plugins/_office/webui/office-store.js new file mode 100644 index 0000000000000000000000000000000000000000..390ec1efa3838d674120ef833e91e6fe75ce923b --- /dev/null +++ b/plugins/_office/webui/office-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d52db56be7b691d6bfa14816630dda9328aa1e174be8b24110652dc63d545cbd +size 9647 diff --git a/plugins/_office/webui/thumbnail.jpg b/plugins/_office/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fb3337e73b91d3d3e25b5a84a7323ba2703406e1 --- /dev/null +++ b/plugins/_office/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3207de8cbe8f7d5f67d0ed2cc0cb807254a8d6b0c918c35ecd7459196d871fa +size 17143 diff --git a/plugins/_onboarding/AGENTS.md b/plugins/_onboarding/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..148cbc201879723e6e908590c7a966526a084933 --- /dev/null +++ b/plugins/_onboarding/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b9dd084f001051399da9e8cf835435a31d23f549e52451f81bcbe1db7543825 +size 975 diff --git a/plugins/_onboarding/plugin.yaml b/plugins/_onboarding/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a43961ba0bf63eac782467598daa681485ed182b --- /dev/null +++ b/plugins/_onboarding/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f31ff5b67a830edfa82c778c010dd5afb998e61397943ad3d4c75d91ca6054f1 +size 226 diff --git a/plugins/_onboarding/webui/assets/provider-logos/cerebras.svg b/plugins/_onboarding/webui/assets/provider-logos/cerebras.svg new file mode 100644 index 0000000000000000000000000000000000000000..a48ba80c82bb95a19e75a33551e91ab9ae5af47e --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/cerebras.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bce80d246e5481e2aca213ad168214071acdf72d88da3713f66fc3c8c860eabb +size 1251 diff --git a/plugins/_onboarding/webui/assets/provider-logos/cometapi.ico b/plugins/_onboarding/webui/assets/provider-logos/cometapi.ico new file mode 100644 index 0000000000000000000000000000000000000000..7d516ca26c9a9aa8427bac7b4969c8be86ad0821 --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/cometapi.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e10d72ad35e3aa72279fd33ff38466b9aaf24fa425dbc86c4d8056a9cbc7898 +size 1198 diff --git a/plugins/_onboarding/webui/assets/provider-logos/deepseek.svg b/plugins/_onboarding/webui/assets/provider-logos/deepseek.svg new file mode 100644 index 0000000000000000000000000000000000000000..890b8e60ccce8991d5dc058b13d46469c6e775f3 --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/deepseek.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed359ecaf1a92b06b41fa897821c253c2294b0f4ceeebc15846a40adfd651575 +size 3678 diff --git a/plugins/_onboarding/webui/assets/provider-logos/github-copilot.svg b/plugins/_onboarding/webui/assets/provider-logos/github-copilot.svg new file mode 100644 index 0000000000000000000000000000000000000000..187a06388b61defb676cd36fe2b950c3c2d0e9ad --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/github-copilot.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d34b0aa829c23094eafce1447481d56f41dde65d779121222dcf294943dd245 +size 1759 diff --git a/plugins/_onboarding/webui/assets/provider-logos/google-gemini.svg b/plugins/_onboarding/webui/assets/provider-logos/google-gemini.svg new file mode 100644 index 0000000000000000000000000000000000000000..4eff01a04816e1be48b7e70e1253b8d06bd02474 --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/google-gemini.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fbe02a159b1e68a5c0adaf7032dccd3d769a8bf5c77d5a1931f8ce9a9f7b7bb +size 599 diff --git a/plugins/_onboarding/webui/assets/provider-logos/groq.svg b/plugins/_onboarding/webui/assets/provider-logos/groq.svg new file mode 100644 index 0000000000000000000000000000000000000000..10bbb86c41bd1e3fd6b6d7b6dad3804a39ef8694 --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/groq.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6063dbaf06ffc76b35059048e31e24d689aef338a6c037a46c1b8176e50ae1b0 +size 2737 diff --git a/plugins/_onboarding/webui/assets/provider-logos/llama-cpp.svg b/plugins/_onboarding/webui/assets/provider-logos/llama-cpp.svg new file mode 100644 index 0000000000000000000000000000000000000000..0d17df9969c1205e5b36a4669a33c92e4de6ac58 --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/llama-cpp.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebedce9a4bc40acea9edd2dcbae64f38174c0a32658baebef827613df2f4facf +size 1175 diff --git a/plugins/_onboarding/webui/assets/provider-logos/omlx.svg b/plugins/_onboarding/webui/assets/provider-logos/omlx.svg new file mode 100644 index 0000000000000000000000000000000000000000..822c15b0071c0dd1ddedbf4f6e1e89621c362f41 --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/omlx.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:daba44700b1aa34c60d7b5eaf9c951e838c51a042b196bb346023cb3ed820d0e +size 1973 diff --git a/plugins/_onboarding/webui/assets/provider-logos/sambanova.png b/plugins/_onboarding/webui/assets/provider-logos/sambanova.png new file mode 100644 index 0000000000000000000000000000000000000000..bad3410ac8e5b6028a04a98df48f1f5618b4e0ce --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/sambanova.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2621eabab0945bc71c1cb51ba034e2350ed19ac6fee2beaae88bdcf1cd1c4c84 +size 28847 diff --git a/plugins/_onboarding/webui/assets/provider-logos/vllm.svg b/plugins/_onboarding/webui/assets/provider-logos/vllm.svg new file mode 100644 index 0000000000000000000000000000000000000000..e9f151daf754c361d87007643202810bf76b6bd3 --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/vllm.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2be63e74d9ec1387b7b624654e3e3f446a8427782446ed44762b8935070c5c8e +size 1108 diff --git a/plugins/_onboarding/webui/assets/provider-logos/zai-logo.svg b/plugins/_onboarding/webui/assets/provider-logos/zai-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..af70c804e165915d29090b778451e9da5d27ae3e --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/zai-logo.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07a45e8e35b0b631ed2c68cd1cb041f9721b1ceeb0bd0e34f1459b0304a741c7 +size 11037 diff --git a/plugins/_onboarding/webui/assets/provider-logos/zai.svg b/plugins/_onboarding/webui/assets/provider-logos/zai.svg new file mode 100644 index 0000000000000000000000000000000000000000..af70c804e165915d29090b778451e9da5d27ae3e --- /dev/null +++ b/plugins/_onboarding/webui/assets/provider-logos/zai.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07a45e8e35b0b631ed2c68cd1cb041f9721b1ceeb0bd0e34f1459b0304a741c7 +size 11037 diff --git a/plugins/_onboarding/webui/onboarding-providers.js b/plugins/_onboarding/webui/onboarding-providers.js new file mode 100644 index 0000000000000000000000000000000000000000..906bce1f2571390688862a80ab32bcd6bf68ff06 --- /dev/null +++ b/plugins/_onboarding/webui/onboarding-providers.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf10325e844787e34f905fa1b200fcba26325bb48422ef59e43815e162bafcf2 +size 11270 diff --git a/plugins/_onboarding/webui/onboarding-store.js b/plugins/_onboarding/webui/onboarding-store.js new file mode 100644 index 0000000000000000000000000000000000000000..052a275fd97931e7eb46fdbc5af71e5317254eb8 --- /dev/null +++ b/plugins/_onboarding/webui/onboarding-store.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af3085bbc956410e332d073ebca084e8cfca812218498635d063ba4b20ea04f0 +size 30740 diff --git a/plugins/_onboarding/webui/onboarding.html b/plugins/_onboarding/webui/onboarding.html new file mode 100644 index 0000000000000000000000000000000000000000..0d3f0730ccbf18714242653babb7e178e4ffbb7c --- /dev/null +++ b/plugins/_onboarding/webui/onboarding.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a7b50543bb23de99af0c4236b64ace7df00965aaf30864c78efedfcd09397d7 +size 50392 diff --git a/plugins/_onboarding/webui/thumbnail.jpg b/plugins/_onboarding/webui/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5783ac28fe322a54a6e57f635c7678210055f56d --- /dev/null +++ b/plugins/_onboarding/webui/thumbnail.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77302eea95f3913eddd87a3df50c9ca27d4866398d4f3474702e57b8777ff00d +size 9732 diff --git a/plugins/_orchestrator/.gitignore b/plugins/_orchestrator/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4d84e5fb79a55d3eadcd82d39ee4f509b1285469 --- /dev/null +++ b/plugins/_orchestrator/.gitignore @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae66cba2b29fd4cb872360d00bebdff9c45ece0f1c3cd19e4942600aba0eb7ac +size 56 diff --git a/plugins/_orchestrator/AGENTS.md b/plugins/_orchestrator/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..1e3ce85260b1bd6d49c7c66ca9d21a65a8282665 --- /dev/null +++ b/plugins/_orchestrator/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:922b37a6aa8f44cc66e782b563acf160543350fe19f89ef27278e78ba7d2b7b8 +size 4271 diff --git a/plugins/_orchestrator/LICENSE b/plugins/_orchestrator/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..2999431aa5d0c4774ce406b155001f8ae1686cdf --- /dev/null +++ b/plugins/_orchestrator/LICENSE @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3fe2cc86b3eeb0a2e482faef449e0e897d234a0d061dd680892f75e863ade3b +size 1089 diff --git a/plugins/_orchestrator/README.md b/plugins/_orchestrator/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f524c2041668bc633b0a91b7229d7e5728924f48 --- /dev/null +++ b/plugins/_orchestrator/README.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd32b4065290bed293eb8afed48131f01df667bbf11e06bdc7f179b4bd9408e5 +size 7890 diff --git a/plugins/_orchestrator/api/AGENTS.md b/plugins/_orchestrator/api/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..1a8e5cd32f5c93f2f00a88e022a82b8fd823de1a --- /dev/null +++ b/plugins/_orchestrator/api/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5aefe9b56eb80851fb7d02334e8a61a24d11d1a33fbbeb2ddff52123a269718c +size 1952 diff --git a/plugins/_orchestrator/api/__init__.py b/plugins/_orchestrator/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/plugins/_orchestrator/api/disconnect.py b/plugins/_orchestrator/api/disconnect.py new file mode 100644 index 0000000000000000000000000000000000000000..4b1727eb4fdcff4726ba3c65d4218925244188e3 --- /dev/null +++ b/plugins/_orchestrator/api/disconnect.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86ef84513408e2a1ebe7e1371716f9298ff430a49e71df1ca7f8f592c69ee647 +size 794 diff --git a/plugins/_orchestrator/api/poll_device_login.py b/plugins/_orchestrator/api/poll_device_login.py new file mode 100644 index 0000000000000000000000000000000000000000..b76833c0acdc6ccaac406f54e1f39066bbc97faf --- /dev/null +++ b/plugins/_orchestrator/api/poll_device_login.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:560dfce356834850ed96620a9f1539eb04f4bbc615a6ef0d0b2e9a4a924579a6 +size 590 diff --git a/plugins/_orchestrator/api/start_device_login.py b/plugins/_orchestrator/api/start_device_login.py new file mode 100644 index 0000000000000000000000000000000000000000..6fe31e9e9810e0d14e691a6de4ae2f676ed0c75c --- /dev/null +++ b/plugins/_orchestrator/api/start_device_login.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03e05cda84efcfad8bb83ac9fae094bc18d80b08309824ae1298ef3ec121cb3b +size 725 diff --git a/plugins/_orchestrator/api/status.py b/plugins/_orchestrator/api/status.py new file mode 100644 index 0000000000000000000000000000000000000000..3a49725e15d7a7fa958ec7777008f83c747229a0 --- /dev/null +++ b/plugins/_orchestrator/api/status.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a422d9f664dfb358385c0743bcc25e3a15eb7e943158e8fc0e5dbe77b6f53e63 +size 1426 diff --git a/plugins/_orchestrator/default_config.yaml b/plugins/_orchestrator/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e57ebb4e47655a7cf95a49ac9be85ce9e40ccf90 --- /dev/null +++ b/plugins/_orchestrator/default_config.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73483e82370fedbc83073b1476e378325673b3adc2495ebf6031fdfc140fadaa +size 697 diff --git a/plugins/_orchestrator/helpers/AGENTS.md b/plugins/_orchestrator/helpers/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..549be207395631cf1e8a5cfb5a4abb69a199f252 --- /dev/null +++ b/plugins/_orchestrator/helpers/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de3d098adc8be80c93cb70668cef5e965aabd8630a12ed2304767763e8304f06 +size 1614 diff --git a/plugins/_orchestrator/helpers/__init__.py b/plugins/_orchestrator/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/plugins/_orchestrator/helpers/adapters/AGENTS.md b/plugins/_orchestrator/helpers/adapters/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..b63bc95fca1256a80b23e7e26756b8e2cc68c665 --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47c944c82ed87905c60c13a8e9025c3abcad314953c45a6f37bf51b03f40db44 +size 3455 diff --git a/plugins/_orchestrator/helpers/adapters/__init__.py b/plugins/_orchestrator/helpers/adapters/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/plugins/_orchestrator/helpers/adapters/a0.py b/plugins/_orchestrator/helpers/adapters/a0.py new file mode 100644 index 0000000000000000000000000000000000000000..31032eebbc8089179c099dba6bfed5ceaa0b4de3 --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/a0.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92c21b6a19aa5c8906ab2a6624c705873354d90ee9f3ee8eca4910a50fa3af8b +size 2362 diff --git a/plugins/_orchestrator/helpers/adapters/base.py b/plugins/_orchestrator/helpers/adapters/base.py new file mode 100644 index 0000000000000000000000000000000000000000..29777c88ba43b643abb07d6a416af14cbbb715cb --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/base.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4deb334feeb34baa5b16952503b2b3031ac97aaa8393a42bed26ee0651588171 +size 2202 diff --git a/plugins/_orchestrator/helpers/adapters/claude.py b/plugins/_orchestrator/helpers/adapters/claude.py new file mode 100644 index 0000000000000000000000000000000000000000..abb42715856a79c49bb69e756ab0b4684b7486ea --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/claude.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4746ede36561a32cfbd6fb3bced6972710438a2d40f998e838acc6ff58e0ad15 +size 2307 diff --git a/plugins/_orchestrator/helpers/adapters/codex.py b/plugins/_orchestrator/helpers/adapters/codex.py new file mode 100644 index 0000000000000000000000000000000000000000..2ad20803ed51ab5305c7765de8f12d2a5cd3059a --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/codex.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96d85a9fdd8150e22d0e1ebcd69e3fcc1e2a23d59a134ec7d5d6135e69e5d6d4 +size 8710 diff --git a/plugins/_orchestrator/helpers/adapters/cursor.py b/plugins/_orchestrator/helpers/adapters/cursor.py new file mode 100644 index 0000000000000000000000000000000000000000..05631d2b3ba31d44039c86e91f3ff53c663fcb93 --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/cursor.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93658a6ac4e5a9ba7f14091343c7bc404ee565506c4e4c8f14498a5bf54f04f1 +size 2445 diff --git a/plugins/_orchestrator/helpers/adapters/gemini.py b/plugins/_orchestrator/helpers/adapters/gemini.py new file mode 100644 index 0000000000000000000000000000000000000000..1ed5f2ce9d6350cbc55abd21e370990543f2c94d --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/gemini.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e5ac677c382a187ad662a7e66fc3e8e6a772b341139213db9530c22d2ea1aee +size 2708 diff --git a/plugins/_orchestrator/helpers/adapters/grok.py b/plugins/_orchestrator/helpers/adapters/grok.py new file mode 100644 index 0000000000000000000000000000000000000000..969f0760719989235acef42d60a1f51a762b66c0 --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/grok.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb7a87693bb913a35852978b063354060625b170bdc01c0dea27133029c1b71c +size 3339 diff --git a/plugins/_orchestrator/helpers/adapters/hermes.py b/plugins/_orchestrator/helpers/adapters/hermes.py new file mode 100644 index 0000000000000000000000000000000000000000..45423d0656bc89b1f51e1a25117d5658e629d6d9 --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/hermes.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5084579347861edb16733e2a6fed803efd429c518e0fef9a829a385f8ef6b096 +size 3658 diff --git a/plugins/_orchestrator/helpers/adapters/opencode.py b/plugins/_orchestrator/helpers/adapters/opencode.py new file mode 100644 index 0000000000000000000000000000000000000000..2e422d291ace1a1d4d4ed8f584df1cc48a468ca2 --- /dev/null +++ b/plugins/_orchestrator/helpers/adapters/opencode.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dceef9fc656d193969fe5ae0ee630d34f822470bf13902f4f920bbb79f58f97f +size 2276 diff --git a/plugins/_orchestrator/helpers/registry.py b/plugins/_orchestrator/helpers/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..efb3e56fdded5c9881c44603451db40c0b7ea543 --- /dev/null +++ b/plugins/_orchestrator/helpers/registry.py @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:343ab4c8cf6a3965f0f59fbc4c9ae412bd01ef6f88b7cad462b0ce3b70293b40 +size 1725 diff --git a/plugins/_orchestrator/plugin.yaml b/plugins/_orchestrator/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8799e12f82a16d4b08068980f4e5507a431bf092 --- /dev/null +++ b/plugins/_orchestrator/plugin.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:211b20791a1ac274a49c7af7451a07c4cfa8b78637fa10154d9af18cf31cce25 +size 334 diff --git a/plugins/_orchestrator/skills/AGENTS.md b/plugins/_orchestrator/skills/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..67e8cbc491f351b53cff566367b028c6a66c9c4b --- /dev/null +++ b/plugins/_orchestrator/skills/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77d9c9adb04f5dc40e0100b110758fbd07caca49fc2ecdf01c4788ff938f9437 +size 2201 diff --git a/plugins/_orchestrator/skills/orchestrator/AGENTS.md b/plugins/_orchestrator/skills/orchestrator/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..457b5cb48f682fc437b2201a15f2808bd8759a3f --- /dev/null +++ b/plugins/_orchestrator/skills/orchestrator/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25ec17df2f74c8e7a4132110cf837e3dd8232854905bb895f6746afff7a8a4c4 +size 2841 diff --git a/plugins/_orchestrator/skills/orchestrator/SKILL.md b/plugins/_orchestrator/skills/orchestrator/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..55cb8e4d5b06da0dbdc22f47f85894816de44df5 --- /dev/null +++ b/plugins/_orchestrator/skills/orchestrator/SKILL.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03e026937f0776a8bf6766a7ddfa9f55bd197357f91c7cc297a647d5d672cc6d +size 7022 diff --git a/plugins/_orchestrator/skills/orchestrator/references/AGENTS.md b/plugins/_orchestrator/skills/orchestrator/references/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..3c0b32c3fd9f771dca6cb718619788c3fba9b8e2 --- /dev/null +++ b/plugins/_orchestrator/skills/orchestrator/references/AGENTS.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fda593ee8285544967ba5e3921ecd62e6668eca4f0c51f90b2353522f908992 +size 1402 diff --git a/plugins/_orchestrator/skills/orchestrator/references/a0.md b/plugins/_orchestrator/skills/orchestrator/references/a0.md new file mode 100644 index 0000000000000000000000000000000000000000..4643d1f16f2496eaebd61811ab2326479c6f1e7d --- /dev/null +++ b/plugins/_orchestrator/skills/orchestrator/references/a0.md @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40a824619ccd187d5a8ce7fc46eb3d9ec08f3ca246fa901c797eef80a254dc2d +size 1420