diff --git a/.gitignore b/.gitignore index 5f09bd5..83a7e98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Python __pycache__/ *.py[cod] +/src/skills/library/ *$py.class *.so .Python @@ -85,14 +86,48 @@ node_modules/ desktop/dist/ desktop/out/ packaging/windows/build/ +packaging/windows/bin/electron/ +packaging/windows/bin/*.pdb npm-debug.log* +/JonathanAi.exe +/JonathanAi-Setup.exe +/.jonathan-ai-processes.json +/.jonathan-ai-runtime-*.ready +/codex-last-message.txt +/codex-task-prompt.txt # Local desktop captures (user-initiated screenshots) .clawd/captures/ +# Local evaluation checkouts plus Jonathan's downloaded image models/outputs. +# None of these directories are runtime source dependencies or installer payloads. +/integrations/Fooocus/ +/integrations/.image-models/ +/integrations/.fooocus-venv/ +/integrations/python-runtimes/ +/integrations/.uv-cache/ +/integrations/.pip-cache/ +/integrations/.fooocus-cache/ +/integrations/Fooocus-outputs/ +/integrations/upstream/ +/Integrations/ECC/ +/Integrations/Kronos/ +/Integrations/Securo/ +/Integrations/ClaudeDB/ +/Integrations/BenjaminPlus/ +/Integrations/FullstackAgent/ +/Integrations/Procoder/ +/Integrations/DrawAI/ +/Integrations/Kindergrimm/ +/integrations/.kronos-venv/ +/integrations/.kronos-cache/ + # Durable agent memory and chat sessions stay on the machine, never in git +/Skills/ .clawd/memory/ .clawd/sessions/ +.clawd/checkpoints/ +.clawd/agent_profiles.json # Claude Code personal settings CLAUDE.local.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e213b0..0f2b3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,211 @@ # Changelog +## [0.4.8] - 2026-08-27 + +- Fixed the desktop startup failure that left the conversation window blank or displayed `escapeHtml is not defined`. The shared escaping primitive is restored, persisted user/assistant messages render again, and Markdown, engine-status, image, attachment and artifact rendering retain HTML escaping. +- Hardened response delivery: if the browser's Server-Sent Events connection is aborted, the conversation automatically resumes through the bounded job-events endpoint, keeps the working indicator active, and reloads the saved transcript when the job completes. A completed answer can no longer disappear merely because the live socket closed. +- Replaced the nonfunctional `RemoteTrigger` placeholder with permission-gated SSH command execution, SCP upload/download, Windows Remote Management execution and bounded TCP reachability checks for explicitly named computers and servers. It uses existing SSH keys/agent or stored Windows credentials, never accepts passwords in model/tool input, validates hosts, defaults to strict host-key checking, uses argument arrays with `shell=False`, and remains subject to the remote machine's authorization, ACL and elevation rules. +- Added regression tests for visible response infrastructure, remote-action permission gating, host validation, strict SSH invocation and password exclusion; visually verified a new local-model reply in the real conversation window. +- Updated product, desktop, installer, documentation and test version surfaces for 0.4.8. + +## [0.4.7] - 2026-08-25 + +- Evaluated Apache-2.0/MIT LoopX and adopted its strongest non-duplicative idea as Jonathan's independent `LongHorizonControl`: durable objectives, workspace scope, bounded turns, dependency-aware todos, agent claims with expiring leases, explicit human/evidence gates, UTF-8 evidence receipts, handoffs, and fail-closed continuation/closure decisions. No upstream source, dashboard, adapter, PWA/Tauri shell, branding, service, or runtime is bundled. +- Evaluated `cporter202/scraping-apis-for-devs`. It is an unlicensed catalog of thousands of changing third-party listings, predominantly paid Apify actors, rather than a scraping engine or API implementation. Jonathan's audited `PublicApiCatalog`, connectors, `ContentReach`, WebFetch and browser tools already cover safe discovery and retrieval, so no catalog mirror, vendor listing, credential flow, actor, branding or automatic scraper is bundled. +- Evaluated MIT OGAM. Jonathan already covers its local/LAN model discovery, GGUF-capable runtimes, vision, Whisper, diffusion, attachments, retrieval, tool loops and user approval; the useful non-duplicative resource-awareness principle is now native in the local-model connector, with stored model bytes, live runtime RAM, available system RAM and conservative fit guidance. No React Native app, Android/iOS native module, mobile model bundle, paid Pro feature, branding or upstream runtime is included. +- Fixed desktop dependency setup to keep npm/Electron caches inside Jonathan's install folder and to report success only when the Electron runtime binary exists, preventing silent headless fallback and repeated first-launch repair. +- Evaluated CC BY-SA 3.0 Awesome Selfhosted and adopted the useful operator workflow without copying its 2,000+ changing links: native `SelfHostedService` registers Docker/Podman Compose, Windows-service and systemd workloads; reports status and bounded logs; checks local/LAN health; creates downloadable bounded backups; and performs approved start/stop/restart/pull-update actions with fixed argument lists and no shell. A visible independent `self-hosted-service-operator` skill governs discovery, licensing, secrets, backup, rollback and verification. No upstream catalog, description, non-free list, branding or service deployment is bundled. +- Updated product, desktop, installer, documentation and test version surfaces for 0.4.7. + +## [0.4.6] - 2026-08-25 + +- Evaluated Apache-2.0 Awesome LLM Apps as an agent/RAG/MCP cookbook. Its templates duplicate Jonathan's provider, agent-team, retrieval, skill and connector foundations, so no second framework, demo application, prompt pack or upstream runtime is bundled. +- Evaluated MIT Public APIs and adopted its useful discovery metadata shape as Jonathan's native `PublicApiCatalog`: an offline audited starter index can be searched by category/auth/HTTPS/CORS and approved entries can become persistent connector cards, without cloning or depending on the upstream list. +- Evaluated Humanizer Stack's mixed MIT/CC BY-SA two-pass writing workflow and added an independent bundled `writing-polish` skill for opt-in surface and structure editing, voice-sample calibration, meaning preservation, honest review notes, and explicit authorship/detector boundaries; no upstream skill text, scanner, research digest, or installer is included. +- Evaluated AI Workflow Hub 2000. It is an n8n workflow-JSON collection with no repository license, per-workflow trust manifest, or execution isolation; bulk importing credentialed third-party automations would weaken Jonathan's audited connector/tool boundary, so no workflow, branding, service dependency, or upstream checkout is bundled. +- Evaluated MIT JustInCache n8n Workflows. Its SQLite search/filter UI is useful but already covered by Jonathan's native searchable catalogs, while its 2,000+ workflow graphs require a separate n8n runtime and broad external credentials; no corpus, FastAPI service, container stack, branding, or automatic workflow execution is bundled. +- Evaluated MIT Open Generative AI. Its broad image/video/lip-sync UI is backed by a required MuAPI gateway key rather than a bundled local model fleet and duplicates Jonathan's desktop/media surfaces; no second Electron/Next.js shell, paid gateway, submodule, branding, unfiltered-policy bypass, or upstream runtime is included. Jonathan retains local image/audio/3D engines and explicit ComfyUI/external connectors for heavier video models. +- Evaluated MIT Taste Skill and adopted its compatible adjustable-art-direction principle as an independent bundled `interface-art-direction` skill: layout variance, motion intensity and information density are chosen from the product context, then checked against accessibility, responsive behavior and rendered evidence. No experimental upstream prompt, code skeleton, image asset, branding or installer is included. +- Evaluated Vercel Labs Agent Skills. Because its repository currently lacks a top-level license file despite a README MIT statement, no upstream skill text is redistributed; Jonathan adds an independent `react-performance-review` skill for evidence-led waterfall, bundle, rendering-boundary, rerender and loading analysis with before/after verification. +- Evaluated MIT Awesome Design MD and adopted its compatible persistent-design-context principle: a project-owned UTF-8 `DESIGN.md` is now loaded into every conversation agent under a separate bounded design-system section. No third-party brand analysis, preview page, token catalog, trademark, or upstream checkout is bundled. +- Evaluated CC0-style gztchan Awesome Design. It is a curated external-link list rather than an executable tool, and linked services have independent terms and changing availability; Jonathan's live search, connector, design-skill and `DESIGN.md` paths already cover the useful workflow, so no stale link mirror or upstream checkout is bundled. +- Evaluated MIT Screenshot to Code and adopted its useful image-to-working-interface and render-and-compare workflow as an independent bundled `screenshot-to-interface` skill. Jonathan uses its existing uploads, multimodal agents, project preview and coding tools without adding a second FastAPI/Vite app, paid provider requirement, Chromium download, branding or upstream runtime. +- Evaluated MIT OpenCode. Its terminal/desktop clients, build/plan agents, provider catalog, sessions, LSP, MCP and permission model overlap Jonathan's existing native shell, per-conversation agents, Plan Mode, connectors and approval gateway; no second Bun/Tauri runtime, branding, cloud service, installer or upstream checkout is bundled. +- Evaluated MIT OpenCode Voice and OpenAI Whisper. Jonathan now includes user-initiated conversation microphone recording, local Whisper-family multilingual transcription/translation with first-use model download, raw-transcript fallback behavior, and local OS speech synthesis to downloadable WAV through native `SpeechStudio`; no background listening, mandatory normalization LLM, Piper/SoX/OpenCode runtime, branding or upstream checkout is bundled. +- Evaluated MIT ScrapeGraphAI. Its LLM graph pipelines substantially overlap Jonathan's WebSearch/WebFetch/browser, local-model, worker and structured-output stack while adding a second provider abstraction, Playwright footprint and opt-out telemetry; no crawler framework, hosted-credit API, branding, telemetry or upstream checkout is bundled. +- Evaluated MIT Agent Reach and adopted its useful channel-health concept as native `ContentReach`: public RSS/Atom and YouTube metadata/captions use lightweight maintained libraries, while GitHub/GitLab and authenticated social services remain in Jonathan's explicit connector/approval system. Jonathan never reads browser cookies automatically and does not install Agent Reach, OpenCLI, proxy services, sponsored APIs or upstream skills. +- Evaluated BSD-3-Clause Scrapling. Jonathan adopted safer visible-page text extraction that drops scripts, styles, templates, head content and hidden elements before model context; stealth fingerprinting, CAPTCHA/Turnstile bypass, cookie sessions, proxy rotation, anti-bot evasion, a second browser bundle and the upstream runtime are intentionally excluded. +- Evaluated MIT Higgsfield AI Skills. Its image/video/audio/brand/product/website workflows are coupled to the Higgsfield hosted CLI, authentication, vendor models and branded templates, duplicating Jonathan's native media, design, 3D, website, artifact and skill systems; no vendor skill text, CLI, assets, model catalog, branding or paid-service dependency is bundled. +- Evaluated Apache-2.0 Higgsfield GPU orchestration. Its 0.0.3-era Linux cluster stack requires remote Ubuntu nodes, Docker, SSH deploy keys, passwordless sudo and distributed-training infrastructure, so it is not a safe standard-desktop dependency; Jonathan retains explicit NVIDIA/cluster connectors and native system administration rather than installing it. +- Evaluated Augmented Unreality, a legacy Unreal Engine 4.19 camera/ArUco tracking plugin. Its Unreal, OpenCV-contrib, GStreamer, calibration-asset and platform-native build requirements do not improve Jonathan's desktop conversation vision, image editing or 3D artifact workflow, so no obsolete engine plugin or runtime dependency is bundled. +- Evaluated the MIT Unreal Augmented Reality Toolkit. Its four-commit C++ implementation only copies webcam frames into an Unreal dynamic texture, while marker detection remains a roadmap item; Jonathan's attachment/media preview and vision tools already cover the useful capability without an Unreal/OpenCV SDK build, so no upstream source or dependency is bundled. +- Evaluated MIT Unreal Engine MCP and adopted the useful live-editor workflow as Jonathan's independent `UnrealStudio`: it discovers installed editors and projects, inspects descriptors, generates reviewable declarative scene scripts, and runs an explicitly approved project-local Python script through `UnrealEditor-Cmd`. The acquired hosted service, API keys, branding, TCP/C++ plugin, arbitrary remote execution and upstream runtime are not bundled. +- Evaluated MIT 9router. Its localhost proxy, provider-format rewriting, subscription OAuth storage, multi-account rotation, tool-result compression and silent paid/free fallback duplicate Jonathan's native providers, connector vault, token meters and bounded selected-provider retry while adding privacy, billing and semantic-routing risk; no proxy, credential harvester, prompt modifier, branding, cloud sync or upstream runtime is bundled. +- Replaced the managed Fooocus/Gradio checkout with Jonathan's native in-process Diffusers text-to-image and image-to-image engine, preserving downloadable artifacts while removing the upstream service/runtime dependency and avoiding heavy model imports during status checks. +- Evaluated MIT OmniRoute and adopted a small native selected-provider resilience layer: transient 408/409/425/429/5xx and transport failures receive one bounded retry, repeated failures open a cooldown circuit, visible streams are never replayed, and prompts are never silently moved to another provider. +- Evaluated Anthropic's official Claude plugin catalog. Jonathan already supports the portable `SKILL.md` and MCP concepts through its audited skill/connector systems; Claude-specific hooks, commands, marketplace runtimes, and mixed third-party licenses are therefore not bundled or auto-enabled. +- Evaluated CC BY 4.0 Task Observer by Eoghan Henn/rebelytics and adopted its compatible friction-evidence principle: sanitized user corrections, tool failures and repeated tool sequences now form confidence-scored review candidates, while drafts remain visible and never activate without user approval. +- Evaluated Apache-2.0 Headroom and adopted its compatible stable-prefix principle directly in Jonathan's Anthropic adapter via prompt-cache markers. No proxy, Rust extension, ML compressor, prompt rewriting, dropped messages, account, or upstream runtime is used. +- Evaluated Anthropic's Apache-2.0 community plugin marketplace mirror. Its third-party entries, mixed licenses, external runtimes, stale pins and catalog-scale failures are not bulk-imported; Jonathan retains explicit connector setup and per-skill security review/enablement. +- Evaluated Apache-2.0 YuE and MIT ACE-Step UI. Their full-song models remain optional external/local-server choices because the documented model, CUDA, VRAM and disk requirements are unsuitable for every standard install; Jonathan now includes a native `AudioStudio` for deterministic instrumental WAV generation, trim, normalize, mix and conversation downloads. +- Added a revision-pinned, independently updatable ECC integration that indexes the complete skills and agent-template catalog while lazily loading only explicitly enabled managed skills. +- Added UTF-8 catalog import with provenance, atomic replacement, recoverable archives, duplicate avoidance, full-repository reporting, and critical checks scoped to content that can actually be activated. +- Added `ECCIntegration` agent tool and desktop controls for sync, security scan, full import, per-skill enable/disable, agent-template instantiation, and inspectable Memory Vault export/import. +- Added native-Windows evidence capture and Git-history learning with secret/instruction sanitization, project isolation, confidence-scored instincts, validated `SKILL.md` drafts, and mandatory review before activation. +- Added a Jonathan-native probabilistic OHLCV research engine, informed by the Kronos evaluation, with recency-weighted drift/volatility, Monte Carlo intervals, downloadable CSV artifacts, and no upstream checkout, model download, or automatic trading. +- Added a Jonathan-native SQLite personal-finance vault, informed by the Securo evaluation, for accounts, transactions, categories, recurring flags, budgets, goals, assets and currency-separated reports with no containers, cloned service, or payment requirement. +- Evaluated Pydantic AI's typed tools/outputs, provider adapters, durable execution, evaluation, and observability. It remains a future opt-in adapter rather than a second core agent loop because duplicating Jonathan's sessions, permissions, tools, and provider routing would increase startup cost and correctness risk without adding an immediate user capability. +- Unified desktop visual states with a consistent system font, focus-visible keyboard treatment, compact connector hierarchy, reduced-motion support, and preserved per-conversation/per-agent token tracking without billing gates. +- Added a redacted, UTF-8, tamper-evident append-only execution ledger inspired by Apache Maka's runtime-fact model; job lifecycle, messages, tool calls/results, permissions, failures, completion, usage, and artifacts survive separately from provider context projections. +- Added local earned-autonomy evidence profiles after evaluating Universal Earned Autonomy Layer: distinct approval/rejection tracking per conversation and tool, Wilson lower-bound confidence, hard risk ceilings, replay resistance, and transparent recommendations that never silently widen permissions. +- Evaluated CopilotKit OpenBot without importing its alpha Docker/Bun/PostgreSQL/licensed-service stack; adopted its compatible preflight-gateway principle so every approved tool action is secret-redacted and durably recorded before execution, then recorded again with its result, failing closed if preflight audit persistence fails. +- Evaluated the functional Scandinavian Design agent skill and its ten-site visual harness without copying its unlicensed skill/source; independently refined Jonathan's dark desktop with neutral solid surfaces, reduced decorative gradients/glow/blur, consistent compact radii, sentence-case section labels, and retained focus/contrast/reduced-motion accessibility. +- Evaluated the MIT Autoprompt Skill and adopted its compatible separation-of-judgment pattern as opt-in Fast/Balanced/Verified internal-worker modes. Verified adds an independent final reviewer, while all worker/reviewer usage rolls into the owning conversation's informational token count; no mode runs implicitly. +- Added Jonathan-native `CodeMemory` for UTF-8 SQLite notes, incremental project scans, text/usages/explain/path queries, and symbol/call/import graphs; Claude DB is a design reference only and no upstream runtime, hooks, account, or subscription are required. +- Evaluated the MIT Benjamin Plus efficiency skill and incorporated its compatible measured workflow disciplines into both full and lightweight agent prompts: one-pass reconnaissance, bounded inspection, batched dependency probes, exact verification, and low-chatter waits, always subordinate to correctness. +- Evaluated the AGPL Fullstack Agent setup conductor. Its Claude/Obsidian-centered memory, UI, updater, permissions and installer duplicate Jonathan, while voice/face/webcam features belong to separate component repositories; the meta-installer is therefore not embedded in Jonathan. +- Evaluated Apache-2.0 Procoder and rewrote its compatible fail-closed principles as Jonathan's native engineering gate: unchecked work fails, canonical tests are detected, conflict/secret/junk hygiene is enforced, and no upstream binary or hook is installed. +- Evaluated Apache-2.0 DrawAI and added a native raster-to-editable engine using Jonathan's installed Pillow/OpenCV/OCR/PowerPoint stack; it exports editable SVG, native-shape PPTX, review PNG and JSON without an upstream checkout or >8 GB model pack. +- Evaluated the Unlicensed/Public Domain Kindergrimm procedural art engine and added an independent deterministic animated SVG character/rig generator with JSON recipes. +- Evaluated MIT Scroll Craft and added a native bundled `scroll-storytelling` skill covering distinctive page grammars, feeling curves, accessibility, efficient scroll mechanics, reduced motion, and visual verification without an upstream plugin/runtime. +- Evaluated MIT Hermes3D and added a native spatial agent floor to Jonathan's existing control center, showing live named-agent desks, working/idle state, unread mail and token totals with direct focus controls; no 3D frontend, gateway, adapter or upstream runtime is required. +- Updated the standard installer/bootstrap to synchronize and import ECC automatically when online while keeping offline installation usable and retryable. +- Upgraded the Electron shell to 44, moved build caches inside the ignored packaging workspace, hardened build failure checks, and produced a fresh standard Windows installer with a clean high-severity dependency audit. +- Updated all version, installer, desktop, feature, license, test, and documentation surfaces for 0.4.6. + +## [0.4.5] - 2026-08-24 + +- Added an explicit, persistent, and revocable Full Device & Network Access approval that expands every conversation agent from workspace-only access to the signed-in operating-system account. +- Added installed application, browser, and PATH tool discovery plus audited application/file/URL launching and mouse, keyboard, hotkey, and desktop screenshot automation. +- Kept the mode disabled by default, requires the exact `ENABLE FULL ACCESS` confirmation, preserves Windows ACL/UAC boundaries, and records enable/revoke actions in the redacted audit log. +- Added desktop controls showing access scope, elevation status, and a live installed-tool inventory. + +## [0.4.4] - 2026-08-24 + +- Added one visible shared `Skills` library with a desktop editor; every conversation agent can discover, validate, create, update, and execute its UTF-8 `SKILL.md` files. +- Added a concrete self-learning workflow and prompt guidance so agents use `SkillManager` instead of claiming they cannot create operational files. +- Added fast local image inspection for dominant colors, shapes, people/faces, QR codes, sharpness, and optional RapidOCR text recognition while still sending original pixels to vision models. +- Bounded provider context without truncating persisted history and stopped resending multi-megabyte image pixels on every tool turn. +- Switched the Windows release to the standard Inno Setup install/upgrade wizard, preserving the user skill library during upgrades. + +## [0.4.3] - 2026-08-24 + +- Fixed OpenAI-compatible/local agent turns dropping multimodal block messages, so LM Studio, Ollama, vLLM, and OpenAI-compatible vision models receive uploaded image pixels. +- Preserved binary file and image bytes for file-picker and drag-and-drop uploads instead of decoding pathless images as garbled clipboard text. +- Added bounded PDF, DOCX, XLSX/XLSM, PPTX, and ZIP extraction plus persistent download links for uploaded files. +- Added local Fooocus image-to-image variations for prompt-guided edits, with cloud 401/403 fallback. +- Simplified the conversation surface to responses, uploaded/generated media, downloads, permissions, errors, and a bottom-left progress robot; raw tool cards stay hidden. +- Kept staged paths and extracted attachment context available to the agent while hiding backend attachment instructions from the visible transcript. + +## [0.4.2] - 2026-08-24 + +- Make the installed local Fooocus engine the default for image generation, even when a stale cloud key exists. +- Fall back to Fooocus when an explicitly selected cloud image endpoint returns HTTP 401 or 403. +- Expose an explicit local/cloud generation-engine selector in Image Studio. +- Close Pillow image handles deterministically so Windows downloads and overwrites do not fail with file-in-use errors. + +## [0.4.1] - 2026-08-24 + +- Render uploaded images directly inside their persistent conversation messages. +- Persist generated image artifacts per conversation across reloads and restarts. +- Add authenticated inline image-view and forced-download routes for uploads and generated files. +- Preserve original attachment filenames after per-session staging. + +## [0.4.0] - 2026-08-24 + +- Added fast full-text search across persistent conversations, summaries, and shared memory. +- Added persistent named agents with canonical conversations, parallel execution, local mailboxes, cross-agent work, and per-agent token totals. +- Added recoverable pre-run checkpoints, Undo, Retry, and checkpoint restore APIs. +- Added a one-click system diagnostics and safe local repair dashboard. +- Bundled 200 permissively licensed skill packages with their supporting files and notices. +- Removed Jonathan's former 100-message conversation truncation; stored chat history is now unbounded by an app quota. + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.3] - 2026-08-24 + +### Fixed +- Normal desktop launches skip dependency repair after a version-specific healthy-runtime marker is written, removing the repeated native-launcher and Electron bootstrap delay. +- Electron selects a free localhost port, verifies the authenticated status endpoint, handles backend spawn errors, and writes structured startup diagnostics to `~/.clawd/run/desktop.log` instead of accepting an unrelated stale backend. +- Ordinary local-model conversation omits the 50-tool schema payload, uses bounded recent history, and limits output so short prompts respond promptly. +- Actionable local-model requests receive a focused tool subset; Tool Search can expand that subset on later turns without sending every schema up front. +- Restored session metadata now reflects the provider/model that actually handles the conversation. + +## [0.3.2] - 2026-08-23 + +### Added +- Managed official Fooocus checkout under `integrations/Fooocus`, isolated Python 3.10 runtime, CPU/CUDA-aware PyTorch installation, pinned Gradio-compatible web dependencies, and automatic first-run SDXL model download. +- Fooocus connector card with install/repair, start/stop, embedded localhost preview, direct prompt generation, output discovery, and conversation downloads. +- Agent-facing `Fooocus` tool plus Image Studio local-generation fallback when no cloud image API key is configured. +- Device-wide local AI discovery for Ollama, LM Studio, Jan, GPT4All, llama.cpp, KoboldCpp, Hugging Face caches, and additional OpenAI-compatible loopback ports. +- Automatic startup/selection of an installed Ollama or LM Studio model when no working provider is configured, with endpoint-scoped local credentials. + +### Fixed +- Redirected Fooocus, pip, uv, Torch, Hugging Face, Transformers, Matplotlib, and XDG caches away from Windows home folders with restrictive ACLs. +- Preserved completed Fooocus images when the legacy Gradio client cannot deserialize its final gallery payload. +- Safely tracks and stops only the managed Fooocus Python process tree. + +## [0.3.1] - 2026-08-23 + +### Fixed +- Uploaded images are staged per conversation and sent to vision-capable providers as real multimodal image blocks instead of path-only text. +- OpenAI-compatible providers receive native `image_url` blocks while Anthropic receives base64 image source blocks. +- Image Studio can use uploaded paths, generate images, perform prompt-based AI edits, and expose direct downloadable results. + +### Changed +- Replaced the long connector table with compact clickable cards and closable provider-specific detail panels. +- Added external-service connections for OAuth 2.0 authorization code, username/password, API key, bearer token, and unauthenticated endpoints. + +## [0.3.0] - 2026-08-23 + +### Added +- `ImageStudio` for local image creation, gradients, text overlays, OpenCV text removal/inpainting, compositing, resizing, cropping, rotation, flipping, conversion, OpenAI-compatible image generation, and conversation downloads +- `ThreeDStudio` for colored GLB/GLTF/OBJ/STL/PLY primitives, `.mlb` alias correction, Blender 5.2 discovery/verified portable installation, textured materials, HD Eevee/Cycles rendering, BLEND/FBX/USD conversion, and downloadable model/render artifacts +- Universal `Artifact` publishing for any workspace file and safe ZIP packaging for directories +- `SystemAdmin` for inventory, CPU/memory/disk/network monitoring, threshold evaluation, process/service control, approved package installs, and downloadable JSON/Markdown reports +- Secret-redacted UTF-8 JSONL audit logging for every desktop tool invocation, exposed through the admin tool and `/api/audit` +- `Repository` for GitHub/GitLab-compatible clone, status, log, diff, fetch, pull, and review-branch push without merge behavior +- `SkillManager` for creating, updating, validating, and packaging project or durable user `SKILL.md` capabilities +- Searchable/exportable `SharedMemory`, with durable fact and conversation metadata retention expanded beyond the old 40-conversation cap +- Desktop engine status and direct generated-image/download cards in the conversation +- `BusinessManager` with durable customers, projects, tasks, invoices, income/expense ledger, dashboards, and downloadable JSON/CSV/HTML reports +- `FinanceMarkets` for delayed quotes/history, technical indicators, symbol screens, portfolio valuation, and watchlist monitoring reports +- Optional Alpaca paper/live `Trading` connector configured outside the model context; every submit/cancel requires a fresh approval and audit entry +- The install wizard now treats the complete Python, Electron, finance, image, system, and Blender stacks as installed dependencies and fails visibly if a required runtime cannot be provisioned + +### Fixed +- Blender 5.2 Eevee engine compatibility and reliable detection of Python tracebacks even when Blender exits with status 0 +- Colored GLB export no longer requires the optional SciPy dependency + +## [0.2.9] - 2026-08-23 + +### Added +- Compact desktop layout, animated working robot, and a collapsible live workspace preview with a bounded file tree +- User-selected project/download directory plus automatic installable-output detection and safe source ZIP packaging, exposed as conversation download links +- Conversation removal from the sidebar without deleting the saved transcript or shared-memory record +- Prominent Connect tools entry for GitHub, GitLab, MCP servers, and OpenAI-compatible external agents +- Simultaneous conversation-scoped agent instances with isolated transcripts, workspace/tool state, approvals, tasks, and jobs; the sidebar can cross-view running agents without redirecting their output +- Read-only `AgentInstances` tool for deliberate cross-conversation context and a real `Terminal` tool that discovers PowerShell, cmd, Bash/Git Bash, WSL, zsh, fish, and nushell while inheriting installed CLIs from PATH + +### Fixed +- Preserved object-shaped `tool_result.content` normalization for Dustin's live Anthropic 400 while restricting the no-tools fallback to the exact legacy tool-schema error; authentication, rate-limit, and unrelated request failures are surfaced normally +- Combined atomic session writes with explicit UTF-8 reads/writes so non-ASCII chats survive restart on Windows +- Electron now uses a writable Jonathan-owned profile and an in-process software GPU fallback, preventing Windows profile-lock and missing GPU-runtime crashes +- PowerShell now executes after user approval instead of returning a placeholder error, and Bash discovers Git Bash on Windows instead of assuming `bash.exe` is on PATH + ## [0.2.8] - 2026-08-23 ### Fixed -- Windows desktop start no longer dies in `Session.load` with `UnicodeDecodeError` on session JSON. Session files are always opened and written as UTF-8 (`encoding='utf-8'`), so bytes such as `0x8F` that are undefined in cp1252 load correctly +- Session JSON is explicitly read and written as UTF-8 on Windows ## [0.2.7] - 2026-08-23 ### Fixed -- Anthropic `tool_result.content` is now always a string or a list of content blocks. Dict/object/list tool outputs (Read, Write, Glob, resumed session JSON) are `json.dumps`'d before `chat`, `chat_stream`, and `chat_stream_response`, so Anthropic no longer returns `Found an object, but tool_result content must either be a string or a list of content blocks` +- Anthropic-compatible message history now JSON-encodes object-shaped `tool_result.content`, including results loaded from older persisted chats, while preserving valid content-block lists +- Unrelated Anthropic errors such as authentication and rate limits are still surfaced; only the existing exact tool-schema 400 fallback retries without tools ## [0.2.6] - 2026-08-23 @@ -220,6 +412,7 @@ The focus was on building a solid foundation with clean architecture, comprehens --- +[0.2.9]: https://github.com/GoDeskio/Clawd-Code/releases/tag/v0.2.9 [0.2.8]: https://github.com/GoDeskio/Clawd-Code/releases/tag/v0.2.8 [0.2.7]: https://github.com/GoDeskio/Clawd-Code/releases/tag/v0.2.7 [0.2.6]: https://github.com/GoDeskio/Clawd-Code/releases/tag/v0.2.6 diff --git a/FEATURE_LIST.md b/FEATURE_LIST.md index 981dd08..390f826 100644 --- a/FEATURE_LIST.md +++ b/FEATURE_LIST.md @@ -36,23 +36,47 @@ | 交互式 REPL | ✅ | 支持交互式输出、历史记录、Tab 补全、多行输入 | | Slash Commands | ✅ | 已支持 `/help`、`/clear`、`/save`、`/load`、`/multiline`、`/exit` | | 多 Provider 抽象 | ✅ | 已支持 Anthropic / OpenAI / GLM / Minimax / Hugging Face / Local LLM | +| 本机 AI 自动发现 | ✅ | v0.4.7:扫描 Ollama / LM Studio / Jan / GPT4All / llama.cpp / KoboldCpp / Hugging Face 缓存与常用回环端口;显示模型存储、运行时 RAM、可用系统 RAM 与保守 fit 提示;无可用云配置时自动启动并连接已安装模型 | +| Jonathan 本地图像引擎 | ✅ | v0.4.6:内置 Diffusers 文生图/图生图;自动安装依赖、首次下载模型、CUDA/CPU、输出下载与 Image Studio 本地回退;不依赖 Fooocus/Gradio | +| Audio Studio | ✅ | v0.4.6:内置原创程序化 WAV 生成、裁剪、标准化、混音和会话下载;重型歌曲模型可通过显式外部/MCP 连接使用 | +| Voice / Speech Studio | ✅ | v0.4.6:会话内用户主动麦克风录音、本地 Whisper 系列多语言转写/翻译、首次使用下载所选模型、本机语音合成与 WAV 下载;不后台监听、不要求付费 API | | Provider 配置管理 | ✅ | 支持默认 Provider、Base URL、默认模型配置 | | 会话持久化 | ✅ | 支持保存/加载本地会话;重启后侧栏仍在;New Chat 新建空线程;桌面左侧可重命名 | -| 独立桌面 Agent | ✅ | v0.2.8:会话 JSON 以 UTF-8 读写(避免 Windows cp1252);Anthropic tool_result 对象序列化为字符串;会话持久化;New Chat / `/new` 空线程;跨会话记忆;内部 Workers;schema 400 去掉 tools 重试;首次启动自动补依赖;Setup 原地升级且不合并 main | +| 独立桌面 Agent | ✅ | v0.3.0:每个对话拥有隔离且可并行运行的 Agent;完整终端与 PATH CLI;图像生成/编辑/文字移除;Blender 高精度材质 3D 与 GLB/GLTF/OBJ/STL/FBX/BLEND/USD;任意文件下载;系统监控、资源评估、进程/服务/包管理;UTF-8 脱敏审计日志;GitHub/GitLab 仓库自动化;自建 SKILL.md;可搜索共享记忆;原子 UTF-8 会话;New Chat 空线程;实时预览;Setup 原地升级且不合并 main | +| 可验证内部协作 | ✅ | v0.4.6:Fast/Balanced/Verified 三种显式模式;Verified 由未参与创作的独立 Agent 检查目标、冲突和缺失证据;所有 worker/reviewer token 计入所属会话且不构成配额 | +| 长程目标控制 | ✅ | v0.4.7:跨会话持久目标、工作区 scope、依赖 todo、Agent claim/lease、人工/证据 gate、UTF-8 evidence receipt、handoff、turn budget 与 fail-closed continuation;无需第二 Agent runtime | +| ECC 技能与 Agent 目录 | ✅ | v0.4.6:完整目录同步、MIT 来源/版本记录、导入前安全扫描、全部离线索引、按需启用避免上下文膨胀、可实例化 Agent 模板与 Memory Vault 互操作 | +| 自动技能学习 | ✅ | v0.4.6:本地 Git 历史测量、项目隔离、敏感信息/提示注入清洗、置信度 instinct、UTF-8 SKILL.md 草稿、用户审核后才激活 | +| 写作润色技能 | ✅ | v0.4.6:可选的表层与结构双重编辑、样本文风校准、含义/事实/引用保留,不承诺规避检测器或虚假作者身份 | +| 界面艺术指导技能 | ✅ | v0.4.6:按产品语境设置布局变化、动效强度和信息密度,保留可访问性、响应式和实际渲染验证 | +| React 性能审查技能 | ✅ | v0.4.6:以测量为先检查请求瀑布、客户端包、渲染边界、重复渲染和加载体验,并验证前后结果 | +| 截图转可用界面技能 | ✅ | v0.4.6:读取会话中的截图/设计图,遵循项目现有技术栈生成可维护界面,使用项目预览进行桌面/窄屏渲染对比与验证;无需独立截图转代码服务 | +| 本地市场预测 | ✅ | v0.4.6:Jonathan 内置 EWMA/Monte Carlo OHLCV 概率预测、Mini/Small/Base 路径规模、P10/P50/P90 CSV、无外部源码/模型、无自动下单 | +| 私有个人财务库 | ✅ | v0.4.6:Jonathan 内置 SQLite 账户/交易/分类/周期项目/预算/目标/资产/多币种汇总、无容器、无外部应用、无付费要求 | +| 本地代码图与代码记忆 | ✅ | v0.4.6:Jonathan 内置 Python/SQLite、增量扫描、文本/引用/解释/依赖路径、无 Claude hook、Node、外部仓库、云端或订阅 | +| 可编辑图形与角色 | ✅ | v0.4.6:内置 Raster→SVG/PPTX/OCR/JSON、确定性动画 SVG 角色和 rig 配方,无外部模型或仓库 | +| 追加式运行事实 | ✅ | v0.4.6:每会话 UTF-8 JSONL、秘密脱敏、SHA-256 哈希链、任务/工具/权限/完成/失败/usage 可恢复事实;上下文裁剪不删除历史证据 | +| 赢得式自主权 | ✅ | v0.4.6:按会话/工具记录互不重复的人工批准与拒绝,使用 Wilson 置信下界和风险上限提出透明建议;建议本身绝不扩大权限,系统/终端/写入仍由用户批准控制 | | 工具 schema 清洗 | ✅ | 发给 Anthropic/OpenAI 的每个工具都带 `input_schema.type` | | 会话消息管理 | ✅ | 支持会话历史维护与序列化 | | 错误恢复 / 重新登录 | 🟡 | 已有基础认证错误处理与重新配置流程 | | Token / Cost 跟踪 | ✅ | 桌面每个聊天窗口显示 input/output/running total,随会话持久化;仅信息展示,不是配额墙 | | GitHub / GitLab | ✅ | 本机 token 或设备登录;clone/pull/push、建仓、PR/MR;默认不推 default branch | | MCP / 其他 Agent | ✅ | 设置中添加/列出/启用 MCP 与 OpenAI 兼容 agent URL;Cursor/Codex/local hook | +| 公共 API 发现 | ✅ | v0.4.6:内置离线审核起始目录,按类别/认证/HTTPS/CORS 搜索,经批准转换为持久连接器;不依赖远程目录仓库 | +| 公共内容 Reach | ✅ | v0.4.6:内置频道健康状态、RSS/Atom 阅读、YouTube 公开元数据与字幕;认证社交平台仍走显式连接器,不自动读取浏览器 Cookie | | 上下文构建 | ✅ | workspace / git / `CLAUDE.md`;跨会话记忆 `~/.clawd/memory`(facts + 其他对话标题/摘要,不含全文) | +| DESIGN.md 设计上下文 | ✅ | v0.4.6:项目根目录 UTF-8 `DESIGN.md` 以独立、有界设计系统段自动提供给每个会话 Agent | | Claude Code Agent Loop | ✅ | 已实现 agent_loop.py,支持工具调用循环 | | `/resume` 会话恢复体验 | 🚫 | 暂无独立恢复流程与 UI | | `/compact` 对话压缩 | 🚫 | 暂无自动/手动压缩能力 | | `/doctor` 诊断系统 | 🚫 | 暂无环境、配置、权限、依赖诊断命令 | | Hook 系统 | 🟡 | 已有 Cursor/Codex/local agent hook 与 inbound POST;非通用 pre/post tool hook | | 权限系统 | ✅ | 路径沙箱 + 文档写入询问 + 桌面端对 Bash/Write/Edit/Web 的交互批准 | -| 桌面应用 | ✅ | Electron/浏览器壳 + 本地 Python host;Windows 上为 JonathanAi.exe 与玻璃拟态仪表盘 | +| 工具执行网关 | ✅ | v0.4.6:允许的工具动作在执行副作用前写入脱敏 preflight 审计记录,完成/拒绝/失败后再写结果;preflight 无法持久化时关闭执行 | +| 自托管服务操作 | ✅ | v0.4.7:持久注册 Docker/Podman Compose、Windows service 与 systemd;固定参数、无 shell 拼接;状态/log、LAN health、可下载 backup、审批后 start/stop/restart/update;配套可见 operator skill | +| 远程电脑与服务器 | ✅ | v0.4.8:对明确主机执行审批门控的 SSH/WinRM 命令、SCP 上传/下载和 TCP 可达性检查;仅使用现有密钥/系统凭据,严格验证主机,无密码模型输入,不能绕过远端 ACL/UAC/sudo/防火墙 | +| 桌面应用 | ✅ | v0.4.8:修复持久对话渲染;SSE 中断时自动切换到 job-event 轮询并在完成后重载保存的回答;Electron/浏览器壳 + 本地 Python host | | 安装向导 | ✅ | Windows Setup exe 可见向导(Next/Install/Finish)+ Desktop/开始菜单快捷方式;默认源码目录 `~/Jonathan/Jonathan-Ai` | | 自更新 | ✅ | 仅从 GoDeskio/Clawd-Code 检查并快进更新 | diff --git a/JonathanAi-0.4.8-source.zip b/JonathanAi-0.4.8-source.zip new file mode 100644 index 0000000..677bf57 Binary files /dev/null and b/JonathanAi-0.4.8-source.zip differ diff --git a/JonathanAi-Setup.exe b/JonathanAi-Setup.exe new file mode 100644 index 0000000..beda498 Binary files /dev/null and b/JonathanAi-Setup.exe differ diff --git a/JonathanAi.exe b/JonathanAi.exe new file mode 100644 index 0000000..eb177dc Binary files /dev/null and b/JonathanAi.exe differ diff --git a/MANIFEST.in b/MANIFEST.in index c5d58e2..b7286ff 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,12 +1,14 @@ include README.md include VERSION include LICENSE +include THIRD_PARTY_NOTICES.md include CLAUDE.md include MVP_PLAN.md recursive-include src *.py recursive-include src *.json recursive-include src/desktop/web * recursive-include src/install/web * +recursive-include src/skills/bundled * include desktop/package.json include desktop/main.js include desktop/preload.js diff --git a/README.md b/README.md index e7da0e4..84066a8 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,64 @@ **A local desktop and CLI agent, built as a Python reimplementation of Claude Code** -**Version 0.2.8** — Jonathan Ai is itself the AI agent. Chats persist on disk and survive restart. New Chat starts a blank thread. Shared memory (`~/.clawd/memory`) lets facts from one chat inform another. Internal workers can plan a goal in parallel without Cursor, Codex, or MCP. Session JSON is always UTF-8 (Windows no longer decodes as cp1252). Anthropic tool results that are objects are stringified so resumed chats do not 400. A first Anthropic chat — including desktop streaming — retries without tools if a schema 400 still appears. First launch repairs the venv and pip packages automatically. The Windows launcher finds `%USERPROFILE%\Jonathan\Jonathan-Ai`. +**Version 0.4.8** — Jonathan Ai's conversation window now reliably renders persisted user and assistant messages. If Windows or the embedded browser aborts the live response stream, the UI automatically resumes from the bounded job-events endpoint and reloads the saved transcript at completion instead of silently losing the answer. + +`RemoteTrigger` is now a real approval-gated remote-computer tool rather than a placeholder. It can probe an explicitly named host, execute commands through SSH or Windows Remote Management, and transfer files through SCP using existing OS credentials or SSH keys. It does not accept passwords in model input, defaults to strict SSH host-key verification, and cannot bypass the remote machine's authentication, ACL, firewall, UAC or sudo rules. + +Jonathan also retains native durable control for long-running work. `LongHorizonControl` keeps objectives, workspace scope, bounded turns, dependency-aware todos, agent claims/leases, human gates, evidence receipts, and handoffs in atomic UTF-8 state shared across conversations. Its continuation and closure decisions fail closed when judgment, evidence, prerequisites, or budget are missing. + +Local-model discovery now also reports on-disk model size, live runtime RAM, available system RAM, and conservative fit guidance before a large model is selected. The measurements are local and informational; Jonathan does not silently stop another runtime or send hardware details to a service. + +Every conversation also writes a redacted, tamper-evident append-only runtime ledger under `~/.clawd/events`. Provider context can stay bounded for speed without deleting job, tool, permission, completion, usage, or artifact evidence. Token counts remain persisted per conversation and agent and are informational only—there is no quota, payment, or purchase path. + +When a project contains `DESIGN.md`, Jonathan loads its UTF-8 visual system into a separate bounded context section for every conversation agent. This keeps project colors, typography, components, layout, responsive rules, and design constraints persistent without copying a third-party brand catalog into the application. + +The Device & System card also shows an earned-autonomy profile for each conversation and gated tool. It counts only distinct human decisions, uses a conservative Wilson confidence bound, and caps terminal, write, device, repository, and trading actions at approval-required. Recommendations are visible evidence—not authority—and can never enable a tool or full-device access without the user's explicit grant. + +Tool execution is audit-gated: after policy and user approval, Jonathan writes a secret-redacted `started` record before invoking the tool and a second record with success, refusal, or failure. If the preflight audit cannot be persisted, the side effect does not run. + +The 0.4.8 shell uses a quiet, neutral desktop system: compact native typography, solid surfaces, low-decoration hierarchy, consistent radii, clear focus rings, semantic state colors, and reduced-motion support. It keeps the dense operational layout instead of applying oversized website spacing. + +Pydantic AI was evaluated for typed outputs, broad model adapters, durable execution, evaluations, and telemetry. Jonathan keeps its existing single runtime for this release; adding a second agent loop would duplicate session, permission, provider, and tool state. Its strongest capabilities remain candidates for narrow future adapters rather than a wholesale dependency. + +The Workers control now offers **Fast**, **Balanced**, and **Verified** modes. Verified uses an additional independent model pass to challenge the worker outputs against the original goal and synthesize the final response; Fast avoids that latency for small jobs. These modes are explicit because deeper orchestration costs more time and provider tokens, and every worker/reviewer token is added to the owning conversation's informational total. + +### Media, 3D, and administrator tools + +- `ImageStudio`: create and convert PNG/JPEG/WebP/BMP/GIF/TIFF/PDF, add/remove text, inpaint, composite, crop, resize, rotate, flip, or call a configured OpenAI-compatible image endpoint. +- `ThreeDStudio`: make colored GLB/GLTF/OBJ/STL/PLY assets locally; use Blender for textures, BLEND/FBX/USD, advanced conversion, and high-definition Eevee/Cycles renders. The common `MLB` typo is accepted and corrected to `.glb`. +- `Artifact`: publish any file to the conversation download shelf, or safely ZIP a directory. +- `SystemAdmin`: inventory, monitor, evaluate, report, control approved processes/services, and install approved packages. Every tool action is written to a secret-redacted audit ledger at `~/.clawd/audit/actions.jsonl`. +- `DeviceControl`, `Terminal`, `PowerShell`, and `RemoteTrigger`: after explicit approval, launch installed applications, automate the signed-in desktop, run installed shells/CLIs, and administer explicitly named SSH/WinRM computers. Full-device mode expands local path scope but never bypasses Windows ACL/UAC or another computer's credentials and firewall. +- `Repository`: inspect, clone, fetch, pull, and push GitHub/GitLab repositories without merging; protected branches require explicit authorization. +- `AudioStudio`: generate deterministic original instrumental WAV beds and trim, normalize, or mix local PCM WAV files into conversation downloads without an account or cloud service. +- `SkillManager` and `SharedMemory`: create/validate/package reusable `SKILL.md` files and search/export durable cross-conversation memory. +- `LongHorizonControl`: coordinate durable goals across separate conversation agents with bounded turns, dependency-aware work, leases, judgment gates, evidence receipts, handoffs, and explicit continuation checks. +- `SelfHostedService`: register and operate approved Docker/Podman Compose, Windows-service, or systemd applications; inspect status/logs, create downloadable backups, check local/LAN health, and run lifecycle/update actions without shell interpolation. The `self-hosted-service-operator` skill is visible in Jonathan's skill list. +- `ECCIntegration`: synchronize, audit, import, update, enable/disable, and search ECC skills/agent templates; exchange unreviewed Memory Vault documents; and create reviewable skills from measured Git history. +- `BusinessManager`: durable local customers, projects, tasks, invoices, income/expense ledger, dashboard, and downloadable JSON/CSV/HTML business reports. +- `KronosForecast`: create built-in Mini/Small/Base probabilistic EWMA/Monte Carlo OHLCV research forecasts as downloadable CSV files; never places an order. +- `PersonalFinanceVault`: manage built-in SQLite accounts, transactions, categories, recurring items, budgets, goals, assets, reports, and currencies; no container, subscription, or payment is required. +- `CodeMemory`: build an incremental local code graph, search durable notes, find symbol usages, explain dependencies, and trace paths through the project using a Jonathan-owned SQLite database with no cloud account or Claude hooks. +- `Procoder`: run Jonathan's built-in fail-closed changed-file gate, detected project tests, secret/conflict hygiene, release readiness, and related engineering reports; it never installs hooks or edits the project. +- `EditableGraphics`: vectorize an uploaded raster diagram into editable SVG, native PowerPoint shapes, OCR text, a review PNG, and a JSON package with no separate model pack. +- `CharacterStudio`: generate deterministic animated SVG character rigs and editable JSON recipes from a seed without a model or API. +- `FinanceMarkets`: current/delayed quotes, price history, RSI/moving averages, multi-symbol screens, portfolio valuation, and timed watchlist reports. +- `Trading`: optional Alpaca paper/live brokerage connection. Credentials are entered directly in Connect Tools; paper is the default, and every order submission or cancellation requires a fresh approval. +- `PublicApiCatalog`: search Jonathan's audited offline starter index by category, authentication, HTTPS, or CORS, inspect the provider's documentation, and—with approval—create a persistent connector card. Provider terms, credentials, quotas, and data policies still apply. +- `writing-polish` skill: opt-in two-pass editing for surface clarity and document structure, calibrated to supplied voice samples while preserving facts, citations, uncertainty, and honest authorship. +- `interface-art-direction` skill: set deliberate layout-variance, motion-intensity, and information-density controls for new interfaces or evidence-based redesigns, with accessibility and responsive verification built in. +- `react-performance-review` skill: diagnose request waterfalls, client bundle cost, rendering boundaries, rerenders, expensive hot paths, and loading behavior using reproducible before/after evidence. +- `screenshot-to-interface` skill: turn an uploaded screenshot or mockup into maintainable code in the project's existing stack, then render and compare desktop/narrow previews before packaging the result. +- `SpeechStudio`: record only when the user clicks Voice, transcribe/translate locally with a Whisper-family model, and synthesize responses to downloadable WAV with the device voice engine. +- `ContentReach`: check public channel readiness, read RSS/Atom feeds, and retrieve public YouTube metadata/captions without silently reusing browser cookies. *From TypeScript Source → Rebuilt in Python with ❤️* *** -[![GitHub stars](https://img.shields.io/github/stars/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GPT-AGI/Clawd-Code/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GPT-AGI/Clawd-Code/network/members) +[![GitHub stars](https://img.shields.io/github/stars/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GoDeskio/Clawd-Code/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GoDeskio/Clawd-Code/network/members) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) @@ -193,7 +243,7 @@ clawd desktop # Start the desktop host The first-run wizard installs everything needed to run the desktop agent: it detects the OS, saves the full source tree under a **Jonathan** folder, creates a Python venv, installs backend and desktop-shell dependencies, writes provider config placeholders (no API keys), and verifies the agent can start a session. -**Chat on first run with only a local model or one API key.** After install, open Jonathan Ai, pick Anthropic / OpenAI / GLM / Hugging Face / Local LLM, and send a message. MCP servers, Cursor, Codex, and other agents are optional — they are not required and are omitted from the provider request until you connect one. Tokens stay on this machine. The product version is **0.2.8** (see the `VERSION` file, UI header, and Windows installer). +**Chat on first run with only a local model or one API key.** After install, Jonathan first discovers installed local runtimes and connects a reachable model automatically when no working provider exists. The installer prepares the built-in code memory, forecasting, engineering-gate, finance, editable-graphics, and long-horizon control engines; only the audited ECC catalog needs an optional online source sync. You can pick Anthropic / OpenAI / GLM / Hugging Face / Local LLM and send a message. MCP servers, Cursor, Codex, and other agents are optional — they are omitted from provider requests until connected. Tokens stay on this machine. The product version is **0.4.8**. **Windows (real desktop app):** @@ -337,7 +387,11 @@ Treat any OpenAI-compatible server as the **Local LLM** provider: | Hugging Face TGI (self-hosted) | `http://127.0.0.1:3000/v1` | | Custom | loopback or LAN URL + optional API key | -On first run and in settings, **Scan local ports** probes those addresses, lists available models, and lets you pick a default. Custom URLs that resolve to the public internet are rejected. Local endpoints are never bound or advertised on the WAN by Jonathan Ai. +On first run and in settings, **Scan local ports** also inventories installed runtimes and common model stores, starts a known Ollama/LM Studio service when needed, lists available models, and connects the first reachable model. Custom URLs that resolve to the public internet are rejected. Local endpoints are never bound or advertised on the WAN by Jonathan Ai. Credentials are scoped to their endpoint, so an Ollama credential is not sent to LM Studio. + +#### Jonathan local image generation + +Open **Connect tools → Local diffusion** to verify Jonathan's built-in Diffusers engine, generate or edit an image from an uploaded source, and publish outputs directly into the conversation. The installer supplies its Python packages; the first generation downloads the configured model (`stabilityai/sdxl-turbo` by default) into Jonathan's private cache. It uses CUDA when available and otherwise runs on CPU. Fooocus informed the product requirements, but Jonathan does not clone, launch, or depend on Fooocus, Gradio, or an upstream service. #### GitHub and GitLab @@ -387,7 +441,7 @@ python -m src.desktop --port 8765 This binds `http://127.0.0.1:8765/` only, serves the chat UI, and opens a browser. Use `--no-browser` in CI. -The dashboard is a glassmorphism prompt UI: conversations on the left (title + last activity; **double-click or right-click to rename**), frosted chat cards, and a monochrome + eye-glow palette matching the robot sketch. The header shows **v0.2.8** and the token meter (informational only). Conversations persist in the sidebar; New Chat is always empty. Shared memory is local only (`~/.clawd/memory`). Jonathan Ai is the agent — no second AI connection is required. +The dashboard is a compact prompt UI: conversations on the left (title + last activity; **double-click or right-click to rename**), clear chat cards, and a monochrome + eye-glow palette matching the robot sketch. The header shows **v0.4.8** and the token meter (informational only). Conversations persist in the sidebar; New Chat is always empty. Shared memory is local only (`~/.clawd/memory`), while durable goal governance is stored under `~/.clawd/goals`. User-authored skills are visible under `Jonathan-Ai/Skills`; managed ECC content is stored below `Skills/.managed/ecc` and only enabled entries are loaded. Jonathan Ai is the agent — no second AI connection is required. **Option B — Electron desktop shell (tray, notifications, folder picker)** @@ -414,8 +468,7 @@ Desktop extras on top of the CLI: - **New Chat** always opens a brand-new empty conversation (new id, empty transcript, reset token meter). Previous chats stay in the sidebar and survive restart. - Shared agent memory in `~/.clawd/memory` (facts + compact titles/summaries of other chats). Threads stay isolated; the model only sees a short brief, not full dumps. - Standalone agent: send message → stream reply → show tools → new chat → rename chat → informational token meter. **Workers** plans a goal into isolated internal workers (shared memory only). No required Cursor/Codex/MCP connection. -- Windows session JSON (0.2.8): `Session.load` / `save` always use `encoding='utf-8'` so a UTF-8 byte such as `0x8F` no longer crashes desktop start under cp1252. -- Anthropic 400 fix (0.2.7): `tool_result.content` that is a dict/object/list is `json.dumps`'d before `chat`, `chat_stream`, and `chat_stream_response`, including resumed sessions that already stored object-shaped tool results. Schema 400s (`tools.N.custom.input_schema.type`) still retry the same turn without tools. +- Anthropic 400 fix (0.2.7): `tool_result.content` dicts and ordinary lists are JSON-encoded before `chat`, `chat_stream`, and `chat_stream_response`, including old persisted sessions. Valid content-block lists are preserved. Unrelated 400/401/429 errors are not hidden by the tool-schema fallback. - Anthropic 400 fix (0.2.6): desktop streaming (`chat_stream_response`) and `chat()` send only `{name, description, input_schema:{type:object, properties}}`, including MCP/dynamic and custom-wrapped tools. If Anthropic still returns `tools.N.custom.input_schema.type: Field required`, the same turn retries with tools omitted so the first message still answers. First launch auto-installs missing venv/pip/Electron when possible. `/new` always opens a new empty session. Setup never merges `main` into this branch. - Workspace/folder picker (Electron dialog, or a path prompt in the browser) - Approve / deny / always-allow-this-session permission prompts @@ -489,6 +542,36 @@ Example: - Arguments: use `$ARGUMENTS`, `$0`, `$1`, or named args like `$path` (from `arguments`). - Placeholder syntax: use `$path`, not `${path}`. +#### ECC catalog and automatic skill learning + +Open **Skills library → ECC catalog** to synchronize the official `affaan-m/ECC` repository, run a full security scan, import the complete skills/agent-template catalog, and enable or disable individual skills. Jonathan records the exact upstream revision and MIT provenance. The complete catalog remains on disk, but only enabled entries are parsed into agent context; this avoids duplicate hooks and large prompt/startup costs. + +#### Native local market forecasting + +Open **Connect tools → Market forecasts** or use `KronosForecast` with a timestamped CSV containing a close column (OHLCV columns are accepted). Jonathan's built-in engine estimates recency-weighted log-return drift and volatility, generates bounded Monte Carlo paths, and exports median/P10/P90 forecast CSVs. It requires no cloned repository or model download. Results are research artifacts, not investment advice or automatic trading signals; validate them with walk-forward tests, transaction costs, slippage, portfolio constraints, and independent risk controls. + +#### Private personal finance vault + +Open **Connect tools → Personal finance vault** or use `PersonalFinanceVault`. Jonathan stores accounts, transactions, categories, recurring flags, budgets, savings goals, assets, and currency-separated totals in `~/.clawd/finance/personal-finance.sqlite`. It is an embedded UTF-8/WAL SQLite engine: no Docker, remote service, bank credential, subscription, or cloned finance application is required. + +#### Local code graph and durable code memory + +Open **Connect tools → Code memory** or use `CodeMemory`. Jonathan's built-in Python/SQLite engine incrementally scans supported source files, indexes symbols/calls/imports, performs live text search, and stores only explicitly requested notes in `~/.clawd/code_memory/jonathan-code-memory.sqlite`. No Node helper, Claude installer, hook, cloned repository, remote database, or account is used. Existing UTF-8 chat/session and shared-memory systems remain authoritative. + +Benjamin Plus was evaluated and adopted as a compact native workflow discipline in Jonathan's system prompt. Fullstack Agent was evaluated but not embedded because it is a setup script for a separate Claude/Obsidian stack. Procoder, DrawAI, Kindergrimm, and Scroll Craft were used only as licensed design references: their useful capabilities were rewritten as Jonathan's built-in engineering gate, editable-graphics engine, procedural character studio, and scroll-storytelling skill. + +OmniRoute informed Jonathan's in-process transient retry/circuit-breaker layer, but no gateway or cross-provider auto-fallback is installed: changing providers remains explicit so prompts, credentials, privacy boundaries, and possible charges cannot move unexpectedly. Task Observer informed the correction/failure/repeated-workflow review queue in automated skill learning; observations are sanitized, confidence scored, and never silently activated. Anthropic's Claude plugin catalog was evaluated, but its Claude-specific marketplace and hooks duplicate Jonathan's audited `SKILL.md`, MCP, connector, and agent systems and are not bundled. + +Headroom's provider-cache analysis informed a native Anthropic prompt-cache marker on Jonathan's system prefix. Jonathan does not install Headroom, route traffic through a proxy, compress user intent, discard turns, or add a retrieval service; the persistent UTF-8 transcript remains authoritative. + +Anthropic's community plugin marketplace was also evaluated. It is a directory of third-party sources rather than an application engine, and its entries have independent licenses, credentials, hooks, and runtime requirements. Jonathan does not bulk-install it; users connect MCP/external tools explicitly and enable reviewed skills individually. + +YuE and ACE-Step UI were evaluated for full-song generation. Both are valuable optional GPU systems, but their multi-gigabyte model stacks and hardware/runtime requirements would make a standard desktop install slower and less reliable. Jonathan therefore ships an always-available native `AudioStudio` baseline for original procedural WAV generation and editing. Users with a separately approved local music server can connect it through Jonathan's existing external/MCP connector; no cloned music UI is required. + +**Learn from project** analyzes up to 200 local Git commits. It measures commit prefixes, frequently changed areas, file types, co-change pairs, and test placement. Repository text and commit messages are sanitized as untrusted evidence, possible secrets are redacted, and the result is saved as a confidence-scored draft. Review the draft in the editor and click **Validate & save** before it becomes active. Jonathan never silently overwrites a user skill or automatically turns a draft into executable policy. + +Memory Vault export writes inspectable `ecc.memory.v1` Markdown under `~/.clawd/memory/vault`. Imported vault entries remain `unreviewed` context, reject credential/instruction-override patterns, and do not grant tools or permissions. + *** @@ -622,14 +705,14 @@ If you find this useful, please **star** ⭐ the repo! **本地桌面与 CLI Agent,基于真实 Claude Code 源码的 Python 重实现** -**版本 0.2.8** — Jonathan Ai 本身就是 AI Agent。会话会持久化;New Chat 总是空线程。跨会话记忆只存在本机 `~/.clawd/memory`。MCP / Cursor / Codex 均为可选。会话 JSON 始终按 UTF-8 读写,避免 Windows cp1252 解码失败。对象形状的 tool_result 会序列化为字符串,避免 Anthropic 400。若仍因工具 schema 返回 400,同一轮会去掉 tools 重试。Windows 启动器会在 `%USERPROFILE%\Jonathan\Jonathan-Ai` 找到 venv;若提示找不到 Electron/venv,请再运行 Setup 原地升级。 +**版本 0.3.0** — Jonathan Ai 本身就是 AI Agent。会话会持久化;New Chat 总是空线程。跨会话记忆只存在本机 `~/.clawd/memory`。MCP / Cursor / Codex 均为可选。新增图像、Blender 3D、系统管理、审计、仓库和技能工具。Windows 会话使用原子 UTF-8 持久化。启动器会在 `%USERPROFILE%\Jonathan\Jonathan-Ai` 找到 venv。 *从 TypeScript 源码 → 用 Python 重建 ❤️* *** -[![GitHub stars](https://img.shields.io/github/stars/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GPT-AGI/Clawd-Code/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GPT-AGI/Clawd-Code/network/members) +[![GitHub stars](https://img.shields.io/github/stars/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GoDeskio/Clawd-Code/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GoDeskio/Clawd-Code/network/members) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) @@ -811,7 +894,7 @@ clawd desktop # 启动桌面 host ### 安装 ```bash -git clone https://github.com/GPT-AGI/Clawd-Code.git +git clone https://github.com/GoDeskio/Clawd-Code.git cd Clawd-Code # 创建虚拟环境(推荐使用 uv) @@ -881,7 +964,7 @@ Windows:双击 `packaging/windows/bin/JonathanAi-Setup.exe`,向导点 Next / ./install.sh --yes # 默认源码目录:~/Jonathan/Jonathan-Ai # 只从 https://github.com/GoDeskio/Clawd-Code 安装与更新 -# 版本 0.2.8:会话 JSON 用 UTF-8;对象 tool_result 序列化为字符串;会话持久化;/new 与 New Chat 均为空线程;跨会话记忆在 ~/.clawd/memory;首次启动自动补依赖;Setup 原地升级且不合并 main +# 版本 0.3.0:图像与 Blender 3D、系统管理与审计、仓库自动化、自建技能、持久共享记忆;Setup 原地升级且不合并 main # 安装后可在向导或桌面设置中连接 Hugging Face / 本地 LLM / GitHub / GitLab / MCP,无需重装 # Token 只保存在 ~/.clawd/config.json,不会写入安装包或 git # 聊天窗口的 token 计数只做展示,不是付费墙 @@ -903,7 +986,7 @@ python -m src.cli desktop # Python host + 浏览器 UI cd desktop && npm install && npm start # Electron 壳 ``` -密钥不会写入 Git。桌面端复用现有 agent loop、工具、skills 与会话。Jonathan Ai 本身就是 Agent:发送消息、流式回复、显示工具、新建/重命名会话、信息性 token 计数、provider 设置。不需要第二个 AI 连接。头部显示版本 **0.2.8**。内部 Workers 可并行拆任务,不需要其它 Agent。桌面流式请求若遇 Anthropic schema 400 会去掉 tools 重试。对象 tool_result 会先转成字符串。会话会留在侧栏,New Chat 总是新的空对话。若桌面快捷方式仍指向上级 Jonathan 文件夹,请再运行 Setup。 +密钥不会写入 Git。桌面端复用现有 agent loop、工具、skills 与会话。Jonathan Ai 本身就是 Agent:发送消息、流式回复、显示工具、新建/重命名会话、信息性 token 计数、provider 设置。不需要第二个 AI 连接。头部显示版本 **0.3.0**。内部 Workers 可并行拆任务,不需要其它 Agent。会话会留在侧栏,New Chat 总是新的空对话。 *** diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..88beb36 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,584 @@ +# Third-party notices + +Jonathan Ai includes or adapts selected permissively licensed reference material. +Product and company names are not used as Jonathan branding; attribution below is +retained because the licenses require it. + +## LoopX design reference + +Jonathan's independent `LongHorizonControl` was informed by the durable-state, +bounded-turn, judgment-gate, claim/lease and evidence-receipt architecture in +`huangruiteng/loopx`, offered under Apache-2.0 and MIT licenses. Jonathan does +not include or require upstream source, prompts, adapters, dashboard, PWA, +Tauri shell, branding, package, service or runtime. See +https://github.com/huangruiteng/loopx. + +## Scraping API catalog evaluation + +`cporter202/scraping-apis-for-devs` was evaluated as a discovery source. It is +a large directory of changing third-party offerings—predominantly hosted +Apify actors—and does not show a top-level software license. Jonathan does not +copy the catalog, descriptions, links, branding, actors, credential flows, or +vendor dependencies. Safe API discovery remains in Jonathan's audited native +catalog and explicit connectors. See +https://github.com/cporter202/scraping-apis-for-devs. + +## OGAM design reference + +Jonathan's local-model resource display was informed by the visible model +residency and memory-fit concepts in `off-grid-ai/OGAM`, licensed under MIT. +Jonathan independently measures its already supported desktop runtimes and +does not include upstream React Native code, Android/iOS native modules, +models, mobile application, paid Pro features, branding, service or runtime. +See https://github.com/off-grid-ai/OGAM. + +## Awesome Selfhosted design reference + +The service-discovery and operator workflow at +`awesome-selfhosted/awesome-selfhosted`, licensed CC BY-SA 3.0, was evaluated. +Jonathan independently implements a generic lifecycle tool and concise +operator skill; it does not copy or bundle the upstream catalog, descriptions, +non-free list, logos, branding, generated site, or any listed application. +Each discovered service remains subject to its own current license and official +deployment documentation. See +https://github.com/awesome-selfhosted/awesome-selfhosted. + +## Local diffusion design reference + +Jonathan's local image requirements were informed by `lllyasviel/Fooocus`, +licensed under GPL-3.0. Jonathan contains an independent Diffusers engine and +does not include or require Fooocus source, Gradio, checkpoints, branding, or +runtime. See https://github.com/lllyasviel/Fooocus. + +## Provider resilience design reference + +Jonathan's independent selected-provider retry and circuit-breaker layer was +added after reviewing the local gateway/resilience model in +`diegosouzapw/OmniRoute`, licensed under MIT. No OmniRoute source, gateway, +provider catalog, Node runtime, dashboard, account, or service is included or +required. See https://github.com/diegosouzapw/OmniRoute. + +## Task-observation design reference + +Jonathan's independent friction-evidence review queue was informed by Task +Observer / `rebelytics/one-skill-to-rule-them-all`, created by Eoghan Henn +(rebelytics.com) and licensed CC BY 4.0. No upstream skill text, reference +bundle, Claude hooks, or runtime is included or required. See +https://github.com/rebelytics/one-skill-to-rule-them-all. + +## Claude plugin catalog evaluation + +The `anthropics/claude-plugins-official` catalog was evaluated for portable +skill and MCP conventions. No catalog, plugin, hook, command, agent, MCP +server, marketplace runtime, or third-party credential is included or +automatically enabled; individual catalog entries carry their own licenses. +See https://github.com/anthropics/claude-plugins-official. + +## Context-cache design reference + +Jonathan's independent Anthropic stable-prefix cache marker was added after +reviewing the context/cache architecture documented by +`headroomlabs-ai/headroom`, licensed under Apache-2.0. No Headroom source, +proxy, Rust extension, ML model, MCP server, compressor, account, or runtime is +included or required. See https://github.com/headroomlabs-ai/headroom. + +## Community plugin catalog evaluation + +The Apache-2.0 index at `anthropics/claude-plugins-community` was evaluated. +No marketplace index, community plugin, hook, command, agent, MCP server, +credential, or third-party runtime is included or automatically enabled; +individual entries have separate licenses and trust requirements. See +https://github.com/anthropics/claude-plugins-community. + +## Generative-music evaluations + +The Apache-2.0 YuE model/repository and MIT `fspecii/ace-step-ui` were evaluated +for local full-song workflows. Jonathan's procedural WAV engine is an +independent implementation and includes no YuE or ACE-Step code, weights, +frontend, Gradio service, FFmpeg/Demucs bundle, branding, or runtime. See +https://github.com/multimodal-art-projection/YuE and +https://github.com/fspecii/ace-step-ui. + +## Agent learning and skill library + +The bundled skill library and design references are derived from the +`NousResearch/hermes-agent` project at revision +`057dcdf236f8a6a26721c10fcc6ccb72726e272a`. + +MIT License + +Copyright (c) 2025 Nous Research + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Individual skill packages may carry additional author and license metadata in +their own `SKILL.md` or `LICENSE` files. Those notices are preserved alongside +the package. + +## Everything Claude Code integration + +Jonathan Ai can synchronize and adapt the `affaan-m/ECC` project. Managed +content records the exact upstream revision and retains this notice. The ECC +catalog is not Jonathan branding and is not enabled wholesale in prompts. + +MIT License + +Copyright (c) 2026 Affaan Mustafa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Kronos financial-market design reference + +Jonathan's independent EWMA/Monte Carlo research-forecast engine was added +after evaluating the market-specific modeling workflow documented by +`shiyu-coder/Kronos`, licensed under MIT. No upstream source, model code, +weights, checkout, branding, or runtime is included or required. See +https://github.com/shiyu-coder/Kronos. + +Copyright (c) 2025 Yu Shi and Kronos contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Securo personal-finance design reference + +Jonathan's independent SQLite personal-finance vault was informed by the +feature taxonomy publicly documented by `securo-finance/securo`, licensed +under GNU AGPL-3.0. No Securo source, container, frontend, service, branding, +database schema, or runtime is copied, linked, or required. See +https://github.com/securo-finance/securo. + +## Apache Maka design reference + +Jonathan's append-only runtime-fact ledger and parts of the compact desktop +state vocabulary were informed by the documented architecture and design +principles of Apache Maka (Incubating), licensed under Apache License 2.0. +Jonathan's implementation is independent Python/CSS code; no Maka runtime, +branding, logo, or package is included. See https://github.com/apache/maka. + +Apache Maka +Copyright 2025-2026 The Apache Software Foundation + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy at +https://www.apache.org/licenses/LICENSE-2.0 . Unless required by applicable +law or agreed to in writing, software distributed under the License is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. + +## Universal Earned Autonomy Layer design reference + +Jonathan's local approval-evidence profiles were informed by the trust, +risk-ceiling, replay-resistance, and explicit-enactment design documented in +`MMVFIRM/Universal-Earned-Autonomy-Layer` at revision +`174dd650c23862c1703f842d6bc8fc01c0973eaf`, licensed under Apache License +2.0. Jonathan's implementation is independent and remains part of its existing +permission enforcement; no upstream runtime, branding, keys, service, or +production-deployment claim is included. See +https://github.com/MMVFIRM/Universal-Earned-Autonomy-Layer. + +## OpenBot design evaluation + +The preflight-audit gateway pattern was informed by the documented design of +`CopilotKit/OpenBot`, licensed under MIT. Jonathan's implementation is +independent Python code in its existing dispatcher. No OpenBot runtime, +branding, CopilotKit service, license token, container image, or source code is +included. See https://github.com/CopilotKit/OpenBot. + +## Scandinavian Design evaluation + +Jonathan's interface was evaluated against the public design guidance and +demonstration harness in `ericzakariasson/scandinavian-design`. That repository +does not publish a license file, so Jonathan includes none of its skill text, +scripts, media, CSS, or other source. Jonathan's neutral compact desktop styles +are an independent implementation. See +https://github.com/ericzakariasson/scandinavian-design. + +## Autoprompt Skill design reference + +Jonathan's opt-in verified-worker mode was informed by the independent-judgment +and explicit-concurrency principles documented in `Spielewoy/autoprompt-skill` +at revision `91dc6edf336abea96ceb10eaf7efab77ca4c7007`, licensed under MIT. +Jonathan's implementation is independent Python/HTML code for its existing +internal workers; no upstream installer, provider adapter, branding, or skill +payload is included. See https://github.com/Spielewoy/autoprompt-skill. + +## Claude DB code-memory design reference + +Jonathan's independent Python/SQLite code graph was informed by the documented +incremental graph and explicit-memory workflow in `Avijit07x/claude-db` at +revision `6f2fc33cfe6fd70ce2b05393ecfbdd3224c41a03`, licensed under Apache +License 2.0. No upstream source, Node runtime, database adapter, Claude hook, +checkout, or binary is included or required. See +https://github.com/Avijit07x/claude-db. + +## Benjamin Plus design reference + +Jonathan's compact agent-efficiency guidance was informed by the measured +workflow rules in `JetBrains/benjamin-plus-skill` at revision +`0f7b2df6887d18afb73e60b3a696e765e86eea5b`, licensed under MIT. Jonathan +uses an independent shortened prompt compatible with its own tools and does +not install upstream hooks. See +https://github.com/JetBrains/benjamin-plus-skill. + +## Fullstack Agent evaluation + +`jaredrhod/fullstack-agent` at revision +`d3c7374c89680628f38e1e63aa9d64a0a7170f7b` was evaluated under AGPL-3.0. +It is a setup conductor for separate Claude Code, Obsidian, voice, browser, +and webcam repositories. No source, branding, runtime, or installer from it +is included in Jonathan. See https://github.com/jaredrhod/fullstack-agent. + +## Procoder design reference + +Jonathan's independent engineering gate was informed by the fail-closed, +unchecked-is-not-green contract documented in `azrtydxb/procoder` at revision +`16122832b5d1c858b4792ad14798d02b0931039d`, licensed under Apache License +2.0. No upstream binary, source, plugin, hook, configuration, or checkout is +included or required. See https://github.com/azrtydxb/procoder. + +## DrawAI design reference + +Jonathan's independent raster-to-editable SVG/PPTX engine was informed by the +public artifact goals documented in `Renaissance-Mind/DrawAI` at revision +`b7a43c651cf9fe10a771728b0bdcd7557d762602`, licensed under Apache License +2.0. No upstream source, runtime, model, workbench, checkout, or model terms +are included or required. See https://github.com/Renaissance-Mind/DrawAI. + +## Kindergrimm design reference + +Jonathan's independent deterministic SVG character recipe/rig engine was +informed by `albertobeiz/kindergrimm` at revision +`5857b1e1cae2713d6714ad7dd7f89626bb242f0f`, released under the Unlicense. +No upstream game, source, artwork, branding, or checkout is included. See +https://github.com/albertobeiz/kindergrimm. + +## Scroll Craft design reference + +Jonathan's independent `scroll-storytelling` skill was informed by the +design-floor and verification concepts documented in +`nateherkai/scroll-craft`, licensed under MIT. No upstream engine, scripts, +templates, media, plugin, generated assets, or checkout is included. See +https://github.com/nateherkai/scroll-craft. + +## Hermes3D design reference + +Jonathan's independent spatial agent-floor UI was informed by the fleet +visibility goal documented in `iamlukethedev/Hermes3D`, licensed under MIT. +No upstream 3D office, Three.js/Next.js frontend, gateway, adapter, token, +asset, source, or checkout is included. See +https://github.com/iamlukethedev/Hermes3D. + +## Named-agent roster reference + +The named-agent roster design was informed by `NousResearch/Hermes-Bot-Mode` +at revision `80fee22582b871b9765a65e2992b8b5a8211c9f8`, licensed under MIT. +Jonathan's roster implementation is independent and uses Jonathan's existing +session runtime. + +## Coding harness reference + +Checkpoint, interruption, and recovery design was informed by +`xai-org/grok-build` at revision +`07b2f7144fd5c5c9d3dd1966937a87852d2dbdb8`, licensed under the Apache +License 2.0. Jonathan's implementation is independent; no vendor branding or +service authentication code is included. + +The unofficial reconstructed desktop repository was reviewed only to +understand general interaction ideas. Its notice grants no source license, so +no source code or binary from it is included in Jonathan Ai. + +## Awesome LLM Apps evaluation + +`Shubhamsaboo/awesome-llm-apps`, licensed under Apache License 2.0, was +evaluated as a cookbook of self-contained agent, RAG, MCP, voice and skill +examples. Jonathan already implements those foundations in its own runtime, so +no template, prompt pack, application, dependency, branding, tutorial content, +or upstream checkout is included. See +https://github.com/Shubhamsaboo/awesome-llm-apps. + +## Public APIs design reference + +Jonathan's independent offline `PublicApiCatalog` uses the general +category/authentication/HTTPS/CORS discovery vocabulary documented by +`public-apis/public-apis`, licensed under MIT. Jonathan ships a deliberately +small, independently maintained starter index and does not copy, download, +mirror, execute, or depend on the upstream catalog or validation scripts. Each +provider's own documentation and terms remain authoritative. See +https://github.com/public-apis/public-apis. + +## Humanizer Stack design evaluation + +The two-pass surface/structure separation documented by +`NulightJens/humanizer-stack` was evaluated. The repository's own work is MIT, +while its attribution file identifies material under additional terms including +CC BY-SA 4.0. Jonathan therefore includes no upstream skill text, reference +material, scanner, research digest, installer, or symlink. The bundled +`writing-polish` skill is an independent implementation focused on meaning, +voice, clarity, structural variety and honest authorship. See +https://github.com/NulightJens/humanizer-stack. + +## AI Workflow Hub 2000 evaluation + +`emretasss/AI-Workflow-Hub-2000-` was evaluated as a collection of n8n workflow +JSON. The repository does not publish a license file, and the workflows can +reference external credentials and side effects. Jonathan therefore includes +none of its workflows, documentation, branding, hosted service, or source and +does not clone or execute the repository. Jonathan's existing cron, connector, +worker, permission and preflight-audit systems remain the supported native +automation path. See +https://github.com/emretasss/AI-Workflow-Hub-2000-. + +## JustInCache n8n Workflows evaluation + +`JustInCache/n8n-workflows`, licensed under MIT, was evaluated for its +searchable workflow catalog and validation claims. The collection requires a +separate n8n runtime (which has its own licensing terms) and workflows can hold +credentials or invoke open-world side effects. Jonathan therefore includes no +workflow corpus, FastAPI/SQLite catalog service, container/Kubernetes stack, +branding, or automatic execution path. Jonathan's native catalogs, connector +cards, cron tasks, permission gateway and execution ledger remain the supported +automation boundary. See https://github.com/JustInCache/n8n-workflows. + +## Open Generative AI evaluation + +`Anil-matcha/Open-Generative-AI`, licensed under MIT, was evaluated for its +image, video, lip-sync and multi-reference studio surfaces. Its documented +model fleet requires a MuAPI access key/cloud gateway and its Electron/Next.js +application duplicates Jonathan's shell and media workspace. Jonathan includes +no upstream source, UI, submodule, model registry, gateway client, branding, +installer, content-policy bypass, or paid-service dependency. Jonathan's native +local engines and explicitly configured ComfyUI/external connectors remain the +supported media paths. See +https://github.com/Anil-matcha/Open-Generative-AI. + +## Taste Skill design reference + +Jonathan's independent `interface-art-direction` skill was informed by the +adjustable layout-variance, motion-intensity and information-density concept +documented by `Leonxlnx/taste-skill`, licensed under MIT. Jonathan includes no +upstream skill wording, experimental prompt, GSAP skeleton, reference image, +research text, example, branding, shell script, or checkout. Its implementation +uses Jonathan's own accessibility and verification requirements. See +https://github.com/Leonxlnx/taste-skill. + +## Vercel Labs Agent Skills evaluation + +`vercel-labs/agent-skills` was evaluated for its React and web-design guidance. +At the time of evaluation the repository README stated MIT, but the repository +had no top-level license file and maintained open issues requesting one. +Jonathan therefore includes no upstream skill wording, examples, scripts, +assets, package, or checkout. The bundled `react-performance-review` skill is +an independent implementation based on generally applicable measurement, +waterfall, bundle, rendering-boundary and verification practices. See +https://github.com/vercel-labs/agent-skills. + +## Awesome Design MD reference + +Jonathan's bounded project-owned `DESIGN.md` context loading was informed by +the persistent plain-text design-system workflow documented by +`VoltAgent/awesome-design-md`, licensed under MIT. Jonathan includes no +third-party brand analysis, extracted CSS tokens, preview HTML, trademarked +visual identity, catalog, hosted service, or upstream checkout. Only the user's +own project-root `DESIGN.md` is loaded. See +https://github.com/VoltAgent/awesome-design-md. + +## gztchan Awesome Design evaluation + +`gztchan/awesome-design` was evaluated as a CC0-style curated list of external +design resources. It contains links rather than an application engine, and +each linked stock, icon, font, prototyping, testing, book, event or community +resource has its own availability and terms. Jonathan includes no copied list, +description, third-party asset, branding, or checkout; live web discovery and +the native design/connector systems remain the supported path. See +https://github.com/gztchan/awesome-design. + +## Screenshot to Code design reference + +Jonathan's independent `screenshot-to-interface` skill was informed by the +image-to-code and rendered-preview verification workflow documented by +`abi/screenshot-to-code`, licensed under MIT. Jonathan includes no upstream +source, prompts, frontend, backend, assets, branding, model routing, Chromium +runtime, API-key requirement, hosted service, or checkout. It uses Jonathan's +existing attachment, vision, coding, preview and artifact systems. See +https://github.com/abi/screenshot-to-code. + +## OpenCode evaluation + +`anomalyco/opencode`, licensed under MIT, was evaluated for its terminal and +desktop clients, build/plan/general agents, provider support, session model, +LSP, MCP and permission controls. Those foundations overlap Jonathan's native +desktop/CLI, independent conversation agents, Plan Mode, provider adapters, +connectors, language tools and audited approval gateway. Jonathan includes no +OpenCode source, Bun/Tauri runtime, provider catalog, UI, branding, hosted +service, installer, binary, prompts, configuration, or upstream checkout. See +https://github.com/anomalyco/opencode. + +## OpenCode Voice and Whisper design references + +Jonathan's user-initiated voice interaction and raw-transcript fallback were +informed by `renjfk/opencode-voice`, licensed under MIT. Jonathan's local +multilingual transcription is compatible with the model architecture and MIT +model weights published by `openai/whisper`; the standard install uses the +independent `faster-whisper` runtime for practical Windows/Python support. +Jonathan includes no upstream OpenCode plugin, prompts, SoX, Piper, global +keybinds, hosted endpoint, mandatory LLM normalization, background microphone +listener, Whisper source checkout, or branding. Model weights download only +when transcription is first requested. See https://github.com/renjfk/opencode-voice +and https://github.com/openai/whisper. + +## ScrapeGraphAI evaluation + +`ScrapeGraphAI/Scrapegraph-ai`, licensed under MIT, was evaluated for its +prompt-driven single/multi-page extraction, local-model support and graph +pipelines. Jonathan already composes WebSearch, WebFetch, browser automation, +local/cloud providers, internal workers and structured outputs inside its own +audited permission boundary. Jonathan includes no upstream scraper source, +Playwright browser download, second provider abstraction, hosted-credit API, +telemetry, proxy/anti-bot integration, generated scraper, branding or checkout. +Sites' access rules, terms, privacy and user approval remain authoritative. See +https://github.com/ScrapeGraphAI/Scrapegraph-ai. + +## Agent Reach design reference + +Jonathan's independent `ContentReach` tool was informed by the per-channel +health/routing goal documented by `Panniantong/Agent-Reach`, licensed under +MIT. Jonathan includes no upstream CLI, channel modules, installer, skills, +configuration, browser session integration, cookies, proxy recommendations, +sponsored services, Jina/Exa/OpenCLI dependency, Bilibili/X/Reddit/Xiaohongshu +adapter, branding or checkout. Public RSS and YouTube support use declared +libraries; authenticated services require explicit Jonathan connectors and +user approval. See https://github.com/Panniantong/Agent-Reach. + +## Scrapling design evaluation + +`D4Vinci/Scrapling`, licensed under BSD-3-Clause, was evaluated for its DOM +selection, adaptive crawl, export, throttling, robots and browser features. +Jonathan independently strengthened WebFetch's visible-text boundary by +dropping executable, template, head and hidden regions before model context. +Jonathan includes no upstream parser/runtime, agent skill, MCP server, browser, +stealth fingerprinting, CAPTCHA/Turnstile bypass, cookies, proxy rotation, +anti-bot behavior, sponsor integration, branding or checkout. See +https://github.com/D4Vinci/Scrapling. + +## Higgsfield AI Skills evaluation + +`higgsfield-ai/skills`, licensed under MIT, was evaluated for its hosted +image/video/audio generation, identity training, brand/product assets, +thumbnails, explainer videos, website deployment and game workflows. The +skills install and authenticate a Higgsfield vendor CLI and route work to its +hosted model catalog. Jonathan includes no upstream skill wording, prompts, +scripts, CLI, plugin manifest, assets, brand kit, model identifiers, account, +hosted job dependency, branding or checkout. Jonathan's native media, design, +3D, website, artifact and editable skill systems remain provider-neutral. See +https://github.com/higgsfield-ai/skills. + +## Higgsfield training framework evaluation + +`higgsfield-ai/higgsfield`, licensed under Apache License 2.0, was evaluated as +an early distributed GPU workload/training framework. It requires remote +Ubuntu GPU nodes, Docker, SSH/deploy keys, passwordless sudo, GitHub Actions +and tightly coupled distributed-training dependencies. Those prerequisites are +inappropriate for a standard Windows desktop install and would broaden system +authority substantially. Jonathan includes no upstream orchestrator, training +code, container setup, workflow, credentials, branding or checkout; advanced +clusters remain explicit user-configured infrastructure. See +https://github.com/higgsfield-ai/higgsfield. + +## Augmented Unreality evaluation + +`adynathos/AugmentedUnreality` was evaluated as a possible augmented-reality +feature reference. It targets Unreal Engine 4.19 and implements camera/video +capture, calibration and ArUco marker tracking through Unreal-native code, +OpenCV-contrib and optional GStreamer. Those engine- and platform-specific +requirements do not add a generally usable capability to Jonathan's existing +desktop image understanding, editing and 3D artifact tools. No source, assets, +branding, binary, plugin or runtime dependency from the project is bundled. + +Project: https://github.com/adynathos/AugmentedUnreality. + +## Unreal Augmented Reality Toolkit evaluation + +`lmalave/unreal-augmented-reality-toolkit`, licensed under MIT, was evaluated +as a possible media/AR feature reference. The repository's implemented scope is +limited to copying webcam RGBA frames into an Unreal Engine dynamic texture; +marker detection and further computer-vision behavior are roadmap items. This +does not improve Jonathan's native attachment previews, image understanding or +editing tools enough to justify an Unreal/OpenCV SDK build. No source, binary, +asset, branding or runtime dependency from the project is bundled. + +Project: https://github.com/lmalave/unreal-augmented-reality-toolkit. + +## Unreal Engine MCP design reference + +`flopperam/unreal-engine-mcp`, licensed under MIT, was evaluated for live Unreal +Editor automation. Jonathan independently implements the compatible high-level +workflow in native `UnrealStudio`: local editor/project discovery, descriptor +inspection, generation of reviewable project-scoped scene scripts, and an +approval-gated `UnrealEditor-Cmd` execution path. No hosted service, API key, +brand, prompt/skill text, TCP server, C++ plugin, source or binary from the +project is bundled. + +Reference: https://github.com/flopperam/unreal-engine-mcp. + +## 9router evaluation + +`decolua/9router`, licensed under MIT, was evaluated for provider routing, +quota tracking and request translation. Its separate localhost proxy, broad +OAuth/API credential store, multi-account rotation, content compression, +request logging and automatic cross-provider fallback overlap Jonathan's +native provider adapters, explicit connector vault, informational token meters +and selected-provider resilience. Automatically moving prompts among providers +can change privacy, billing, model behavior and tool semantics, so Jonathan does +not import or silently enable that architecture. No source, prompt modifier, +branding, cloud service or runtime dependency from the project is bundled. + +Project: https://github.com/decolua/9router. diff --git a/VERSION b/VERSION index a45be46..cb498ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.8 +0.4.8 diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 163eeee..58a38c0 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -2,7 +2,7 @@ appId: io.godesk.jonathanai productName: Jonathan Ai copyright: MIT directories: - output: ../packaging/windows/bin/electron + output: ../packaging/windows/build/electron files: - main.js - preload.js @@ -16,4 +16,6 @@ win: - target: dir arch: - x64 -electronVersion: 36.0.0 +electronVersion: 44.0.0 +electronDownload: + cache: ../packaging/windows/build/cache/electron diff --git a/desktop/main.js b/desktop/main.js index 8d6e1c0..c316836 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -1,15 +1,26 @@ -const { app, BrowserWindow, Tray, Menu, Notification, dialog, clipboard, desktopCapturer, nativeImage, ipcMain } = require("electron"); -const { spawn } = require("child_process"); +const { app, BrowserWindow, Tray, Menu, Notification, dialog, clipboard, desktopCapturer, nativeImage, ipcMain, shell } = require("electron"); +const { spawn, spawnSync } = require("child_process"); const fs = require("fs"); const http = require("http"); +const net = require("net"); const os = require("os"); const path = require("path"); const HOST = "127.0.0.1"; -const PORT = Number(process.env.CLAWD_DESKTOP_PORT || 8765); +let PORT = Number(process.env.CLAWD_DESKTOP_PORT || 8765); const TOKEN = process.env.CLAWD_DESKTOP_TOKEN || require("crypto").randomBytes(18).toString("hex"); app.setName("Jonathan Ai"); +// The packaged shell must also run on Windows systems where Chromium's GPU +// helper DLL/runtime is unavailable. This UI is lightweight and needs no GPU. +app.disableHardwareAcceleration(); +app.commandLine.appendSwitch("in-process-gpu"); +// Keep Electron's singleton lock/cache beside Jonathan's local configuration. +// Some Windows profiles deny lock-file creation in the legacy roaming AppData +// directory, which made the launcher silently fall back to a browser window. +const USER_DATA = path.join(os.homedir(), ".clawd", "electron"); +fs.mkdirSync(USER_DATA, { recursive: true }); +app.setPath("userData", USER_DATA); function hasCliAndRuntime(dir) { const cli = path.join(dir, "src", "cli.py"); @@ -41,6 +52,25 @@ function resolveRoot() { } const ROOT = resolveRoot(); +const PROCESS_RECORD = path.join(ROOT, ".jonathan-ai-processes.json"); +const RUN_DIR = path.join(os.homedir(), ".clawd", "run"); +const LOG_PATH = path.join(RUN_DIR, "desktop.log"); + +fs.mkdirSync(RUN_DIR, { recursive: true }); + +function logRuntime(event, details = {}) { + const payload = { + at: new Date().toISOString(), + event, + pid: process.pid, + ...details, + }; + try { + fs.appendFileSync(LOG_PATH, `${JSON.stringify(payload)}\n`, "utf8"); + } catch (_err) { + // Logging must never prevent the desktop from starting. + } +} let mainWindow = null; let tray = null; @@ -55,17 +85,37 @@ function pythonCommand() { return process.platform === "win32" ? "python" : "python3"; } +function runtimeReady() { + try { + const version = fs.readFileSync(path.join(ROOT, "VERSION"), "utf8").trim(); + const marker = path.join(ROOT, `.jonathan-ai-runtime-${version}.ready`); + const electron = path.join(ROOT, "desktop", "node_modules", "electron", "dist", process.platform === "win32" ? "electron.exe" : "electron"); + return fs.existsSync(pythonCommand()) && fs.existsSync(electron) && fs.existsSync(marker); + } catch (_err) { + return false; + } +} + function bootstrapDeps() { - const { spawnSync } = require("child_process"); + if (runtimeReady()) { + logRuntime("bootstrap_skipped", { reason: "runtime marker present" }); + return true; + } + logRuntime("bootstrap_started"); try { - spawnSync(pythonCommand(), ["-m", "src.install.bootstrap", "--source-dir", ROOT], { + const result = spawnSync(pythonCommand(), ["-m", "src.install.bootstrap", "--source-dir", ROOT], { cwd: ROOT, env: { ...process.env, CLAWD_SOURCE_DIR: ROOT }, - timeout: 180000, + timeout: 7200000, stdio: "ignore", }); - } catch (_err) { + const ok = result.status === 0; + logRuntime("bootstrap_finished", { ok, status: result.status, signal: result.signal || "" }); + return ok; + } catch (error) { + logRuntime("bootstrap_failed", { error: String(error?.message || error) }); // Setup can still repair the venv. Chat host starts anyway. + return false; } } @@ -73,46 +123,120 @@ function startPython() { const args = ["-m", "src.cli", "desktop", "--host", HOST, "--port", String(PORT), "--no-browser", "--token", TOKEN]; python = spawn(pythonCommand(), args, { cwd: ROOT, - env: { ...process.env, CLAWD_DESKTOP_TOKEN: TOKEN }, + env: { ...process.env, CLAWD_DESKTOP_TOKEN: TOKEN, CLAWD_DESKTOP_MANAGED_BY_ELECTRON: "1" }, stdio: ["ignore", "pipe", "pipe"], }); - python.stdout.on("data", (chunk) => process.stdout.write(chunk)); - python.stderr.on("data", (chunk) => process.stderr.write(chunk)); + logRuntime("backend_spawned", { backend_pid: python.pid || null, port: PORT }); + python.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + logRuntime("backend_stdout", { text: String(chunk).trim().slice(0, 4000) }); + }); + python.stderr.on("data", (chunk) => { + process.stderr.write(chunk); + logRuntime("backend_stderr", { text: String(chunk).trim().slice(0, 4000) }); + }); + python.on("error", (error) => { + logRuntime("backend_spawn_error", { error: String(error?.message || error) }); + }); python.on("exit", (code) => { + logRuntime("backend_exited", { code }); if (!app.isQuiting && code) { dialog.showErrorBox("Jonathan Ai backend stopped", `Python host exited with code ${code}`); } }); + writeProcessRecord(); +} + +function writeProcessRecord() { + const payload = { + source_dir: ROOT, + owner_pid: process.pid, + backend_pid: python?.pid || null, + kind: "electron", + port: PORT, + owner_executable: process.execPath, + updated_at: new Date().toISOString(), + }; + const temp = `${PROCESS_RECORD}.${process.pid}.tmp`; + fs.writeFileSync(temp, JSON.stringify(payload, null, 2), "utf8"); + fs.renameSync(temp, PROCESS_RECORD); +} + +function removeProcessRecord() { + try { + const payload = JSON.parse(fs.readFileSync(PROCESS_RECORD, "utf8")); + if (Number(payload.owner_pid) === process.pid) fs.unlinkSync(PROCESS_RECORD); + } catch (_err) { + // Missing/stale process records are repaired on the next launch or upgrade. + } } function waitForHealth(timeoutMs = 60000) { const started = Date.now(); return new Promise((resolve, reject) => { + let settled = false; + let retryTimer = null; const attempt = () => { - const req = http.get({ host: HOST, port: PORT, path: "/api/health", timeout: 1000 }, (res) => { + if (settled) return; + const req = http.get({ + host: HOST, + port: PORT, + path: "/api/ready", + timeout: 1000, + headers: { "X-Clawd-Token": TOKEN }, + }, (res) => { if (res.statusCode === 200) { + settled = true; + if (retryTimer) clearTimeout(retryTimer); + logRuntime("backend_ready", { port: PORT, elapsed_ms: Date.now() - started }); resolve(); return; } retry(); }); - req.on("error", retry); + req.once("error", retry); req.on("timeout", () => { req.destroy(); - retry(); }); }; const retry = () => { + if (settled) return; if (Date.now() - started > timeoutMs) { + settled = true; reject(new Error("desktop host did not become ready")); return; } - setTimeout(attempt, 250); + if (retryTimer) clearTimeout(retryTimer); + retryTimer = setTimeout(attempt, 250); }; attempt(); }); } +function canBind(port) { + return new Promise((resolve) => { + const probe = net.createServer(); + probe.unref(); + probe.once("error", () => resolve(false)); + probe.listen({ host: HOST, port, exclusive: true }, () => { + probe.close(() => resolve(true)); + }); + }); +} + +async function chooseAvailablePort(preferred, attempts = 20) { + for (let offset = 0; offset < attempts; offset += 1) { + const candidate = preferred + offset; + if (await canBind(candidate)) { + if (candidate !== preferred) { + logRuntime("port_conflict_avoided", { preferred, selected: candidate }); + } + return candidate; + } + } + throw new Error(`No free localhost port between ${preferred} and ${preferred + attempts - 1}`); +} + function iconPath() { const candidates = [ path.join(ROOT, "src", "desktop", "web", "robot.png"), @@ -122,15 +246,16 @@ function iconPath() { return candidates.find((item) => fs.existsSync(item)) || ""; } -function createWindow() { +function createWindow({ loading = false } = {}) { const icon = iconPath(); mainWindow = new BrowserWindow({ width: 1280, height: 840, minWidth: 880, minHeight: 600, - title: "Jonathan Ai 0.2.8", + title: "Jonathan Ai 0.4.8", backgroundColor: "#0b0c0f", + show: true, autoHideMenuBar: true, icon: icon || undefined, webPreferences: { @@ -140,7 +265,30 @@ function createWindow() { sandbox: false, }, }); - mainWindow.loadURL(`http://${HOST}:${PORT}/`); + logRuntime("window_created", { loading }); + mainWindow.webContents.on("did-fail-load", (_event, code, description, url) => { + logRuntime("window_load_failed", { code, description, url }); + }); + mainWindow.webContents.on("render-process-gone", (_event, details) => { + logRuntime("window_renderer_gone", { reason: details.reason, exit_code: details.exitCode }); + }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (/^https?:\/\//i.test(url)) shell.openExternal(url); + return { action: "deny" }; + }); + if (loading) { + const startupHtml = `Starting Jonathan Ai

Starting Jonathan Ai

Preparing your conversations and local tools…
`; + mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml)}`); + } else { + mainWindow.loadURL(`http://${HOST}:${PORT}/`); + } + mainWindow.show(); + mainWindow.focus(); mainWindow.on("close", (event) => { if (!app.isQuiting) { event.preventDefault(); @@ -194,17 +342,44 @@ ipcMain.handle("clawd:notify", async (_event, { title, body }) => { return true; }); -app.whenReady().then(async () => { - bootstrapDeps(); - startPython(); - await waitForHealth(); - createWindow(); - createTray(); -}); +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +if (!hasSingleInstanceLock) { + app.quit(); +} else { + app.on("second-instance", () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + }); + app.whenReady().then(async () => { + createWindow({ loading: true }); + createTray(); + bootstrapDeps(); + PORT = await chooseAvailablePort(PORT); + startPython(); + await waitForHealth(); + await mainWindow.loadURL(`http://${HOST}:${PORT}/`); + }).catch((error) => { + logRuntime("desktop_start_failed", { error: String(error?.stack || error?.message || error) }); + dialog.showErrorBox("Jonathan Ai could not start", String(error?.message || error)); + app.isQuiting = true; + app.quit(); + }); +} app.on("before-quit", () => { app.isQuiting = true; - if (python && !python.killed) python.kill(); + logRuntime("desktop_stopping", { backend_pid: python?.pid || null }); + removeProcessRecord(); + if (python && !python.killed) { + if (process.platform === "win32" && python.pid) { + spawnSync("taskkill", ["/PID", String(python.pid), "/T", "/F"], { windowsHide: true }); + } else { + python.kill(); + } + } }); app.on("window-all-closed", () => { diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 0000000..81831ff --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,3320 @@ +{ + "name": "jonathan-ai", + "version": "0.4.8", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jonathan-ai", + "version": "0.4.8", + "license": "MIT", + "devDependencies": { + "electron": "^44.0.0", + "electron-builder": "^26.0.12" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/get/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.0.0.tgz", + "integrity": "sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.33.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "6.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/unzipper/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/unzipper/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json index 5595d49..de65b5f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "jonathan-ai", "productName": "Jonathan Ai", - "version": "0.2.8", + "version": "0.4.8", "private": true, "description": "Native desktop shell for Jonathan Ai. The Python agent remains the brain.", "main": "main.js", @@ -12,7 +12,7 @@ }, "license": "MIT", "devDependencies": { - "electron": "^36.0.0", + "electron": "^44.0.0", "electron-builder": "^26.0.12" } } diff --git a/desktop/preload.js b/desktop/preload.js index 54f315c..e784110 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -1,10 +1,17 @@ -const { contextBridge, ipcRenderer } = require("electron"); +const { contextBridge, ipcRenderer, webUtils } = require("electron"); // User-initiated desktop helpers only. Nothing polls the clipboard, // captures keystrokes, or records the screen in the background. contextBridge.exposeInMainWorld("clawdDesktop", { pickFolder: () => ipcRenderer.invoke("clawd:pickFolder"), pickFiles: () => ipcRenderer.invoke("clawd:pickFiles"), + getPathForFile: (file) => { + try { + return webUtils?.getPathForFile(file) || file?.path || ""; + } catch (_err) { + return file?.path || ""; + } + }, readClipboardText: () => ipcRenderer.invoke("clawd:readClipboardText"), captureScreenshot: () => ipcRenderer.invoke("clawd:captureScreenshot"), notify: (title, body) => ipcRenderer.invoke("clawd:notify", { title, body }), diff --git a/docs/i18n/README_AR.md b/docs/i18n/README_AR.md index fdbe12e..307f67a 100644 --- a/docs/i18n/README_AR.md +++ b/docs/i18n/README_AR.md @@ -10,8 +10,8 @@ *** -[![GitHub stars](https://img.shields.io/github/stars/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GPT-AGI/Clawd-Code/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GPT-AGI/Clawd-Code/network/members) +[![GitHub stars](https://img.shields.io/github/stars/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GoDeskio/Clawd-Code/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GoDeskio/Clawd-Code/network/members) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) @@ -53,7 +53,7 @@ - ✅ **سهل الاستخدام** — إعداد سهل، REPL تفاعلي، توثيق شامل - ✅ **تحسين مستمر** — معالجة أخطاء محسّنة، اختبارات، توثيق -**الإصدار 0.2.8 — Jonathan Ai.** تطبيق سطح المكتب هو الوكيل نفسه. تُحفظ المحادثات على القرص؛ New Chat يفتح خيطاً فارغاً. الذاكرة المشتركة (`~/.clawd/memory`) تنقل الحقائق بين الدردشات. MCP وCursor وCodex اختيارية. تُحوَّل نتائج الأدوات ذات الشكل الكائني إلى نص. أعد تسمية المحادثات من الشريط الجانبي. تُرسل مخططات الأدوات بالشكل الكلاسيكي لـ Anthropic (`input_schema.type`). عند 400 schema تُعاد نفس الطلب بدون tools. +**الإصدار 0.3.0 — Jonathan Ai.** تطبيق سطح المكتب هو الوكيل نفسه، مع أدوات الصور وBlender ثلاثية الأبعاد وإدارة النظام والتدقيق والمستودعات والمهارات والذاكرة المشتركة. **🚀 جرّبه الآن! افرکه، عدّله، اجعله ملكك! طلبات السحب مرحب بها!** @@ -122,7 +122,7 @@ clawd config # عرض الإعدادات ### التثبيت ```bash -git clone https://github.com/GPT-AGI/Clawd-Code.git +git clone https://github.com/GoDeskio/Clawd-Code.git cd Clawd-Code # إنشاء venv (يُوصى بـ uv) diff --git a/docs/i18n/README_FR.md b/docs/i18n/README_FR.md index 588fb46..fa93ec2 100644 --- a/docs/i18n/README_FR.md +++ b/docs/i18n/README_FR.md @@ -10,8 +10,8 @@ *** -[![GitHub stars](https://img.shields.io/github/stars/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GPT-AGI/Clawd-Code/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GPT-AGI/Clawd-Code/network/members) +[![GitHub stars](https://img.shields.io/github/stars/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GoDeskio/Clawd-Code/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GoDeskio/Clawd-Code/network/members) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) @@ -53,7 +53,7 @@ - ✅ **Convivial** — Configuration simple, REPL interactif, documentation complète - ✅ **Continuellement amélioré** — Gestion des erreurs améliorée, tests, documentation -**Version 0.2.8 — Jonathan Ai.** L'application de bureau est l'agent. Les conversations persistent sur disque ; New Chat ouvre un fil vide. La mémoire partagée (`~/.clawd/memory`) relie les faits entre les chats. MCP, Cursor et Codex sont optionnels. Les tool_result objets sont sérialisés en chaîne. Renommez les conversations dans la barre latérale. Les schémas d'outils sont envoyés au format classique Anthropic (`input_schema.type`). Un 400 schema relance la même requête sans tools. +**Version 0.3.0 — Jonathan Ai.** L'application de bureau est l'agent, avec outils d'image, 3D Blender, administration système, audit, dépôts, compétences et mémoire partagée. **🚀 Essayez-le maintenant ! Forkez-le, modifiez-le, rendez-le vôtre ! Les pull requests sont les bienvenues !** @@ -122,7 +122,7 @@ clawd config # Voir les paramètres ### Installation ```bash -git clone https://github.com/GPT-AGI/Clawd-Code.git +git clone https://github.com/GoDeskio/Clawd-Code.git cd Clawd-Code # Créer un venv (uv recommandé) diff --git a/docs/i18n/README_HI.md b/docs/i18n/README_HI.md index 7b6ab40..96e3101 100644 --- a/docs/i18n/README_HI.md +++ b/docs/i18n/README_HI.md @@ -10,8 +10,8 @@ *** -[![GitHub stars](https://img.shields.io/github/stars/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GPT-AGI/Clawd-Code/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GPT-AGI/Clawd-Code/network/members) +[![GitHub stars](https://img.shields.io/github/stars/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GoDeskio/Clawd-Code/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GoDeskio/Clawd-Code/network/members) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) @@ -53,7 +53,7 @@ - ✅ **उपयोगकर्ता अनुकूल** — आसान सेटअप, इंटरैक्टिव REPL, व्यापक दस्तावेज़ - ✅ **निरंतर सुधार** — उन्नत त्रुटि हैंडलिंग, परीक्षण, दस्तावेज़ीकरण -**संस्करण 0.2.8 — Jonathan Ai.** डेस्कटॉप ऐप स्वयं एजेंट है। चैट डिस्क पर रहते हैं; New Chat हमेशा खाली थ्रेड खोलता है। साझा मेमोरी (`~/.clawd/memory`) एक चैट के तथ्य दूसरी में लाती है। MCP, Cursor और Codex वैकल्पिक हैं। ऑब्जेक्ट रूप के tool_result स्ट्रिंग बन जाते हैं। साइडबार में चैट का नाम बदलें। टूल स्कीमा क्लासिक Anthropic रूप में भेजे जाते हैं (`input_schema.type`)। schema 400 पर वही अनुरोध tools के बिना दोहराया जाता है। +**संस्करण 0.3.0 — Jonathan Ai.** डेस्कटॉप ऐप स्वयं एजेंट है और अब इमेज, Blender 3D, सिस्टम प्रशासन, ऑडिट, रिपॉज़िटरी, कौशल और साझा मेमोरी टूल शामिल करता है। **🚀 अभी आज़माएं! इसे फोर्क करें, संशोधित करें, अपना बनाएं! Pull requests का स्वागत है!** @@ -122,7 +122,7 @@ clawd config # सेटिंग्स देखें ### इंस्टॉल करें ```bash -git clone https://github.com/GPT-AGI/Clawd-Code.git +git clone https://github.com/GoDeskio/Clawd-Code.git cd Clawd-Code # venv बनाएं (uv अनुशंसित) diff --git a/docs/i18n/README_PT.md b/docs/i18n/README_PT.md index b48285b..79362b3 100644 --- a/docs/i18n/README_PT.md +++ b/docs/i18n/README_PT.md @@ -10,8 +10,8 @@ *** -[![GitHub stars](https://img.shields.io/github/stars/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GPT-AGI/Clawd-Code/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GPT-AGI/Clawd-Code/network/members) +[![GitHub stars](https://img.shields.io/github/stars/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GoDeskio/Clawd-Code/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GoDeskio/Clawd-Code/network/members) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) @@ -53,7 +53,7 @@ - ✅ **Amigável ao Usuário** — Configuração fácil, REPL interativo, documentação abrangente - ✅ **Continuamente Melhorado** — Tratamento de erros aprimorado, testes, documentação -**Versão 0.2.8 — Jonathan Ai.** O app desktop é o próprio agente. As conversas persistem em disco; New Chat abre um fio vazio. A memória compartilhada (`~/.clawd/memory`) leva factos de um chat para outro. MCP, Cursor e Codex são opcionais. Resultados de ferramentas em objeto são convertidos em string. Renomeie conversas na barra lateral. Os schemas vão no formato clássico da Anthropic (`input_schema.type`). Um 400 de schema repete o mesmo pedido sem tools. +**Versão 0.3.0 — Jonathan Ai.** O app desktop é o próprio agente, agora com ferramentas de imagem, Blender 3D, administração do sistema, auditoria, repositórios, skills e memória compartilhada. **🚀 Experimente agora! Faça fork, modifique, torne seu! Pull requests são bem-vindos!** @@ -122,7 +122,7 @@ clawd config # Ver configurações ### Instalar ```bash -git clone https://github.com/GPT-AGI/Clawd-Code.git +git clone https://github.com/GoDeskio/Clawd-Code.git cd Clawd-Code # Criar venv (uv recomendado) diff --git a/docs/i18n/README_RU.md b/docs/i18n/README_RU.md index f24cadf..dbeb1ab 100644 --- a/docs/i18n/README_RU.md +++ b/docs/i18n/README_RU.md @@ -10,8 +10,8 @@ *** -[![GitHub stars](https://img.shields.io/github/stars/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GPT-AGI/Clawd-Code/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/GPT-AGI/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GPT-AGI/Clawd-Code/network/members) +[![GitHub stars](https://img.shields.io/github/stars/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=yellow)](https://github.com/GoDeskio/Clawd-Code/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/GoDeskio/Clawd-Code?style=for-the-badge&logo=github&color=blue)](https://github.com/GoDeskio/Clawd-Code/network/members) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) @@ -53,7 +53,7 @@ - ✅ **Удобство использования** — Простая настройка, интерактивный REPL, полная документация - ✅ **Постоянное улучшение** — Улучшенная обработка ошибок, тестирование, документация -**Версия 0.2.8 — Jonathan Ai.** Десктопное приложение само является агентом. Чаты сохраняются на диск; New Chat открывает пустую ветку. Общая память (`~/.clawd/memory`) переносит факты между чатами. MCP, Cursor и Codex необязательны. Объектные tool_result сериализуются в строку. Переименовывайте чаты в боковой панели. Схемы инструментов отправляются в классическом виде Anthropic (`input_schema.type`). При 400 schema тот же запрос повторяется без tools. +**Версия 0.3.0 — Jonathan Ai.** Десктопное приложение само является агентом и включает инструменты изображений, Blender 3D, системного администрирования, аудита, репозиториев, навыков и общей памяти. **🚀 Попробуйте сейчас! Форкните, изменяйте, сделайте своим! Pull requests приветствуются!** @@ -122,7 +122,7 @@ clawd config # Просмотреть настройки ### Установка ```bash -git clone https://github.com/GPT-AGI/Clawd-Code.git +git clone https://github.com/GoDeskio/Clawd-Code.git cd Clawd-Code # Создать venv (рекомендуется uv) diff --git a/install.ps1 b/install.ps1 index f284776..cbc61f1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -17,14 +17,27 @@ if ((Test-Path $Setup) -and -not $unattended) { } function Find-Python { - foreach ($name in @("pythonw", "python", "python3", "py")) { + foreach ($name in @("python", "python3", "py")) { $cmd = Get-Command $name -ErrorAction SilentlyContinue if (-not $cmd) { continue } - if ($name -eq "pythonw") { return $cmd.Source } & $cmd.Source -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" 2>$null if ($LASTEXITCODE -eq 0) { return $cmd.Source } } - throw "Python 3.10+ is required. Install it from https://www.python.org/downloads/ and re-run JonathanAi-Setup.exe" + + $winget = Get-Command winget -ErrorAction SilentlyContinue + if ($winget) { + Write-Host "Python 3.12 is missing; installing it with Windows Package Manager (Fooocus Python 3.10 is installed separately)..." + & $winget.Source install --id Python.Python.3.12 -e --source winget --silent --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -eq 0) { + foreach ($candidate in @( + (Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe"), + (Join-Path $env:ProgramFiles "Python312\python.exe") + )) { + if (Test-Path $candidate) { return $candidate } + } + } + } + throw "Python 3.10+ could not be installed automatically. Install it from https://www.python.org/downloads/ and re-run JonathanAi-Setup.exe" } $Py = Find-Python @@ -40,7 +53,8 @@ if (Test-Path (Join-Path $Here "src\cli.py")) { Set-Location $Here if ($unattended) { - & $Py -m src.install --source-dir $Dest @extra --yes --launch @args + $forwardArgs = @($args | Where-Object { $_ -notin @("--yes", "-Yes", "/Y", "--launch") }) + & $Py -m src.install --source-dir $Dest @extra --yes --launch @forwardArgs } else { & $Py -m src.install --source-dir $Dest @extra --gui } diff --git a/packaging/windows/JonathanAi-Setup.c b/packaging/windows/JonathanAi-Setup.c index a5de30a..f6d5bc1 100644 --- a/packaging/windows/JonathanAi-Setup.c +++ b/packaging/windows/JonathanAi-Setup.c @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include #pragma comment(lib, "shell32") #pragma comment(lib, "shlwapi") @@ -28,6 +31,7 @@ static int g_page = 0; static wchar_t g_root[MAX_PATH]; static wchar_t g_payload[MAX_PATH]; static int g_ok = 0; +static int g_restart_after_install = 0; static void append_log(const wchar_t *line) { int len = GetWindowTextLengthW(g_log); @@ -108,6 +112,46 @@ static BOOL run_hidden(const wchar_t *exe, wchar_t *cmdline, const wchar_t *cwd) return code == 0; } +static int stop_recorded_app(const wchar_t *dest) { + wchar_t record[MAX_PATH], cmd[256], image[MAX_PATH], *name; + char data[4096], *marker, *colon; + HANDLE file, process; + DWORD read = 0, image_len = MAX_PATH, exit_code = 0; + unsigned long pid; + join(record, dest, L".jonathan-ai-processes.json"); + file = CreateFileW(record, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) return 0; + ZeroMemory(data, sizeof(data)); + ReadFile(file, data, sizeof(data) - 1, &read, NULL); + CloseHandle(file); + marker = strstr(data, "\"owner_pid\""); + colon = marker ? strchr(marker, ':') : NULL; + pid = colon ? strtoul(colon + 1, NULL, 10) : 0; + if (pid <= 4 || pid == GetCurrentProcessId()) return 0; + process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) return 0; + image[0] = 0; + if (!GetExitCodeProcess(process, &exit_code) || exit_code != STILL_ACTIVE || + !QueryFullProcessImageNameW(process, 0, image, &image_len)) { + CloseHandle(process); + return 0; + } + CloseHandle(process); + name = PathFindFileNameW(image); + if (_wcsnicmp(image, dest, lstrlenW(dest)) != 0 || + (image[lstrlenW(dest)] != L'\\' && image[lstrlenW(dest)] != L'/')) return 0; + if (lstrcmpiW(name, L"electron.exe") != 0 && + lstrcmpiW(name, L"JonathanAi.exe") != 0 && + lstrcmpiW(name, L"python.exe") != 0 && + lstrcmpiW(name, L"pythonw.exe") != 0) return 0; + append_log(L"Stopping the running Jonathan Ai process tree for upgrade…"); + wsprintfW(cmd, L"taskkill.exe /PID %lu /T /F", pid); + if (!run_hidden(L"C:\\Windows\\System32\\taskkill.exe", cmd, dest)) return 0; + DeleteFileW(record); + return 1; +} + static BOOL create_shortcut(const wchar_t *link, const wchar_t *target, const wchar_t *workdir, const wchar_t *icon) { HRESULT hr; IShellLinkW *sl = NULL; @@ -190,9 +234,10 @@ static DWORD WINAPI install_thread(LPVOID param) { } append_log(L"Using existing Jonathan-Ai folder when present (upgrade in place)…"); SHCreateDirectoryExW(NULL, dest, NULL); + g_restart_after_install = stop_recorded_app(dest); join(srccli, g_payload, L"src\\cli.py"); - if (exists(srccli)) { + if (exists(srccli) && lstrcmpiW(g_payload, dest) != 0) { append_log(L"Replacing app files from this checkout…"); wsprintfW(cmd, L"cmd.exe /C xcopy /E /I /Y /Q \"%s\" \"%s\"", g_payload, dest); run_hidden(L"C:\\Windows\\System32\\cmd.exe", cmd, g_payload); @@ -230,9 +275,22 @@ static DWORD WINAPI install_thread(LPVOID param) { if (!run_hidden(python, cmd, dest)) { wsprintfW(cmd, L"py -3 -m src.install --source-dir \"%s\" --from-local \"%s\" --yes", dest, dest); if (!run_hidden(L"py", cmd, dest)) { - append_log(L"ERROR: Python install step failed."); - PostMessageW(g_main, WM_APP + 2, 0, 0); - return 1; + wchar_t localapp[MAX_PATH]; + append_log(L"Python is missing; installing Python 3.12 automatically…"); + lstrcpynW(cmd, L"winget.exe install --id Python.Python.3.12 -e --source winget --silent --accept-package-agreements --accept-source-agreements", 2048); + if (run_hidden(L"winget.exe", cmd, dest) && + GetEnvironmentVariableW(L"LOCALAPPDATA", localapp, MAX_PATH) > 0) { + join(python, localapp, L"Programs\\Python\\Python312\\python.exe"); + wsprintfW(cmd, L"\"%s\" -m src.install --source-dir \"%s\" --from-local \"%s\" --yes", python, dest, dest); + if (!run_hidden(python, cmd, dest)) python[0] = 0; + } else { + python[0] = 0; + } + if (!python[0]) { + append_log(L"ERROR: Python install step failed."); + PostMessageW(g_main, WM_APP + 2, 0, 0); + return 1; + } } } @@ -243,6 +301,11 @@ static DWORD WINAPI install_thread(LPVOID param) { if (!exists(icon)) join(icon, g_root, L"jonathan-ai.ico"); append_log(L"Rewriting Desktop and Start Menu shortcuts to Jonathan-Ai\\JonathanAi.exe…"); make_shortcuts(dest, launcher, icon); + if (g_restart_after_install) { + append_log(L"Restarting the app that was running before this upgrade…"); + ShellExecuteW(NULL, L"open", launcher, NULL, dest, SW_SHOWNORMAL); + SendMessageW(g_launch_check, BM_SETCHECK, BST_UNCHECKED, 0); + } append_log(L"Done. Second Setup runs upgrade this same folder."); g_ok = 1; PostMessageW(g_main, WM_APP + 1, 0, 0); @@ -264,11 +327,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM w, LPARAM l) { InitCommonControlsEx(&icc); CreateWindowW(L"STATIC", L"Jonathan Ai", WS_CHILD | WS_VISIBLE, 24, 16, 400, 28, hwnd, NULL, NULL, NULL); - CreateWindowW(L"STATIC", L"Windows desktop installer 0.2.8", WS_CHILD | WS_VISIBLE, + CreateWindowW(L"STATIC", L"Windows desktop installer 0.4.8", WS_CHILD | WS_VISIBLE, 24, 44, 400, 20, hwnd, NULL, NULL, NULL); g_welcome = CreateWindowW(L"STATIC", - L"This wizard installs or upgrades Jonathan Ai 0.2.8 in place.\r\n\r\n" + L"This wizard installs or upgrades Jonathan Ai 0.4.8 in place.\r\n\r\n" L"It reuses %USERPROFILE%\\Jonathan\\Jonathan-Ai (or an existing Clawd-Code folder).\r\n" L"A second run upgrades that same folder — it does not create a parallel install.\r\n\r\n" L"Shortcuts are rewritten to Jonathan-Ai\\JonathanAi.exe.\r\n" @@ -366,7 +429,7 @@ int WINAPI wWinMain(HINSTANCE inst, HINSTANCE prev, PWSTR cmd, int show) { wc.hIcon = LoadIconW(inst, MAKEINTRESOURCEW(1)); if (!wc.hIcon) wc.hIcon = LoadIcon(NULL, IDI_APPLICATION); RegisterClassW(&wc); - g_main = CreateWindowW(L"JonathanAiSetup", L"Install Jonathan Ai 0.2.8", + g_main = CreateWindowW(L"JonathanAiSetup", L"Install Jonathan Ai 0.4.8", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 640, 460, NULL, NULL, inst, NULL); ShowWindow(g_main, show); diff --git a/packaging/windows/JonathanAi.c b/packaging/windows/JonathanAi.c index c521b04..a204f86 100644 --- a/packaging/windows/JonathanAi.c +++ b/packaging/windows/JonathanAi.c @@ -74,7 +74,9 @@ static BOOL run_and_wait(const wchar_t *exe, const wchar_t *args, const wchar_t if (!CreateProcessW(exe, cmdline, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, cwd, &si, &pi)) { return FALSE; } - WaitForSingleObject(pi.hProcess, 180000); + /* First-run repair installs Jonathan's native image-engine packages and + prepares its private model/output directories. */ + WaitForSingleObject(pi.hProcess, 7200000); GetExitCodeProcess(pi.hProcess, &code); CloseHandle(pi.hThread); CloseHandle(pi.hProcess); @@ -94,6 +96,19 @@ static void run_bootstrap(const wchar_t *root) { run_and_wait(L"py", cmdbuf, root); } +static int runtime_ready(const wchar_t *root) { + wchar_t probe[MAX_PATH]; + join(probe, MAX_PATH, root, L".venv\\Scripts\\python.exe"); + if (!exists(probe)) return 0; + join(probe, MAX_PATH, root, L"desktop\\node_modules\\electron\\dist\\electron.exe"); + if (!exists(probe)) return 0; + /* Version-specific marker: Setup/bootstrap creates this only after all + required dependencies are healthy. A future upgrade changes the marker + name and automatically performs one repair on its first launch. */ + join(probe, MAX_PATH, root, L".jonathan-ai-runtime-0.4.6.ready"); + return exists(probe); +} + static BOOL start_process(const wchar_t *exe, const wchar_t *args, const wchar_t *cwd) { STARTUPINFOW si; PROCESS_INFORMATION pi; @@ -110,13 +125,35 @@ static BOOL start_process(const wchar_t *exe, const wchar_t *args, const wchar_t return TRUE; } +static BOOL start_process_checked(const wchar_t *exe, const wchar_t *args, const wchar_t *cwd, DWORD settle_ms) { + STARTUPINFOW si; + PROCESS_INFORMATION pi; + wchar_t cmdline[2048]; + DWORD code = STILL_ACTIVE; + ZeroMemory(&si, sizeof(si)); + ZeroMemory(&pi, sizeof(pi)); + si.cb = sizeof(si); + lstrcpynW(cmdline, args, 2048); + if (!CreateProcessW(exe, cmdline, NULL, NULL, FALSE, 0, NULL, cwd, &si, &pi)) { + return FALSE; + } + CloseHandle(pi.hThread); + if (WaitForSingleObject(pi.hProcess, settle_ms) == WAIT_OBJECT_0) { + GetExitCodeProcess(pi.hProcess, &code); + } + CloseHandle(pi.hProcess); + return code == STILL_ACTIVE; +} + static int start_electron(const wchar_t *root) { wchar_t electron[MAX_PATH], desktop[MAX_PATH], cmdbuf[2048]; join(electron, MAX_PATH, root, L"desktop\\node_modules\\electron\\dist\\electron.exe"); if (!exists(electron)) return 0; join(desktop, MAX_PATH, root, L"desktop"); wsprintfW(cmdbuf, L"\"%s\" \"%s\"", electron, desktop); - return start_process(electron, cmdbuf, desktop) ? 1 : 0; + /* A loader/crashpad failure can exit immediately after CreateProcess. + Only suppress the Python fallback once Electron remains alive. */ + return start_process_checked(electron, cmdbuf, desktop, 1800) ? 1 : 0; } static int start_pythonw(const wchar_t *root) { @@ -127,8 +164,13 @@ static int start_pythonw(const wchar_t *root) { } if (!exists(python)) return 0; /* Electron is optional. A working venv is enough to open the UI. */ + SetEnvironmentVariableW(L"CLAWD_DESKTOP_MANAGED_BY_LAUNCHER", L"1"); wsprintfW(cmdbuf, L"\"%s\" -m src.cli desktop --no-browser", python); - if (!start_process(python, cmdbuf, root)) return 0; + if (!start_process(python, cmdbuf, root)) { + SetEnvironmentVariableW(L"CLAWD_DESKTOP_MANAGED_BY_LAUNCHER", NULL); + return 0; + } + SetEnvironmentVariableW(L"CLAWD_DESKTOP_MANAGED_BY_LAUNCHER", NULL); Sleep(1200); ShellExecuteW(NULL, L"open", L"http://127.0.0.1:8765/", NULL, NULL, SW_SHOWNORMAL); return 1; @@ -151,7 +193,9 @@ int WINAPI wWinMain(HINSTANCE inst, HINSTANCE prev, PWSTR cmd, int show) { } SetEnvironmentVariableW(L"CLAWD_SOURCE_DIR", root); - run_bootstrap(root); + if (!runtime_ready(root)) { + run_bootstrap(root); + } if (start_electron(root)) { return 0; diff --git a/packaging/windows/JonathanAi.iss b/packaging/windows/JonathanAi.iss index 6ee490f..adaf802 100644 --- a/packaging/windows/JonathanAi.iss +++ b/packaging/windows/JonathanAi.iss @@ -6,7 +6,7 @@ #define MyAppExe "JonathanAi.exe" #define MyAppPublisher "GoDeskio" #define MyAppURL "https://github.com/GoDeskio/Clawd-Code" -#define MyAppVersion "0.2.8" +#define MyAppVersion "0.4.8" [Setup] AppId={{C8E2A4B1-0F31-4E77-9A44-7B2E91C0D801} @@ -14,18 +14,23 @@ AppName={#MyAppName} AppVersion={#MyAppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} -DefaultDirName={userdocs}\..\Jonathan\Jonathan-Ai +DefaultDirName={userprofile}\Jonathan\Jonathan-Ai DefaultGroupName={#MyAppName} DisableProgramGroupPage=no OutputDir=bin OutputBaseFilename=JonathanAi-Setup SetupIconFile=jonathan-ai.ico WizardStyle=modern +DisableWelcomePage=no +DisableReadyPage=no +SetupLogging=yes Compression=lzma2 SolidCompression=yes PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=commandline +Uninstallable=yes UninstallDisplayIcon={app}\{#MyAppExe} -CloseApplications=no +CloseApplications=yes ArchitecturesInstallIn64BitMode=x64compatible [Languages] @@ -36,19 +41,78 @@ Name: "desktopicon"; Description: "Create a Desktop shortcut"; GroupDescription: Name: "startmenu"; Description: "Create a Start Menu shortcut"; GroupDescription: "Shortcuts:"; Flags: checked [Files] -Source: "..\..\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion; Excludes: ".venv,node_modules,.git,.pytest_cache,__pycache__,*.pyc,dist" +Source: "..\..\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion; Excludes: ".venv,node_modules,.git,.pytest_cache,__pycache__,*.pyc,dist,Skills,packaging\windows\bin,Integrations\ECC,Integrations\Kronos,Integrations\Securo,integrations\Fooocus-outputs,integrations\.image-models,integrations\.fooocus-venv,integrations\.kronos-venv,integrations\.fooocus-cache,integrations\.kronos-cache,.jonathan-ai-processes.json,JonathanAi.exe.new" Source: "bin\JonathanAi.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "jonathan-ai.ico"; DestDir: "{app}"; Flags: ignoreversion +[Dirs] +Name: "{app}\Skills" + [Icons] -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExe}"; IconFilename: "{app}\jonathan-ai.ico"; Tasks: desktopicon -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExe}"; IconFilename: "{app}\jonathan-ai.ico"; Tasks: startmenu +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExe}"; WorkingDir: "{app}"; IconFilename: "{app}\jonathan-ai.ico"; Tasks: desktopicon +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExe}"; WorkingDir: "{app}"; IconFilename: "{app}\jonathan-ai.ico"; Tasks: startmenu [Run] -Filename: "{cmd}"; Parameters: "/C if exist ""{app}\.venv\Scripts\python.exe"" (""{app}\.venv\Scripts\python.exe"" -m src.install --source-dir ""{app}"" --from-local ""{app}"" --yes) else (python -m src.install --source-dir ""{app}"" --from-local ""{app}"" --yes)"; WorkingDir: "{app}"; StatusMsg: "Installing Python environment…"; Flags: runhidden -Filename: "{app}\{#MyAppExe}"; Description: "Launch Jonathan Ai"; Flags: nowait postinstall skipifsilent +Filename: "{cmd}"; Parameters: "/C set ""CLAWD_INSTALL_DIR={app}"" && powershell.exe -NoProfile -ExecutionPolicy Bypass -File ""{app}\install.ps1"" --yes"; WorkingDir: "{app}"; StatusMsg: "Installing Python, app, and desktop dependencies..."; Flags: runhidden +Filename: "{app}\{#MyAppExe}"; Flags: nowait; Check: WasRunningBeforeUpgrade +Filename: "{app}\{#MyAppExe}"; Description: "Launch Jonathan Ai"; Flags: nowait postinstall skipifsilent; Check: not WasRunningBeforeUpgrade [Code] +var + RestartAfterUpgrade: Boolean; + +function JsonInteger(const Content, Key: String): Integer; +var + Tail, Digits: String; + P, I: Integer; +begin + Result := 0; + P := Pos('"' + Key + '"', Content); + if P = 0 then Exit; + Tail := Copy(Content, P + Length(Key) + 2, Length(Content)); + P := Pos(':', Tail); + if P = 0 then Exit; + Tail := Trim(Copy(Tail, P + 1, Length(Tail))); + Digits := ''; + for I := 1 to Length(Tail) do begin + if (Tail[I] < '0') or (Tail[I] > '9') then Break; + Digits := Digits + Tail[I]; + end; + Result := StrToIntDef(Digits, 0); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + RecordPath, Content, Script: String; + Pid, ExitCode: Integer; +begin + Result := ''; + RestartAfterUpgrade := False; + RecordPath := ExpandConstant('{app}\.jonathan-ai-processes.json'); + if not LoadStringFromFile(RecordPath, Content) then Exit; + Pid := JsonInteger(Content, 'owner_pid'); + if Pid <= 4 then begin + DeleteFile(RecordPath); + Exit; + end; + Script := '$root=(Resolve-Path -LiteralPath ''' + ExpandConstant('{app}') + ''').Path + ''\''; ' + + '$p=Get-Process -Id ' + IntToStr(Pid) + + ' -ErrorAction SilentlyContinue; if($p -and $p.Path.StartsWith($root,[System.StringComparison]::OrdinalIgnoreCase)' + + ' -and $p.ProcessName -match ''^(electron|JonathanAi|python|pythonw)$'')' + + '{ & taskkill.exe /PID ' + IntToStr(Pid) + ' /T /F; exit $LASTEXITCODE }; exit 2'; + if Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + '-NoProfile -NonInteractive -Command "' + Script + '"', ExpandConstant('{app}'), + SW_HIDE, ewWaitUntilTerminated, ExitCode) and (ExitCode = 0) then begin + RestartAfterUpgrade := True; + DeleteFile(RecordPath); + end; +end; + +function WasRunningBeforeUpgrade(): Boolean; +begin + Result := RestartAfterUpgrade; +end; + function InitializeSetup(): Boolean; begin Result := True; diff --git a/packaging/windows/JonathanAi.nsi b/packaging/windows/JonathanAi.nsi new file mode 100644 index 0000000..9bf7c4a --- /dev/null +++ b/packaging/windows/JonathanAi.nsi @@ -0,0 +1,96 @@ +; Standard per-user Windows setup/upgrade wizard for Jonathan Ai. +Unicode True +RequestExecutionLevel user +; Non-solid compression avoids NSIS mmap failures on large source payloads and +; makes in-place upgrades faster when only a few application files changed. +SetCompressor zlib + +!define PRODUCT_NAME "Jonathan Ai" +!define PRODUCT_VERSION "0.4.8" +!define PRODUCT_PUBLISHER "GoDeskio" +!define PRODUCT_WEB "https://github.com/GoDeskio/Clawd-Code" +!define PRODUCT_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\JonathanAi" + +Name "${PRODUCT_NAME} ${PRODUCT_VERSION}" +Caption "${PRODUCT_NAME} Setup" +OutFile "bin\JonathanAi-Setup.exe" +InstallDir "$PROFILE\Jonathan\Jonathan-Ai" +InstallDirRegKey HKCU "Software\GoDeskio\JonathanAi" "InstallDir" +Icon "nsis-icon.ico" +UninstallIcon "nsis-icon.ico" +BrandingText "Jonathan Ai ${PRODUCT_VERSION}" +ShowInstDetails show +ShowUninstDetails show + +!include "MUI2.nsh" +!define MUI_ABORTWARNING +!define MUI_WELCOMEPAGE_TITLE "Install or upgrade Jonathan Ai" +!define MUI_WELCOMEPAGE_TEXT "This standard wizard installs Jonathan Ai ${PRODUCT_VERSION} for the current Windows user.$\r$\n$\r$\nExisting installations are upgraded in place. Conversations, configuration, models, and the editable Skills library are preserved. Required application dependencies are installed automatically." +!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the Jonathan Ai application folder. The recommended location keeps JonathanAi.exe and the editable Skills library together." +!define MUI_FINISHPAGE_RUN "$INSTDIR\JonathanAi.exe" +!define MUI_FINISHPAGE_RUN_TEXT "Launch Jonathan Ai" +!define MUI_FINISHPAGE_LINK "Open the Jonathan Ai source repository" +!define MUI_FINISHPAGE_LINK_LOCATION "${PRODUCT_WEB}" + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE" +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +Section "Jonathan Ai" MainSection + SectionIn RO + SetShellVarContext current + SetOverwrite on + SetOutPath "$INSTDIR" + + ; Ship a complete source payload. Heavy generated/runtime folders remain in + ; place during upgrades and are repaired by install.ps1 when needed. + File /r /x ".git" /x ".venv" /x "node_modules" /x "Skills" /x "__pycache__" /x "*.pyc" /x ".pytest_cache" /x "dist" /x "build" /x "upstream" /x "Fooocus" /x "Fooocus-outputs" /x ".image-models" /x "python-runtimes" /x ".fooocus-venv" /x ".kronos-venv" /x ".uv-cache" /x ".pip-cache" /x ".fooocus-cache" /x ".kronos-cache" /x "ECC" /x "Kronos" /x "Securo" /x "electron" /x "media" /x "pong" /x "mech_robot_*.png" /x "JonathanAi-Setup.exe" /x "JonathanAi.exe.new" /x ".jonathan-ai-processes.json" /x ".jonathan-ai-runtime-*.ready" "..\..\*.*" + File /oname=JonathanAi.exe "bin\JonathanAi.exe" + File /oname=jonathan-ai.ico "jonathan-ai.ico" + CreateDirectory "$INSTDIR\Skills" + + DetailPrint "Installing and verifying Python, OCR, native image, document, Electron, and Blender dependencies..." + System::Call 'Kernel32::SetEnvironmentVariable(t, t) i("CLAWD_INSTALL_DIR", "$INSTDIR").r0' + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\install.ps1" --yes' + Pop $0 + System::Call 'Kernel32::SetEnvironmentVariable(t, t) i("CLAWD_INSTALL_DIR", "").r0' + ${If} $0 != 0 + MessageBox MB_ICONSTOP|MB_OK "Dependency installation failed with exit code $0. Review the setup details, then run this installer again." + Abort + ${EndIf} + + WriteUninstaller "$INSTDIR\Uninstall-JonathanAi.exe" + CreateDirectory "$SMPROGRAMS\Jonathan Ai" + CreateShortcut "$SMPROGRAMS\Jonathan Ai\Jonathan Ai.lnk" "$INSTDIR\JonathanAi.exe" "" "$INSTDIR\jonathan-ai.ico" + CreateShortcut "$SMPROGRAMS\Jonathan Ai\Uninstall Jonathan Ai.lnk" "$INSTDIR\Uninstall-JonathanAi.exe" + CreateShortcut "$DESKTOP\Jonathan Ai.lnk" "$INSTDIR\JonathanAi.exe" "" "$INSTDIR\jonathan-ai.ico" + + WriteRegStr HKCU "Software\GoDeskio\JonathanAi" "InstallDir" "$INSTDIR" + WriteRegStr HKCU "${PRODUCT_KEY}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKCU "${PRODUCT_KEY}" "DisplayVersion" "${PRODUCT_VERSION}" + WriteRegStr HKCU "${PRODUCT_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKCU "${PRODUCT_KEY}" "URLInfoAbout" "${PRODUCT_WEB}" + WriteRegStr HKCU "${PRODUCT_KEY}" "DisplayIcon" "$INSTDIR\JonathanAi.exe" + WriteRegStr HKCU "${PRODUCT_KEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "${PRODUCT_KEY}" "UninstallString" '"$INSTDIR\Uninstall-JonathanAi.exe"' + WriteRegDWORD HKCU "${PRODUCT_KEY}" "NoModify" 1 + WriteRegDWORD HKCU "${PRODUCT_KEY}" "NoRepair" 1 +SectionEnd + +Section "Uninstall" + SetShellVarContext current + Delete "$DESKTOP\Jonathan Ai.lnk" + RMDir /r "$SMPROGRAMS\Jonathan Ai" + DeleteRegKey HKCU "${PRODUCT_KEY}" + DeleteRegKey HKCU "Software\GoDeskio\JonathanAi" + Delete "$INSTDIR\JonathanAi.exe" + Delete "$INSTDIR\jonathan-ai.ico" + Delete "$INSTDIR\Uninstall-JonathanAi.exe" + ; Conversations/configuration live in ~/.clawd and the visible Skills folder + ; is user-authored. Keep both recoverable after uninstall. +SectionEnd diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 22e4ae2..bb29ae4 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -1,6 +1,6 @@ # Jonathan Ai for Windows -**Version 0.2.8** — Jonathan Ai is itself the AI agent. After install, chat works with one API key or a local LLM. Conversations persist across restarts; New Chat is always empty. Shared memory stays in `%USERPROFILE%\.clawd\memory`. MCP, Cursor, and Codex are optional. Object-shaped Anthropic tool results are stringified. Rename conversations from the left sidebar (double-click or right-click). The version appears in the installer title and the app header. +**Version 0.4.8** — Jonathan Ai restores visible persisted replies, automatically recovers interrupted live response streams through job-event polling, and replaces the old remote-action placeholder with approval-gated SSH/SCP/WinRM operations that use existing OS credentials. The standard wizard still upgrades `%USERPROFILE%\Jonathan\Jonathan-Ai` in place and preserves conversations, configuration, user skills, media and project downloads. `JonathanAi.exe` looks for the app in this order: `CLAWD_SOURCE_DIR`, the exe folder, `%USERPROFILE%\Jonathan\Jonathan-Ai`, then the current working directory. A working `.venv` is enough to open the UI; Electron is optional. If a Desktop click still says it cannot find Electron or the venv, run `JonathanAi-Setup.exe` again — it upgrades that same `Jonathan-Ai` folder in place and rewrites shortcuts to `%USERPROFILE%\Jonathan\Jonathan-Ai\JonathanAi.exe`. @@ -29,13 +29,13 @@ On Linux (mingw): ./packaging/windows/build.sh ``` -On Windows (optional Electron + Inno Setup polish): +On Windows (Electron plus a standard Inno Setup or NSIS wizard): ```powershell powershell -File packaging\windows\build-windows.ps1 ``` -Inno Setup (`JonathanAi.iss`) produces a classic Setup wizard that also writes Desktop/Start Menu shortcuts and launches the app on Finish. +Inno Setup (`JonathanAi.iss`) or NSIS (`JonathanAi.nsi`) produces a standard Setup wizard that registers an uninstaller, writes Desktop/Start Menu shortcuts, upgrades the same folder, preserves `Skills`, installs runtime dependencies, initializes all native engines, optionally synchronizes the ECC catalog when online, and launches the app on Finish. No API tokens are baked into these artifacts. Tokens stay in `%USERPROFILE%\.clawd\config.json`. There is no token paywall. The on-screen token count is informational only. diff --git a/packaging/windows/bin/JonathanAi-Setup.exe b/packaging/windows/bin/JonathanAi-Setup.exe index d83861b..beda498 100755 Binary files a/packaging/windows/bin/JonathanAi-Setup.exe and b/packaging/windows/bin/JonathanAi-Setup.exe differ diff --git a/packaging/windows/bin/JonathanAi.exe b/packaging/windows/bin/JonathanAi.exe index 4fb5e96..eb177dc 100755 Binary files a/packaging/windows/bin/JonathanAi.exe and b/packaging/windows/bin/JonathanAi.exe differ diff --git a/packaging/windows/build-windows.ps1 b/packaging/windows/build-windows.ps1 index 27cfa38..5733ee7 100644 --- a/packaging/windows/build-windows.ps1 +++ b/packaging/windows/build-windows.ps1 @@ -1,19 +1,33 @@ -# Build JonathanAi.exe (Electron) and JonathanAi-Setup.exe (Inno Setup) on Windows. +# Validate the Electron source shell and build the native launcher installer on Windows. $ErrorActionPreference = "Stop" $Root = Resolve-Path (Join-Path $PSScriptRoot "..\..") $Desktop = Join-Path $Root "desktop" $Out = Join-Path $PSScriptRoot "bin" +$BuildCache = Join-Path $PSScriptRoot "build\cache" +$OriginalLocalAppData = $env:LOCALAPPDATA New-Item -ItemType Directory -Force -Path $Out | Out-Null +New-Item -ItemType Directory -Force -Path $BuildCache | Out-Null +$env:NPM_CONFIG_CACHE = Join-Path $BuildCache "npm" +$env:ELECTRON_CACHE = Join-Path $BuildCache "electron" +$env:ELECTRON_BUILDER_CACHE = Join-Path $BuildCache "electron-builder" +$env:LOCALAPPDATA = Join-Path $BuildCache "local-app-data" +New-Item -ItemType Directory -Force -Path $env:LOCALAPPDATA | Out-Null -Write-Host "Installing Electron desktop shell…" +Write-Host "Validating Electron desktop shell…" Push-Location $Desktop if (Get-Command npm -ErrorAction SilentlyContinue) { npm install - npx electron-builder --win --x64 --dir - $built = Get-ChildItem -Recurse -Filter "JonathanAi.exe" | Select-Object -First 1 - if ($built) { Copy-Item $built.FullName (Join-Path $Out "JonathanAi.exe") -Force } + if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" } + npm audit --audit-level=high + if ($LASTEXITCODE -ne 0) { throw "npm audit failed with exit code $LASTEXITCODE" } + node --check main.js + if ($LASTEXITCODE -ne 0) { throw "desktop/main.js syntax check failed with exit code $LASTEXITCODE" } + node --check preload.js + if ($LASTEXITCODE -ne 0) { throw "desktop/preload.js syntax check failed with exit code $LASTEXITCODE" } + node --check ..\src\desktop\web\app.js + if ($LASTEXITCODE -ne 0) { throw "desktop web app syntax check failed with exit code $LASTEXITCODE" } } else { - Write-Host "npm not found; using the prebuilt mingw JonathanAi.exe launcher." + throw "Node.js/npm is required to validate the Electron desktop shell." } Pop-Location @@ -22,12 +36,23 @@ if (-not $iscc) { $isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" if (Test-Path $isccPath) { $iscc = $isccPath } } +$makensis = Get-Command makensis -ErrorAction SilentlyContinue +if (-not $makensis) { + $nsisRoots = @($env:ELECTRON_BUILDER_CACHE, (Join-Path $OriginalLocalAppData "electron-builder\Cache")) | Where-Object { $_ -and (Test-Path $_) } + $cached = Get-ChildItem $nsisRoots -Recurse -Filter makensis.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\Bin\\makensis\.exe$' } | Select-Object -First 1 + if ($cached) { $makensis = $cached.FullName } +} if ($iscc) { Write-Host "Building JonathanAi-Setup.exe with Inno Setup…" & $iscc (Join-Path $PSScriptRoot "JonathanAi.iss") +} elseif ($makensis) { + Write-Host "Building JonathanAi-Setup.exe with the standard NSIS wizard…" + & $makensis (Join-Path $PSScriptRoot "JonathanAi.nsi") } else { - Write-Host "Inno Setup not found. The mingw JonathanAi-Setup.exe in dist/ is the wizard." + throw "A standard Windows installer compiler (Inno Setup or NSIS) is required." } +if ($LASTEXITCODE -ne 0) { throw "Windows installer compiler failed with exit code $LASTEXITCODE" } Write-Host "Artifacts in $Out" Get-ChildItem $Out diff --git a/packaging/windows/nsis-icon.ico b/packaging/windows/nsis-icon.ico new file mode 100644 index 0000000..5f1fc78 Binary files /dev/null and b/packaging/windows/nsis-icon.ico differ diff --git a/pyproject.toml b/pyproject.toml index 21a6178..4996d9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "clawd-codex" -version = "0.2.8" +version = "0.4.8" description = "Jonathan Ai — local desktop and CLI agent (Python Claude Code-style runtime)" readme = "README.md" -license = {text = "MIT"} +license = "MIT" +license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"] requires-python = ">=3.10" authors = [ {name = "Clawd Codex Team", email = "team@example.com"} @@ -16,7 +17,6 @@ keywords = ["claude", "cli", "ai", "llm", "anthropic", "openai", "glm"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -32,6 +32,27 @@ dependencies = [ "rich", "prompt-toolkit", "tiktoken>=0.7.0", + "Pillow>=10.4.0", + "opencv-python-headless>=4.10.0", + "rapidocr-onnxruntime>=1.4.4", + "trimesh>=4.4.0", + "psutil>=6.0.0", + "yfinance>=0.2.54", + "pandas>=2.2.0", + "pypdf>=5.0.0", + "python-docx>=1.1.2", + "openpyxl>=3.1.5", + "python-pptx>=1.0.2", + "pyautogui>=0.9.54", + "uv>=0.5.0", + "torch>=2.4.0", + "diffusers>=0.35.0", + "accelerate>=1.0.0", + "transformers>=4.48.0", + "safetensors>=0.5.0", + "faster-whisper>=1.1.0", + "feedparser>=6.0.11", + "yt-dlp>=2025.1.1", ] [project.optional-dependencies] @@ -50,10 +71,17 @@ jonathan-ai-desktop = "src.desktop.__main__:main" jonathan-ai-install = "src.install.__main__:main" [project.urls] -Homepage = "https://github.com/GPT-AGI/Clawd-Codex" -Repository = "https://github.com/GPT-AGI/Clawd-Codex" -Documentation = "https://github.com/GPT-AGI/Clawd-Codex#readme" +Homepage = "https://github.com/GoDeskio/Clawd-Code" +Repository = "https://github.com/GoDeskio/Clawd-Code" +Documentation = "https://github.com/GoDeskio/Clawd-Code#readme" [tool.setuptools.packages.find] where = ["."] include = ["src*"] + +[tool.setuptools.package-data] +"src.skills" = ["bundled/**/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +norecursedirs = ["integrations", ".venv", "node_modules", ".git"] diff --git a/requirements.txt b/requirements.txt index fcf8fe8..69fc701 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,27 @@ huggingface_hub>=0.24.0 python-dotenv>=1.0.0 rich>=13.0.0 prompt-toolkit>=3.0.0 +Pillow>=10.4.0 +opencv-python-headless>=4.10.0 +rapidocr-onnxruntime>=1.4.4 +trimesh>=4.4.0 +psutil>=6.0.0 +yfinance>=0.2.54 +pandas>=2.2.0 +pypdf>=5.0.0 +python-docx>=1.1.2 +openpyxl>=3.1.5 +python-pptx>=1.0.2 +pyautogui>=0.9.54 +uv>=0.5.0 +torch>=2.4.0 +diffusers>=0.35.0 +accelerate>=1.0.0 +transformers>=4.48.0 +safetensors>=0.5.0 +faster-whisper>=1.1.0 +feedparser>=6.0.11 +yt-dlp>=2025.1.1 # Development Dependencies build>=1.0.0 diff --git a/src/agent/autonomy.py b/src/agent/autonomy.py new file mode 100644 index 0000000..8569c43 --- /dev/null +++ b/src/agent/autonomy.py @@ -0,0 +1,166 @@ +"""Local evidence profiles for earned, revocable agent autonomy. + +This module measures approval history; it never bypasses Jonathan's permission +enforcement. A recommendation becomes authority only through an explicit user +grant such as "Always this session" or the separately confirmed device-access +setting. +""" + +from __future__ import annotations + +import json +import math +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +_LOCK = threading.RLock() +_LEVELS = ( + "Observe only", + "Suggest", + "Draft", + "Execute with approval", + "Execute with sampling", + "Conditional autonomy", + "Exception-based supervision", + "Delegated authority", +) +_HIGH_RISK = { + "bash", "powershell", "terminal", "write", "edit", "notebookedit", + "devicecontrol", "systemadmin", "repository", "trading", +} +_MEDIUM_RISK = {"webfetch", "websearch", "mcp", "externalagent", "remotetrigger"} + + +def autonomy_store_path() -> Path: + path = Path.home() / ".clawd" / "autonomy" / "evidence.json" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def wilson_lower_bound(successes: int, total: int, z: float = 1.96) -> float: + """Return the conservative 95% lower bound for a binomial proportion.""" + if total <= 0: + return 0.0 + successes = max(0, min(int(successes), int(total))) + proportion = successes / total + denominator = 1 + z * z / total + centre = proportion + z * z / (2 * total) + margin = z * math.sqrt((proportion * (1 - proportion) + z * z / (4 * total)) / total) + return max(0.0, (centre - margin) / denominator) + + +def _read() -> dict[str, Any]: + target = autonomy_store_path() + if not target.is_file(): + return {"version": 1, "profiles": {}} + try: + data = json.loads(target.read_text(encoding="utf-8")) + if isinstance(data, dict) and isinstance(data.get("profiles"), dict): + return data + except (OSError, UnicodeError, json.JSONDecodeError): + pass + return {"version": 1, "profiles": {}} + + +def _write(data: dict[str, Any]) -> None: + target = autonomy_store_path() + temporary = target.with_suffix(".tmp") + temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(target) + + +def _risk(tool_name: str) -> tuple[str, int]: + lowered = str(tool_name or "unknown").strip().lower() + if lowered in _HIGH_RISK: + return "high", 3 + if lowered in _MEDIUM_RISK: + return "medium", 4 + return "low", 5 + + +def record_permission_evidence( + agent_id: str, + tool_name: str, + decision: str, + event_id: str, +) -> dict[str, Any]: + """Record one distinct human decision without storing prompts or tool input.""" + agent = str(agent_id or "default") + tool = str(tool_name or "unknown").strip().lower() + outcome = str(decision or "deny").strip().lower() + clean = outcome in {"allow", "yes", "y", "once", "always", "always_allow", "session", "allow_docs", "enable_docs"} + with _LOCK: + data = _read() + key = f"{agent}:{tool}" + profile = data["profiles"].setdefault(key, { + "agent_id": agent, + "workflow": tool, + "clean_approvals": 0, + "rejections": 0, + "event_ids": [], + }) + ids = list(profile.get("event_ids") or []) + if event_id in ids: + return _profile(profile) + ids.append(str(event_id)) + profile["event_ids"] = ids[-2000:] + if clean: + profile["clean_approvals"] = int(profile.get("clean_approvals") or 0) + 1 + else: + profile["rejections"] = int(profile.get("rejections") or 0) + 1 + profile["last_decision"] = outcome + profile["updated_at"] = datetime.now(timezone.utc).isoformat() + _write(data) + return _profile(profile) + + +def _profile(raw: dict[str, Any]) -> dict[str, Any]: + clean = int(raw.get("clean_approvals") or 0) + rejected = int(raw.get("rejections") or 0) + total = clean + rejected + confidence = wilson_lower_bound(clean, total) + risk, ceiling = _risk(str(raw.get("workflow") or "")) + recommended = 3 + if risk == "low" and total >= 20 and confidence >= 0.88: + recommended = 5 + elif risk in {"low", "medium"} and total >= 8 and confidence >= 0.80: + recommended = 4 + recommended = min(recommended, ceiling) + return { + "agent_id": str(raw.get("agent_id") or "default"), + "workflow": str(raw.get("workflow") or "unknown"), + "clean_approvals": clean, + "rejections": rejected, + "total_decisions": total, + "clean_approval_lower_bound": round(confidence, 4), + "risk": risk, + "autonomy_ceiling": ceiling, + "autonomy_ceiling_label": _LEVELS[ceiling], + "recommended_level": recommended, + "recommended_level_label": _LEVELS[recommended], + "enforced_level": 3, + "enforced_level_label": _LEVELS[3], + "updated_at": str(raw.get("updated_at") or ""), + } + + +def autonomy_status(agent_id: str | None = None) -> dict[str, Any]: + """Return transparent recommendations for one agent or all agents.""" + with _LOCK: + data = _read() + profiles = [ + _profile(row) for row in data.get("profiles", {}).values() + if not agent_id or str(row.get("agent_id")) == str(agent_id) + ] + profiles.sort(key=lambda row: (row["agent_id"], row["workflow"])) + return { + "agent_id": str(agent_id or ""), + "profiles": profiles, + "profile_count": len(profiles), + "mode": "recommendation_only", + "enforcement": "Explicit user approval remains required; recommendations never widen access.", + "store_path": str(autonomy_store_path()), + } diff --git a/src/agent/checkpoints.py b/src/agent/checkpoints.py new file mode 100644 index 0000000..4eeae6e --- /dev/null +++ b/src/agent/checkpoints.py @@ -0,0 +1,122 @@ +"""Recoverable per-conversation checkpoints for retry, undo, and crash recovery.""" + +from __future__ import annotations + +import json +import os +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from src.agent.conversation import Conversation, ImageContentBlock, TextContentBlock +from src.agent.session import Session + +_LOCK = threading.RLock() + + +def checkpoint_dir() -> Path: + path = Path.home() / ".clawd" / "checkpoints" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _path(session_id: str) -> Path: + return checkpoint_dir() / f"{session_id}.json" + + +def _write(path: Path, payload: dict[str, Any]) -> None: + temp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(temp, path) + finally: + try: + temp.unlink() + except FileNotFoundError: + pass + + +def create_checkpoint(session: Session, *, reason: str = "before-run", keep: int = 20) -> dict[str, Any]: + with _LOCK: + path = _path(session.session_id) + try: + data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"checkpoints": []} + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + data = {"checkpoints": []} + item = { + "id": uuid.uuid4().hex[:12], + "created_at": datetime.now(timezone.utc).isoformat(), + "reason": reason, + "title": session.display_title(), + "provider": session.provider, + "model": session.model, + "workspace": session.workspace, + "conversation": session.conversation.to_dict(), + "token_usage": session.token_usage, + } + rows = [row for row in data.get("checkpoints", []) if isinstance(row, dict)] + rows.append(item) + _write(path, {"session_id": session.session_id, "checkpoints": rows[-max(1, keep):]}) + return {key: value for key, value in item.items() if key != "conversation"} + + +def list_checkpoints(session_id: str) -> list[dict[str, Any]]: + path = _path(session_id) + if not path.exists(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return [] + rows = [row for row in data.get("checkpoints", []) if isinstance(row, dict)] + return [{key: value for key, value in row.items() if key != "conversation"} for row in reversed(rows)] + + +def restore_checkpoint(session: Session, checkpoint_id: str = "") -> dict[str, Any]: + path = _path(session.session_id) + if not path.exists(): + raise ValueError("No recovery checkpoint exists for this conversation.") + data = json.loads(path.read_text(encoding="utf-8")) + rows = [row for row in data.get("checkpoints", []) if isinstance(row, dict)] + chosen = next((row for row in reversed(rows) if not checkpoint_id or row.get("id") == checkpoint_id), None) + if chosen is None: + raise ValueError("Checkpoint not found.") + session.conversation = Conversation.from_dict(chosen.get("conversation") or {}) + session.token_usage = dict(chosen.get("token_usage") or {}) + session.save() + return {"restored": chosen.get("id"), "session": session.to_summary(), "messages": session.export_messages()} + + +def undo_last_turn(session: Session) -> dict[str, Any]: + messages = session.conversation.messages + user_index = next(( + index for index in range(len(messages) - 1, -1, -1) + if messages[index].role == "user" + and not getattr(messages[index], "_is_internal", False) + and ( + isinstance(messages[index].content, str) + or any(isinstance(block, (TextContentBlock, ImageContentBlock)) for block in (messages[index].content or [])) + ) + ), None) + if user_index is None: + raise ValueError("There is no completed turn to undo.") + create_checkpoint(session, reason="before-undo") + del messages[user_index:] + session.save() + return {"undone": True, "session": session.to_summary(), "messages": session.export_messages()} + + +def prepare_retry(session: Session) -> str: + messages = session.conversation.messages + user_index = next((index for index in range(len(messages) - 1, -1, -1) if messages[index].role == "user" and isinstance(messages[index].content, str)), None) + if user_index is None: + raise ValueError("There is no user prompt to retry.") + prompt = str(messages[user_index].content).strip() + if not prompt: + raise ValueError("The last prompt is empty.") + create_checkpoint(session, reason="before-retry") + del messages[user_index:] + session.save() + return prompt diff --git a/src/agent/conversation.py b/src/agent/conversation.py index 93d359f..3961905 100644 --- a/src/agent/conversation.py +++ b/src/agent/conversation.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass, field from datetime import datetime from typing import Any, Union @@ -28,6 +29,31 @@ "mcp_tool_result", }) +_LEGACY_BINARY_UPLOAD = re.compile( + r"\[clipboard attachment:\s*([^\]]+\.(?:png|jpe?g|webp|gif|bmp|tiff?|pdf|docx|xlsx|pptx))\]", + re.IGNORECASE, +) + + +def sanitize_legacy_binary_upload_text(content: str) -> str: + """Hide binary bytes that desktop versions before 0.4.3 decoded as text.""" + text = str(content or "") + match = _LEGACY_BINARY_UPLOAD.search(text) + if not match: + return text + sample = text[:4096] + looks_binary = "JFIF" in sample or "PNG" in sample[:128] or "\x00" in sample or sample.count("�") >= 8 + if not looks_binary: + return text + name = match.group(1) + prefix = text[:match.start()].strip() + lead = f"{prefix}\n\n" if prefix else "" + return ( + f"{lead}[uploaded file: {name}]\n" + "(This file was corrupted by a legacy binary-upload bug and its text payload was hidden. " + "Re-upload the original file if you want Jonathan to inspect it.)" + ) + def _is_anthropic_content_block(item: Any) -> bool: """True when item looks like an Anthropic content block, not a tool dict.""" @@ -99,6 +125,25 @@ class TextContentBlock: """A text content block.""" type: str = "text" text: str = "" + # Optional transcript-only text. Providers always receive ``text``. + display_text: str | None = None + + +@dataclass +class ImageContentBlock: + """A provider-neutral image block stored in Anthropic wire format.""" + type: str = "image" + source: dict[str, str] = field(default_factory=dict) + + +@dataclass +class AttachmentContentBlock: + """Transcript-only downloadable user attachment metadata.""" + type: str = "attachment" + name: str = "" + download_url: str = "" + media_type: str = "application/octet-stream" + size: int = 0 @dataclass @@ -119,7 +164,7 @@ class ToolResultContentBlock: is_error: bool = False -ContentBlock = Union[TextContentBlock, ToolUseContentBlock, ToolResultContentBlock] +ContentBlock = Union[TextContentBlock, ImageContentBlock, AttachmentContentBlock, ToolUseContentBlock, ToolResultContentBlock] @dataclass @@ -136,18 +181,20 @@ class Message: class Conversation: """Conversation manager.""" messages: list[Message] = field(default_factory=list) - max_history: int = 100 + # Zero means durable/unbounded history. Provider context windows are handled + # separately; Jonathan never deletes old turns merely to enforce an app quota. + max_history: int = 0 def add_message(self, role: str, content: Union[str, list[ContentBlock]]): """Add a message to conversation.""" - if len(self.messages) >= self.max_history: + if self.max_history > 0 and len(self.messages) >= self.max_history: self.messages.pop(0) self.messages.append(Message(role=role, content=content)) - def add_user_message(self, text: str): - """Add a plain user text message.""" - self.add_message("user", text) + def add_user_message(self, content: Union[str, list[ContentBlock]]): + """Add user text or multimodal content blocks.""" + self.add_message("user", content) def add_assistant_message(self, content: Union[str, list[ContentBlock]]): """Add an assistant message (text or tool use).""" @@ -171,12 +218,18 @@ def get_messages(self) -> list[dict]: if getattr(msg, "_is_internal", False): continue if isinstance(msg.content, str): - api_messages.append({"role": msg.role, "content": msg.content}) + api_messages.append({"role": msg.role, "content": sanitize_legacy_binary_upload_text(msg.content)}) else: content_blocks = [] for block in msg.content: if isinstance(block, TextContentBlock): content_blocks.append({"type": "text", "text": block.text}) + elif isinstance(block, ImageContentBlock): + content_blocks.append({"type": "image", "source": dict(block.source)}) + elif isinstance(block, AttachmentContentBlock): + # UI metadata is not a provider content block. The staged + # path and extracted text are already present in text. + continue elif isinstance(block, ToolUseContentBlock): content_blocks.append({ "type": "tool_use", @@ -208,7 +261,14 @@ def to_dict(self) -> dict: content_data = [] for block in msg.content: if isinstance(block, TextContentBlock): - content_data.append({"type": "text", "text": block.text}) + content_data.append({"type": "text", "text": block.text, "display_text": block.display_text}) + elif isinstance(block, ImageContentBlock): + content_data.append({"type": "image", "source": dict(block.source)}) + elif isinstance(block, AttachmentContentBlock): + content_data.append({ + "type": "attachment", "name": block.name, "download_url": block.download_url, + "media_type": block.media_type, "size": block.size, + }) elif isinstance(block, ToolUseContentBlock): content_data.append({ "type": "tool_use", @@ -237,7 +297,8 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> 'Conversation': """Deserialize conversation.""" - conv = cls(max_history=data.get("max_history", 100)) + # Migrate older sessions that stored the former 100-message UI cap. + conv = cls(max_history=0) for msg_data in data.get("messages", []): content = msg_data["content"] if isinstance(content, str): @@ -247,7 +308,19 @@ def from_dict(cls, data: dict) -> 'Conversation': for block_data in content: block_type = block_data.get("type") if block_type == "text": - msg_content.append(TextContentBlock(type="text", text=block_data.get("text", ""))) + msg_content.append(TextContentBlock( + type="text", text=block_data.get("text", ""), + display_text=block_data.get("display_text"), + )) + elif block_type == "image" and isinstance(block_data.get("source"), dict): + msg_content.append(ImageContentBlock(type="image", source=dict(block_data["source"]))) + elif block_type == "attachment": + msg_content.append(AttachmentContentBlock( + type="attachment", name=str(block_data.get("name") or "attachment"), + download_url=str(block_data.get("download_url") or ""), + media_type=str(block_data.get("media_type") or "application/octet-stream"), + size=int(block_data.get("size") or 0), + )) elif block_type == "tool_use": msg_content.append(ToolUseContentBlock( type="tool_use", diff --git a/src/agent/event_log.py b/src/agent/event_log.py new file mode 100644 index 0000000..3624509 --- /dev/null +++ b/src/agent/event_log.py @@ -0,0 +1,93 @@ +"""Tamper-evident append-only runtime facts for session recovery and audit.""" + +from __future__ import annotations + +import hashlib +import json +import re +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from src.tool_system.audit import redact + +_LOCK = threading.RLock() +_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]+") + + +def event_log_dir() -> Path: + path = Path.home() / ".clawd" / "events" + path.mkdir(parents=True, exist_ok=True) + return path + + +def event_log_path(session_id: str) -> Path: + safe = _SAFE_ID.sub("", str(session_id or ""))[:160] + if not safe: + raise ValueError("session_id is required") + return event_log_dir() / f"{safe}.jsonl" + + +def _hash(row: dict[str, Any]) -> str: + encoded = json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _last_hash(path: Path) -> str: + if not path.is_file(): + return "" + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + return str(json.loads(lines[-1]).get("event_hash") or "") if lines else "" + except (OSError, json.JSONDecodeError): + return "" + + +def append_runtime_event(session_id: str, event_type: str, payload: dict[str, Any] | None = None, *, + job_id: str = "", workspace: str = "") -> dict[str, Any]: + """Append one redacted fact; existing records are never rewritten.""" + target = event_log_path(session_id) + with _LOCK: + body = { + "event_id": uuid.uuid4().hex, + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id": str(session_id), + "job_id": str(job_id or ""), + "type": str(event_type or "event"), + "workspace": str(workspace or ""), + "payload": redact(dict(payload or {})), + "previous_hash": _last_hash(target), + } + body["event_hash"] = _hash(body) + with target.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(json.dumps(body, ensure_ascii=False, default=str) + "\n") + return body + + +def read_runtime_events(session_id: str, limit: int = 500) -> dict[str, Any]: + target = event_log_path(session_id) + if not target.is_file(): + return {"session_id": session_id, "events": [], "valid_chain": True, "path": str(target)} + with _LOCK: + lines = target.read_text(encoding="utf-8", errors="replace").splitlines() + events: list[dict[str, Any]] = [] + expected_previous = "" + valid = True + for line in lines: + try: + row = json.loads(line) + except json.JSONDecodeError: + valid = False + continue + claimed = str(row.pop("event_hash", "")) + if str(row.get("previous_hash") or "") != expected_previous or _hash(row) != claimed: + valid = False + row["event_hash"] = claimed + expected_previous = claimed + events.append(row) + bounded = max(1, min(int(limit), 5000)) + return {"session_id": session_id, "events": events[-bounded:], "event_count": len(events), + "valid_chain": valid, "path": str(target)} + diff --git a/src/agent/history_search.py b/src/agent/history_search.py new file mode 100644 index 0000000..a100db4 --- /dev/null +++ b/src/agent/history_search.py @@ -0,0 +1,152 @@ +"""Local full-text search across durable Jonathan Ai conversations and memory.""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +from contextlib import closing +from pathlib import Path +from typing import Any + +from src.agent.memory import list_conversation_index, list_facts, memory_dir +from src.agent.session import Session, _message_text, session_dir + +_LOCK = threading.RLock() + + +def search_database_path() -> Path: + path = memory_dir() / "search.sqlite3" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def _connect() -> sqlite3.Connection: + connection = sqlite3.connect(search_database_path(), timeout=20) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA busy_timeout=20000") + return connection + + +def _source_stamp() -> str: + paths = list(session_dir().glob("*.json")) + [memory_dir() / "facts.json", memory_dir() / "index.json"] + parts: list[str] = [] + for path in sorted(paths): + try: + stat = path.stat() + except OSError: + continue + parts.append(f"{path.name}:{stat.st_mtime_ns}:{stat.st_size}") + return "|".join(parts) + + +def _current_index_metadata() -> dict[str, Any] | None: + if not search_database_path().exists(): + return None + try: + with closing(_connect()) as db: + rows = db.execute("SELECT key, value FROM history_meta").fetchall() + except sqlite3.Error: + return None + meta = {str(row["key"]): str(row["value"]) for row in rows} + if meta.get("source_stamp") != _source_stamp(): + return None + return { + "documents": int(meta.get("documents") or 0), + "fts5": meta.get("fts5") == "1", + "path": str(search_database_path()), + } + + +def rebuild_history_index() -> dict[str, Any]: + """Rebuild the compact search index from authoritative UTF-8 JSON state.""" + with _LOCK, closing(_connect()) as db: + db.execute("DROP TABLE IF EXISTS history_fts") + db.execute("DROP TABLE IF EXISTS history") + db.execute("DROP TABLE IF EXISTS history_meta") + db.execute("CREATE TABLE history_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + db.execute( + "CREATE TABLE history (id INTEGER PRIMARY KEY, kind TEXT NOT NULL, " + "session_id TEXT NOT NULL, title TEXT NOT NULL, role TEXT NOT NULL, " + "content TEXT NOT NULL, timestamp TEXT NOT NULL)" + ) + fts = True + try: + db.execute( + "CREATE VIRTUAL TABLE history_fts USING fts5(title, content, " + "content='history', content_rowid='id', tokenize='unicode61')" + ) + except sqlite3.OperationalError: + fts = False + rows: list[tuple[str, str, str, str, str, str]] = [] + for summary in Session.list_sessions(): + session_id = str(summary.get("session_id") or "") + loaded = Session.load(session_id) + if loaded is None: + continue + title = loaded.display_title() + for message in loaded.conversation.messages: + if getattr(message, "_is_internal", False): + continue + content = _message_text(message.content).strip() + if content: + rows.append(("message", session_id, title, message.role, content, message.timestamp or "")) + for fact in list_facts(): + content = str(fact.get("text") or "").strip() + if content: + rows.append(("memory", str(fact.get("source_session") or ""), "Shared memory", "memory", content, str(fact.get("created_at") or ""))) + # Conversation summaries remain searchable even after a chat is hidden. + for item in list_conversation_index(): + content = str(item.get("summary") or "").strip() + if content: + rows.append(("summary", str(item.get("session_id") or ""), str(item.get("title") or "Conversation"), "summary", content, str(item.get("updated_at") or ""))) + db.executemany( + "INSERT INTO history(kind, session_id, title, role, content, timestamp) VALUES(?,?,?,?,?,?)", + rows, + ) + if fts: + db.execute("INSERT INTO history_fts(rowid, title, content) SELECT id, title, content FROM history") + db.executemany("INSERT INTO history_meta(key, value) VALUES(?,?)", [ + ("source_stamp", _source_stamp()), ("documents", str(len(rows))), ("fts5", "1" if fts else "0"), + ]) + db.commit() + return {"documents": len(rows), "fts5": fts, "path": str(search_database_path())} + + +def _fts_query(query: str) -> str: + words = [word.strip('"\'()[]{}:*+-') for word in query.split()] + return " AND ".join(f'"{word.replace(chr(34), chr(34) * 2)}"' for word in words if word) + + +def search_history(query: str, *, limit: int = 30) -> dict[str, Any]: + cleaned = " ".join(str(query or "").split()) + if not cleaned: + return {"query": "", "hits": [], **rebuild_history_index()} + meta = _current_index_metadata() or rebuild_history_index() + capped = max(1, min(int(limit), 100)) + with _LOCK, closing(_connect()) as db: + if meta["fts5"]: + sql = ( + "SELECT h.*, snippet(history_fts, 1, '[', ']', ' … ', 22) AS snippet " + "FROM history_fts JOIN history h ON h.id=history_fts.rowid " + "WHERE history_fts MATCH ? ORDER BY bm25(history_fts), h.timestamp DESC LIMIT ?" + ) + try: + found = db.execute(sql, (_fts_query(cleaned), capped)).fetchall() + except sqlite3.OperationalError: + found = [] + else: + pattern = f"%{cleaned}%" + found = db.execute( + "SELECT h.*, content AS snippet FROM history h WHERE title LIKE ? OR content LIKE ? " + "ORDER BY timestamp DESC LIMIT ?", + (pattern, pattern, capped), + ).fetchall() + hits = [dict(row) for row in found] + for hit in hits: + hit.pop("id", None) + if not hit.get("snippet"): + hit["snippet"] = str(hit.get("content") or "")[:240] + hit.pop("content", None) + return {"query": cleaned, "hits": hits, **meta} diff --git a/src/agent/memory.py b/src/agent/memory.py index f84def6..51efce7 100644 --- a/src/agent/memory.py +++ b/src/agent/memory.py @@ -7,17 +7,22 @@ from __future__ import annotations import json +import os import re +import threading import uuid from datetime import datetime from pathlib import Path from typing import Any -MAX_FACTS = 80 -MAX_INDEX = 40 +# Retain durable knowledge and conversation metadata instead of silently +# forgetting older projects. Prompt injection still uses a small relevant tail. +MAX_FACTS = 5000 +MAX_INDEX = 10000 MAX_FACT_CHARS = 240 MAX_SUMMARY_CHARS = 180 MAX_BRIEF_CHARS = 1600 +_MEMORY_LOCK = threading.RLock() _SECRET_RE = re.compile( r"(?i)\b(sk-[a-z0-9_\-]{8,}|ghp_[a-z0-9]{8,}|glpat-[a-z0-9\-_]{8,}|xox[baprs]-[a-z0-9-]{8,}|api[_-]?key\s*[:=]\s*\S+|bearer\s+[a-z0-9._\-]+)\b" @@ -55,7 +60,15 @@ def _read_json(path: Path, fallback: dict[str, Any]) -> dict[str, Any]: def _write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + temp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + temp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(temp, path) + finally: + try: + temp.unlink() + except FileNotFoundError: + pass def sanitize_memory_text(text: str, *, limit: int = MAX_FACT_CHARS) -> str: @@ -67,37 +80,40 @@ def sanitize_memory_text(text: str, *, limit: int = MAX_FACT_CHARS) -> str: def list_facts() -> list[dict[str, Any]]: - data = _read_json(_facts_path(), {"facts": []}) - rows = data.get("facts") - return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + with _MEMORY_LOCK: + data = _read_json(_facts_path(), {"facts": []}) + rows = data.get("facts") + return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] def add_fact(text: str, *, source_session: str = "") -> dict[str, Any] | None: cleaned = sanitize_memory_text(text) if len(cleaned) < 4: return None - facts = list_facts() - lowered = cleaned.lower() - for existing in facts: - if str(existing.get("text") or "").strip().lower() == lowered: - return existing - item = { - "id": uuid.uuid4().hex[:12], - "text": cleaned, - "source_session": source_session, - "created_at": datetime.now().isoformat(), - } - facts.append(item) - _write_json(_facts_path(), {"facts": facts[-MAX_FACTS:]}) - return item + with _MEMORY_LOCK: + facts = list_facts() + lowered = cleaned.lower() + for existing in facts: + if str(existing.get("text") or "").strip().lower() == lowered: + return existing + item = { + "id": uuid.uuid4().hex[:12], + "text": cleaned, + "source_session": source_session, + "created_at": datetime.now().isoformat(), + } + facts.append(item) + _write_json(_facts_path(), {"facts": facts[-MAX_FACTS:]}) + return item def list_conversation_index() -> list[dict[str, Any]]: - data = _read_json(_index_path(), {"conversations": []}) - rows = data.get("conversations") - items = [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] - items.sort(key=lambda row: row.get("updated_at") or "", reverse=True) - return items + with _MEMORY_LOCK: + data = _read_json(_index_path(), {"conversations": []}) + rows = data.get("conversations") + items = [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + items.sort(key=lambda row: row.get("updated_at") or "", reverse=True) + return items def upsert_conversation_index( @@ -106,16 +122,17 @@ def upsert_conversation_index( title: str, summary: str, ) -> dict[str, Any]: - item = { - "session_id": session_id, - "title": sanitize_memory_text(title, limit=72) or "New chat", - "summary": sanitize_memory_text(summary, limit=MAX_SUMMARY_CHARS), - "updated_at": datetime.now().isoformat(), - } - rows = [row for row in list_conversation_index() if row.get("session_id") != session_id] - rows.insert(0, item) - _write_json(_index_path(), {"conversations": rows[:MAX_INDEX]}) - return item + with _MEMORY_LOCK: + item = { + "session_id": session_id, + "title": sanitize_memory_text(title, limit=72) or "New chat", + "summary": sanitize_memory_text(summary, limit=MAX_SUMMARY_CHARS), + "updated_at": datetime.now().isoformat(), + } + rows = [row for row in list_conversation_index() if row.get("session_id") != session_id] + rows.insert(0, item) + _write_json(_index_path(), {"conversations": rows[:MAX_INDEX]}) + return item def extract_facts(user_text: str) -> list[str]: diff --git a/src/agent/multi_agent.py b/src/agent/multi_agent.py index c225080..612eefd 100644 --- a/src/agent/multi_agent.py +++ b/src/agent/multi_agent.py @@ -14,10 +14,12 @@ from typing import Any from src.agent.memory import build_memory_brief, remember_turn +from src.agent.session import empty_token_usage, merge_token_usage from src.providers.base import BaseProvider, ChatResponse MAX_WORKERS = 3 +WORKER_MODES = {"fast": 2, "balanced": 3, "verified": 3} def plan_subtasks(goal: str, *, max_workers: int = MAX_WORKERS) -> list[str]: @@ -85,8 +87,10 @@ def _run_one_worker( system=system, ) answer = (response.content if isinstance(response, ChatResponse) else str(response)) or "" + usage = dict(response.usage or {}) if isinstance(response, ChatResponse) else {} except Exception as exc: answer = f"Worker failed: {exc}" + usage = {} title = prompt.splitlines()[0][:72] or f"Worker {index + 1}" try: remember_turn( @@ -103,6 +107,7 @@ def _run_one_worker( "title": title, "prompt": prompt, "answer": answer, + "usage": usage, } @@ -112,15 +117,20 @@ def run_internal_workers( goal: str, parent_session_id: str = "", max_workers: int = MAX_WORKERS, + mode: str = "balanced", ) -> dict[str, Any]: """Plan subtasks and run isolated workers in parallel. No external agents.""" if provider is None: raise ValueError("Connect a provider before running workers.") - subtasks = _planner_subtasks(provider, goal, max_workers=max_workers) + selected_mode = str(mode or "balanced").strip().lower() + if selected_mode not in WORKER_MODES: + raise ValueError(f"unknown worker mode: {mode}") + worker_cap = min(max(1, int(max_workers)), WORKER_MODES[selected_mode], MAX_WORKERS) + subtasks = _planner_subtasks(provider, goal, max_workers=worker_cap) if not subtasks: raise ValueError("Goal is empty.") results: list[dict[str, Any]] = [None] * len(subtasks) # type: ignore[list-item] - workers = min(len(subtasks), MAX_WORKERS) + workers = min(len(subtasks), worker_cap) with ThreadPoolExecutor(max_workers=max(1, workers)) as pool: futures = { pool.submit( @@ -141,10 +151,38 @@ def run_internal_workers( for item in results if item ) + usage = empty_token_usage() + for item in results: + if item: + usage = merge_token_usage(usage, item.get("usage") or {}) + review = "" + answer = answers[0] if len(answers) == 1 else combined + if selected_mode == "verified": + system = ( + "You are Jonathan Ai's independent final verifier. You did not author the worker outputs. " + "Check them against the original goal, identify conflicts or missing evidence, and return a concise " + "corrected final answer. Never claim checks you did not perform." + ) + try: + response = provider.chat( + [{"role": "user", "content": f"Goal:\n{goal}\n\nWorker outputs:\n{combined}"}], + tools=None, + system=system, + ) + review = (response.content if isinstance(response, ChatResponse) else str(response)) or "" + if review: + answer = review + if isinstance(response, ChatResponse): + usage = merge_token_usage(usage, response.usage or {}) + except Exception as exc: + review = f"Independent verification failed; returning worker evidence unchanged: {exc}" return { "ok": True, "goal": goal, + "mode": selected_mode, "workers": results, "combined": combined, - "answer": answers[0] if len(answers) == 1 else combined, + "review": review, + "answer": answer, + "usage": usage, } diff --git a/src/agent/roster.py b/src/agent/roster.py new file mode 100644 index 0000000..7c5595e --- /dev/null +++ b/src/agent/roster.py @@ -0,0 +1,140 @@ +"""Persistent named agent profiles and local agent-to-agent mailboxes.""" + +from __future__ import annotations + +import json +import os +import re +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_LOCK = threading.RLock() +_NAME = re.compile(r"^[^\x00-\x1f]{1,60}$") + + +def roster_path() -> Path: + path = Path.home() / ".clawd" / "agent_profiles.json" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def _load() -> dict[str, Any]: + path = roster_path() + if not path.exists(): + return {"version": 1, "agents": [], "messages": []} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return {"version": 1, "agents": [], "messages": []} + return data if isinstance(data, dict) else {"version": 1, "agents": [], "messages": []} + + +def _save(data: dict[str, Any]) -> None: + path = roster_path() + temp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + temp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(temp, path) + finally: + try: + temp.unlink() + except FileNotFoundError: + pass + + +def list_agents() -> list[dict[str, Any]]: + with _LOCK: + rows = [dict(row) for row in _load().get("agents", []) if isinstance(row, dict)] + rows.sort(key=lambda row: str(row.get("name") or "").lower()) + return rows + + +def get_agent(agent_id: str) -> dict[str, Any] | None: + return next((row for row in list_agents() if row.get("id") == agent_id), None) + + +def save_agent(payload: dict[str, Any], *, canonical_session_id: str | None = None) -> dict[str, Any]: + name = " ".join(str(payload.get("name") or "").split()) + if not _NAME.fullmatch(name): + raise ValueError("Agent name must be 1-60 printable characters.") + instructions = str(payload.get("instructions") or payload.get("role") or "").strip() + if not instructions: + raise ValueError("Agent instructions are required.") + now = datetime.now(timezone.utc).isoformat() + with _LOCK: + data = _load() + agents = [row for row in data.get("agents", []) if isinstance(row, dict)] + agent_id = str(payload.get("id") or "").strip() + existing = next((row for row in agents if row.get("id") == agent_id), None) if agent_id else None + if existing is None: + agent_id = uuid.uuid4().hex[:12] + existing = {"id": agent_id, "created_at": now} + agents.append(existing) + existing.update({ + "name": name, + "instructions": instructions[:12_000], + "workspace": str(payload.get("workspace") or existing.get("workspace") or ""), + "provider": str(payload.get("provider") or existing.get("provider") or ""), + "model": str(payload.get("model") or existing.get("model") or ""), + "avatar": str(payload.get("avatar") or existing.get("avatar") or "robot"), + "canonical_session_id": str(canonical_session_id or existing.get("canonical_session_id") or ""), + "updated_at": now, + }) + data["agents"] = agents + data.setdefault("messages", []) + _save(data) + return dict(existing) + + +def remove_agent(agent_id: str) -> dict[str, Any]: + with _LOCK: + data = _load() + before = len(data.get("agents", [])) + data["agents"] = [row for row in data.get("agents", []) if isinstance(row, dict) and row.get("id") != agent_id] + if len(data["agents"]) == before: + raise ValueError("Agent profile not found.") + # Conversation history is intentionally retained; only the roster entry is removed. + _save(data) + return {"removed": agent_id, "conversation_retained": True} + + +def send_message(*, sender_session_id: str, recipient_agent_id: str, text: str) -> dict[str, Any]: + cleaned = str(text or "").strip() + if not cleaned: + raise ValueError("Message is empty.") + if get_agent(recipient_agent_id) is None: + raise ValueError("Recipient agent profile not found.") + item = { + "id": uuid.uuid4().hex[:12], + "sender_session_id": sender_session_id, + "recipient_agent_id": recipient_agent_id, + "text": cleaned[:40_000], + "created_at": datetime.now(timezone.utc).isoformat(), + "read_at": "", + } + with _LOCK: + data = _load() + messages = [row for row in data.get("messages", []) if isinstance(row, dict)] + messages.append(item) + data["messages"] = messages[-5000:] + _save(data) + return item + + +def read_inbox(agent_id: str, *, mark_read: bool = False, limit: int = 50) -> list[dict[str, Any]]: + with _LOCK: + data = _load() + rows = [row for row in data.get("messages", []) if isinstance(row, dict) and row.get("recipient_agent_id") == agent_id] + rows = rows[-max(1, min(limit, 200)):] + if mark_read: + now = datetime.now(timezone.utc).isoformat() + ids = {row.get("id") for row in rows} + for row in data.get("messages", []): + if isinstance(row, dict) and row.get("id") in ids and not row.get("read_at"): + row["read_at"] = now + _save(data) + return [dict(row) for row in reversed(rows)] + diff --git a/src/agent/session.py b/src/agent/session.py index 7a109e2..2da2d6e 100644 --- a/src/agent/session.py +++ b/src/agent/session.py @@ -3,17 +3,20 @@ from __future__ import annotations import json +import os +import re +import threading import uuid from pathlib import Path from datetime import datetime from typing import Optional from dataclasses import dataclass, field -from .conversation import Conversation +from .conversation import Conversation, sanitize_legacy_binary_upload_text def empty_token_usage() -> dict: - return {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + return {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "estimated": False} def merge_token_usage(current: dict | None, delta: dict | None) -> dict: @@ -25,6 +28,7 @@ def merge_token_usage(current: dict | None, delta: dict | None) -> dict: totals["input_tokens"] = int(totals.get("input_tokens") or 0) + max(inp, 0) totals["output_tokens"] = int(totals.get("output_tokens") or 0) + max(out, 0) totals["total_tokens"] = totals["input_tokens"] + totals["output_tokens"] + totals["estimated"] = bool(totals.get("estimated") or extra.get("estimated")) return totals @@ -36,21 +40,14 @@ def session_dir() -> Path: def _read_json_file(path: Path) -> dict: - """Read session/memory JSON as UTF-8. Never use the Windows locale (cp1252).""" - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) + """Read session JSON as UTF-8 and require an object payload.""" + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) if not isinstance(data, dict): raise json.JSONDecodeError("session JSON must be an object", "", 0) return data -def _write_json_file(path: Path, data: dict) -> None: - """Write session JSON as UTF-8 so non-ASCII tool results and titles survive Windows.""" - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - def _message_text(content) -> str: if isinstance(content, str): return content @@ -85,6 +82,7 @@ class Session: title: str = "New chat" custom_title: bool = False token_usage: dict = field(default_factory=empty_token_usage) + media_items: list[dict] = field(default_factory=list) def preview_title(self) -> str: derived = _title_from_conversation(self.conversation) @@ -126,19 +124,71 @@ def record_usage(self, usage: dict | None) -> dict: self.token_usage = merge_token_usage(self.token_usage, usage) return dict(self.token_usage) + def record_artifact(self, artifact: dict | None, *, caption: str = "Generated file") -> dict | None: + """Attach a safe, persistent artifact descriptor to this conversation.""" + if not isinstance(artifact, dict) or not artifact.get("name"): + return None + allowed = { + "id", "name", "size", "updated_at", "download_url", "view_url", + "media_type", "is_image", + } + item = {key: artifact[key] for key in allowed if key in artifact} + item["caption"] = str(caption or "Generated file") + item["timestamp"] = datetime.now().isoformat() + identity = str(item.get("id") or item.get("name")) + for existing in self.media_items: + if str(existing.get("id") or existing.get("name")) == identity: + return existing + self.media_items.append(item) + return item + def export_messages(self) -> list[dict]: - """User-visible messages for the desktop transcript (no tool dumps).""" + """User-visible rich transcript, including images and downloadable artifacts.""" rows: list[dict] = [] for msg in self.conversation.messages: if getattr(msg, "_is_internal", False): continue - text = _message_text(msg.content) - if not text.strip(): - continue if msg.role not in {"user", "assistant", "system"}: continue - rows.append({"role": msg.role, "content": text, "timestamp": msg.timestamp}) - return rows + if isinstance(msg.content, str): + visible = sanitize_legacy_binary_upload_text(msg.content) + if visible.strip(): + rows.append({"role": msg.role, "content": visible, "timestamp": msg.timestamp}) + continue + blocks: list[dict] = [] + visible_text = _message_text(msg.content) + attachment_names = re.findall(r"\[(?:image|screenshot) attachment: ([^\]]+)\]", visible_text) + image_index = 0 + for block in msg.content or []: + block_type = getattr(block, "type", "") + if block_type == "text" and str(getattr(block, "text", "")).strip(): + display_text = getattr(block, "display_text", None) + if display_text is None or str(display_text).strip(): + blocks.append({"type": "text", "text": str(block.text if display_text is None else display_text)}) + elif block_type == "image" and isinstance(getattr(block, "source", None), dict): + source = dict(block.source) + media_type = str(source.get("media_type") or "image/png") + extension = {"image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"}.get(media_type, "png") + name = attachment_names[image_index] if image_index < len(attachment_names) else f"uploaded-image-{image_index + 1}.{extension}" + blocks.append({"type": "image", "source": source, "name": name}) + image_index += 1 + elif block_type == "attachment" and str(getattr(block, "download_url", "")): + blocks.append({ + "type": "attachment", + "name": str(getattr(block, "name", "attachment")), + "download_url": str(block.download_url), + "media_type": str(getattr(block, "media_type", "application/octet-stream")), + "size": int(getattr(block, "size", 0) or 0), + }) + if blocks: + rows.append({"role": msg.role, "content": blocks, "timestamp": msg.timestamp}) + for item in self.media_items: + rows.append({ + "role": "assistant", + "content": [{"type": "artifact", **dict(item)}], + "timestamp": str(item.get("timestamp") or item.get("updated_at") or ""), + }) + return sorted(rows, key=lambda row: str(row.get("timestamp") or "")) def save(self): """Save session to disk.""" @@ -159,9 +209,23 @@ def save(self): "title": self.title, "custom_title": self.custom_title, "token_usage": merge_token_usage(self.token_usage, None), + "media_items": self.media_items, } - _write_json_file(session_file, session_data) + temp_file = session_file.with_name( + f"{session_file.name}.{os.getpid()}.{threading.get_ident()}.tmp" + ) + try: + temp_file.write_text( + json.dumps(session_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + os.replace(temp_file, session_file) + finally: + try: + temp_file.unlink() + except FileNotFoundError: + pass @classmethod def load(cls, session_id: str) -> Optional['Session']: @@ -188,6 +252,7 @@ def load(cls, session_id: str) -> Optional['Session']: title=data.get("title") or _title_from_conversation(conversation), custom_title=bool(data.get("custom_title")), token_usage=merge_token_usage(data.get("token_usage"), None), + media_items=[dict(item) for item in data.get("media_items") or [] if isinstance(item, dict)], ) @classmethod diff --git a/src/config.py b/src/config.py index afbef2a..21aa548 100644 --- a/src/config.py +++ b/src/config.py @@ -50,7 +50,7 @@ def _get_default_config_from_providers() -> dict[str, Any]: }, "session": { "auto_save": True, - "max_history": 100 + "max_history": 0 } }) @@ -265,37 +265,117 @@ def public_config() -> dict[str, Any]: def _encode_connector_secrets(config: dict[str, Any]) -> None: + local_credentials = config.get("local_runtime_credentials") + if isinstance(local_credentials, dict): + for slot in local_credentials.values(): + if not isinstance(slot, dict): + continue + for field in ("api_key", "device_key"): + if slot.get(field): + slot[field] = _encode_api_key(str(slot[field])) connectors = config.get("connectors") - if not isinstance(connectors, dict): - return - for name in ("github", "gitlab"): - slot = connectors.get(name) - if isinstance(slot, dict) and slot.get("token"): - slot["token"] = _encode_api_key(str(slot["token"])) - for key, field in (("mcp", "token"), ("agents", "api_key")): - items = connectors.get(key) - if not isinstance(items, list): - continue - for item in items: - if isinstance(item, dict) and item.get(field): - item[field] = _encode_api_key(str(item[field])) + if isinstance(connectors, dict): + for name in ("github", "gitlab"): + slot = connectors.get(name) + if isinstance(slot, dict) and slot.get("token"): + slot["token"] = _encode_api_key(str(slot["token"])) + for key, field in (("mcp", "token"), ("agents", "api_key")): + items = connectors.get(key) + if not isinstance(items, list): + continue + for item in items: + if isinstance(item, dict) and item.get(field): + item[field] = _encode_api_key(str(item[field])) + services = connectors.get("services") + if isinstance(services, list): + for item in services: + if not isinstance(item, dict): + continue + for field in ("password", "api_key", "bearer_token", "client_secret", "access_token", "refresh_token"): + if item.get(field): + item[field] = _encode_api_key(str(item[field])) + alpaca = (config.get("finance") or {}).get("alpaca") if isinstance(config.get("finance"), dict) else None + if isinstance(alpaca, dict): + for field in ("api_key", "secret_key"): + if alpaca.get(field): + alpaca[field] = _encode_api_key(str(alpaca[field])) def _decode_connector_secrets(config: dict[str, Any]) -> None: + local_credentials = config.get("local_runtime_credentials") + if isinstance(local_credentials, dict): + for slot in local_credentials.values(): + if not isinstance(slot, dict): + continue + for field in ("api_key", "device_key"): + if slot.get(field): + slot[field] = _decode_api_key(str(slot[field])) connectors = config.get("connectors") - if not isinstance(connectors, dict): - return - for name in ("github", "gitlab"): - slot = connectors.get(name) - if isinstance(slot, dict) and slot.get("token"): - slot["token"] = _decode_api_key(str(slot["token"])) - for key, field in (("mcp", "token"), ("agents", "api_key")): - items = connectors.get(key) - if not isinstance(items, list): - continue - for item in items: - if isinstance(item, dict) and item.get(field): - item[field] = _decode_api_key(str(item[field])) + if isinstance(connectors, dict): + for name in ("github", "gitlab"): + slot = connectors.get(name) + if isinstance(slot, dict) and slot.get("token"): + slot["token"] = _decode_api_key(str(slot["token"])) + for key, field in (("mcp", "token"), ("agents", "api_key")): + items = connectors.get(key) + if not isinstance(items, list): + continue + for item in items: + if isinstance(item, dict) and item.get(field): + item[field] = _decode_api_key(str(item[field])) + services = connectors.get("services") + if isinstance(services, list): + for item in services: + if not isinstance(item, dict): + continue + for field in ("password", "api_key", "bearer_token", "client_secret", "access_token", "refresh_token"): + if item.get(field): + item[field] = _decode_api_key(str(item[field])) + alpaca = (config.get("finance") or {}).get("alpaca") if isinstance(config.get("finance"), dict) else None + if isinstance(alpaca, dict): + for field in ("api_key", "secret_key"): + if alpaca.get(field): + alpaca[field] = _decode_api_key(str(alpaca[field])) + + +def get_finance_config() -> dict[str, Any]: + config = load_config() + finance = config.get("finance") + return dict(finance) if isinstance(finance, dict) else {} + + +def set_alpaca_config(api_key: str, secret_key: str, *, paper: bool = True) -> dict[str, Any]: + config = load_config() + finance = config.get("finance") if isinstance(config.get("finance"), dict) else {} + finance["alpaca"] = {"api_key": api_key.strip(), "secret_key": secret_key.strip(), "paper": bool(paper)} + config["finance"] = finance + save_config(config) + return {"configured": bool(api_key.strip() and secret_key.strip()), "paper": bool(paper)} + + +def set_local_runtime_credential(base_url: str, api_key: str, *, device_key: str = "") -> None: + """Store endpoint-scoped local-runtime credentials outside public config.""" + config = load_config() + credentials = config.get("local_runtime_credentials") + if not isinstance(credentials, dict): + credentials = {} + credentials[str(base_url).rstrip("/")] = { + "api_key": str(api_key or "").strip(), + "device_key": str(device_key or "").strip(), + } + config["local_runtime_credentials"] = credentials + save_config(config) + + +def get_local_runtime_credential(base_url: str) -> dict[str, str]: + config = load_config() + credentials = config.get("local_runtime_credentials") + if not isinstance(credentials, dict): + return {"api_key": "", "device_key": ""} + slot = credentials.get(str(base_url).rstrip("/")) + if not isinstance(slot, dict): + return {"api_key": "", "device_key": ""} + return {"api_key": str(slot.get("api_key") or ""), "device_key": str(slot.get("device_key") or "")} def _public_connectors_safe() -> dict[str, Any]: @@ -311,16 +391,24 @@ def get_desktop_settings() -> dict[str, Any]: desktop = {} return { "workspace": desktop.get("workspace", ""), + "projects_dir": desktop.get("projects_dir", ""), "notify_on_complete": bool(desktop.get("notify_on_complete", True)), "current_session_id": desktop.get("current_session_id") or "", + "hidden_session_ids": list(desktop.get("hidden_session_ids") or []), + "full_device_access": bool(desktop.get("full_device_access", False)), + "full_device_access_approved_at": str(desktop.get("full_device_access_approved_at") or ""), } def update_desktop_settings( *, workspace: Optional[str] = None, + projects_dir: Optional[str] = None, notify_on_complete: Optional[bool] = None, current_session_id: Optional[str] = None, + hidden_session_ids: Optional[list[str]] = None, + full_device_access: Optional[bool] = None, + full_device_access_approved_at: Optional[str] = None, ) -> dict[str, Any]: config = load_config() desktop = config.get("desktop") @@ -328,10 +416,18 @@ def update_desktop_settings( desktop = {} if workspace is not None: desktop["workspace"] = workspace + if projects_dir is not None: + desktop["projects_dir"] = projects_dir if notify_on_complete is not None: desktop["notify_on_complete"] = bool(notify_on_complete) if current_session_id is not None: desktop["current_session_id"] = current_session_id + if hidden_session_ids is not None: + desktop["hidden_session_ids"] = list(dict.fromkeys(hidden_session_ids)) + if full_device_access is not None: + desktop["full_device_access"] = bool(full_device_access) + if full_device_access_approved_at is not None: + desktop["full_device_access_approved_at"] = str(full_device_access_approved_at) config["desktop"] = desktop save_config(config) return get_desktop_settings() diff --git a/src/connectors/external.py b/src/connectors/external.py new file mode 100644 index 0000000..4450d2a --- /dev/null +++ b/src/connectors/external.py @@ -0,0 +1,121 @@ +"""Generic external-service connectors with explicit credential and OAuth flows.""" + +from __future__ import annotations + +import base64 +import json +import secrets +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +from .store import read_connectors, save_external_service + + +def _service(service_id: str) -> dict[str, Any]: + for item in read_connectors().get("services") or []: + if item.get("id") == service_id or item.get("name") == service_id: + return dict(item) + raise ValueError(f"unknown external service: {service_id}") + + +def _http_url(value: str, label: str) -> str: + url = str(value or "").strip() + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password: + raise ValueError(f"{label} must be an http(s) URL without embedded credentials") + return url + + +def authorization_request(service_id: str, *, redirect_uri: str = "urn:ietf:wg:oauth:2.0:oob") -> dict[str, str]: + item = _service(service_id) + if item.get("auth_type") != "oauth2": + raise ValueError("service is not configured for OAuth 2.0") + authorization_url = _http_url(str(item.get("authorization_url") or ""), "authorization URL") + client_id = str(item.get("client_id") or "").strip() + if not client_id: + raise ValueError("OAuth client ID is required") + state = secrets.token_urlsafe(24) + query = urllib.parse.urlencode({ + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": str(item.get("scopes") or ""), + "state": state, + }) + return {"authorization_url": authorization_url + ("&" if "?" in authorization_url else "?") + query, + "state": state, "redirect_uri": redirect_uri} + + +def exchange_authorization_code( + service_id: str, + code: str, + *, + redirect_uri: str = "urn:ietf:wg:oauth:2.0:oob", +) -> dict[str, Any]: + item = _service(service_id) + token_url = _http_url(str(item.get("token_url") or ""), "token URL") + if not str(code or "").strip(): + raise ValueError("authorization code is required") + form = { + "grant_type": "authorization_code", + "code": code.strip(), + "redirect_uri": redirect_uri, + "client_id": str(item.get("client_id") or ""), + "client_secret": str(item.get("client_secret") or ""), + } + request = urllib.request.Request( + token_url, + data=urllib.parse.urlencode(form).encode("utf-8"), + headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:1000] + raise ValueError(f"OAuth token exchange failed ({exc.code}): {detail}") from exc + access_token = str(payload.get("access_token") or "") + if not access_token: + raise ValueError("OAuth provider returned no access token") + save_external_service( + name=str(item.get("name") or "External service"), + base_url=str(item.get("base_url") or ""), + auth_type="oauth2", + service_id=str(item.get("id") or service_id), + **{key: item.get(key) or "" for key in ( + "username", "password", "api_key", "api_key_header", "bearer_token", + "authorization_url", "token_url", "client_id", "client_secret", "scopes", + )}, + access_token=access_token, + refresh_token=str(payload.get("refresh_token") or item.get("refresh_token") or ""), + ) + return {"connected": True, "token_type": payload.get("token_type") or "Bearer", + "scope": payload.get("scope") or item.get("scopes") or ""} + + +def test_service(service_id: str) -> dict[str, Any]: + item = _service(service_id) + url = _http_url(str(item.get("base_url") or ""), "service URL") + headers = {"Accept": "application/json, text/plain, */*", "User-Agent": "JonathanAi/0.3"} + auth_type = str(item.get("auth_type") or "none") + if auth_type in {"basic", "password"}: + token = base64.b64encode(f"{item.get('username') or ''}:{item.get('password') or ''}".encode()).decode() + headers["Authorization"] = f"Basic {token}" + elif auth_type == "api_key": + headers[str(item.get("api_key_header") or "X-API-Key")] = str(item.get("api_key") or "") + elif auth_type == "bearer": + headers["Authorization"] = f"Bearer {item.get('bearer_token') or ''}" + elif auth_type == "oauth2": + headers["Authorization"] = f"Bearer {item.get('access_token') or ''}" + request = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(request, timeout=20) as response: + body = response.read(2048).decode("utf-8", errors="replace") + return {"reachable": True, "status": response.status, "content_type": response.headers.get("Content-Type", ""), "preview": body} + except urllib.error.HTTPError as exc: + body = exc.read(2048).decode("utf-8", errors="replace") + return {"reachable": False, "status": exc.code, "preview": body} + diff --git a/src/connectors/store.py b/src/connectors/store.py index 40bb2a8..2b89296 100644 --- a/src/connectors/store.py +++ b/src/connectors/store.py @@ -12,6 +12,7 @@ SECRET_FORGE_FIELDS = ("token",) SECRET_ITEM_FIELDS = ("token", "api_key") +SECRET_SERVICE_FIELDS = ("password", "api_key", "bearer_token", "client_secret", "access_token", "refresh_token") def _empty_connectors() -> dict[str, Any]: @@ -20,6 +21,7 @@ def _empty_connectors() -> dict[str, Any]: "gitlab": {"token": "", "owner": "", "host": DEFAULT_GITLAB_HOST, "login": "", "auth": ""}, "mcp": [], "agents": [], + "services": [], } @@ -33,7 +35,7 @@ def read_connectors() -> dict[str, Any]: item = raw.get(name) if isinstance(item, dict): base[name].update({k: item.get(k, base[name].get(k, "")) for k in base[name]}) - for key in ("mcp", "agents"): + for key in ("mcp", "agents", "services"): items = raw.get(key) if isinstance(items, list): base[key] = [item for item in items if isinstance(item, dict)] @@ -102,6 +104,31 @@ def public_connectors(connectors: dict[str, Any] | None = None) -> dict[str, Any for item in data.get("agents") or [] if isinstance(item, dict) ], + "services": [ + { + "id": item.get("id"), + "name": item.get("name") or "External service", + "base_url": item.get("base_url") or "", + "auth_type": item.get("auth_type") or "none", + "configured": bool( + item.get("auth_type") == "none" + or item.get("password") + or item.get("api_key") + or item.get("bearer_token") + or item.get("access_token") + ), + "username": item.get("username") or "", + "api_key_header": item.get("api_key_header") or "X-API-Key", + "authorization_url": item.get("authorization_url") or "", + "token_url": item.get("token_url") or "", + "client_id": item.get("client_id") or "", + "scopes": item.get("scopes") or "", + "has_password": bool(item.get("password")), + "has_secret": any(bool(item.get(field)) for field in SECRET_SERVICE_FIELDS), + } + for item in data.get("services") or [] + if isinstance(item, dict) + ], "hooks": { "cursor": "Drop ~/.clawd/hooks/cursor.json or add an OpenAI-compatible agent URL.", "codex": "Same JSON hook, or POST http://127.0.0.1:8765/api/hooks/inbound with the desktop token.", @@ -229,3 +256,55 @@ def save_agent( else: agents.append(item) return write_connectors(data) + + +def save_external_service( + *, + name: str, + base_url: str, + auth_type: str = "none", + service_id: str | None = None, + **fields: Any, +) -> dict[str, Any]: + if not name.strip(): + raise ValueError("service name is required") + if not base_url.strip(): + raise ValueError("service base URL is required") + allowed_auth = {"none", "basic", "password", "api_key", "bearer", "oauth2"} + auth = auth_type.strip().lower() or "none" + if auth not in allowed_auth: + raise ValueError(f"unsupported authentication type: {auth_type}") + data = read_connectors() + item: dict[str, Any] = { + "id": service_id or uuid.uuid4().hex[:12], + "name": name.strip(), + "base_url": base_url.strip().rstrip("/"), + "auth_type": auth, + } + for field in ( + "username", "password", "api_key", "api_key_header", "bearer_token", + "authorization_url", "token_url", "client_id", "client_secret", "scopes", + "access_token", "refresh_token", + ): + item[field] = str(fields.get(field) or "").strip() + services = data["services"] + for index, existing in enumerate(services): + if existing.get("id") == item["id"] or existing.get("name") == item["name"]: + item["id"] = existing.get("id") or item["id"] + for secret in SECRET_SERVICE_FIELDS: + if not item.get(secret): + item[secret] = existing.get(secret) or "" + services[index] = item + break + else: + services.append(item) + return write_connectors(data) + + +def remove_external_service(service_id: str) -> dict[str, Any]: + data = read_connectors() + before = len(data["services"]) + data["services"] = [item for item in data["services"] if item.get("id") != service_id and item.get("name") != service_id] + if len(data["services"]) == before: + raise ValueError(f"unknown external service: {service_id}") + return write_connectors(data) diff --git a/src/context_system/builder.py b/src/context_system/builder.py index 4146b6a..8bf8369 100644 --- a/src/context_system/builder.py +++ b/src/context_system/builder.py @@ -3,7 +3,7 @@ from datetime import date from pathlib import Path -from .claude_md import load_claude_md_context +from .claude_md import load_claude_md_context, load_design_md_context from .git_context import collect_git_context from .workspace_snapshot import build_workspace_snapshot @@ -19,6 +19,7 @@ def build_context_prompt( workspace = build_workspace_snapshot(root, cwd=current) git = collect_git_context(root) claude_md = load_claude_md_context(root, cwd=current) + design_md = load_design_md_context(root) sections: list[str] = [] @@ -32,6 +33,10 @@ def build_context_prompt( if md_lines: sections.append("\n".join(md_lines)) + design_lines = _render_design_md_section(design_md, root) + if design_lines: + sections.append("\n".join(design_lines)) + return "\n\n".join(section for section in sections if section.strip()) @@ -90,3 +95,19 @@ def _render_claude_md_section(claude_md, workspace_root: Path) -> list[str]: if claude_md.truncated: lines.append("- Additional instruction files were truncated to stay within prompt budget.") return lines + + +def _render_design_md_section(design_md, workspace_root: Path) -> list[str]: + if not design_md.files: + return [] + item = design_md.files[0] + lines = [ + "## Project Design System", + "### ./DESIGN.md", + "```md", + item.content, + "```", + ] + if design_md.truncated: + lines.append("- DESIGN.md was truncated to stay within the prompt budget.") + return lines diff --git a/src/context_system/claude_md.py b/src/context_system/claude_md.py index 163c9bb..1b6e354 100644 --- a/src/context_system/claude_md.py +++ b/src/context_system/claude_md.py @@ -8,6 +8,28 @@ _USER_CANDIDATES = (".clawd/CLAUDE.md", ".claude/CLAUDE.md") +def load_design_md_context( + workspace_root: str | Path, + *, + max_chars: int = 8_000, +) -> ClaudeMdContext: + """Load a project-owned DESIGN.md as bounded, UTF-8 visual context.""" + root = Path(workspace_root).expanduser().resolve() + path = root / "DESIGN.md" + if not path.is_file(): + return ClaudeMdContext(files=(), truncated=False) + try: + content = path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError): + return ClaudeMdContext(files=(), truncated=False) + if not content: + return ClaudeMdContext(files=(), truncated=False) + truncated = len(content) > max_chars + if truncated: + content = content[: max(0, max_chars - 32)].rstrip() + "\n...[truncated]" + return ClaudeMdContext(files=(ClaudeMdFile(path=path, content=content),), truncated=truncated) + + def load_claude_md_context( workspace_root: str | Path, *, diff --git a/src/context_system/workspace_snapshot.py b/src/context_system/workspace_snapshot.py index d4e6dd8..e4bd0b8 100644 --- a/src/context_system/workspace_snapshot.py +++ b/src/context_system/workspace_snapshot.py @@ -16,6 +16,7 @@ _KEY_FILE_CANDIDATES = ( "README.md", "CLAUDE.md", + "DESIGN.md", "pyproject.toml", "requirements.txt", "uv.lock", diff --git a/src/desktop/attachments.py b/src/desktop/attachments.py index 07f1cec..b20663c 100644 --- a/src/desktop/attachments.py +++ b/src/desktop/attachments.py @@ -7,11 +7,19 @@ from __future__ import annotations -from dataclasses import dataclass +import base64 +from dataclasses import dataclass, field, replace +import io +import mimetypes from pathlib import Path from typing import Any +from src.file_content import extract_rich_text + MAX_TEXT_BYTES = 256_000 +MAX_IMAGE_BYTES = 5 * 1024 * 1024 +MAX_INLINE_UPLOAD_BYTES = 64 * 1024 * 1024 +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"} TEXT_SUFFIXES = { ".py", ".md", ".txt", ".json", ".toml", ".yml", ".yaml", ".js", ".ts", ".tsx", ".jsx", ".css", ".html", ".sh", ".rs", ".go", ".java", ".rb", @@ -29,6 +37,9 @@ class Attachment: size: int = 0 omitted: bool = False reason: str = "" + media_type: str = "" + is_image: bool = False + data: bytes = field(default=b"", repr=False, compare=False) def to_dict(self) -> dict[str, Any]: return { @@ -39,6 +50,8 @@ def to_dict(self) -> dict[str, Any]: "size": self.size, "omitted": self.omitted, "reason": self.reason, + "media_type": self.media_type, + "is_image": self.is_image, } @@ -69,6 +82,28 @@ def attach_path(path: str | Path, *, kind: str = "file") -> Attachment: reason="file not found", ) size = resolved.stat().st_size + if resolved.suffix.lower() in IMAGE_SUFFIXES: + media_type = mimetypes.guess_type(resolved.name)[0] or "image/png" + return Attachment( + name=resolved.name, + kind="screenshot" if kind == "screenshot" else "image", + path=str(resolved), + size=size, + media_type=media_type, + is_image=True, + ) + supported, extracted, note = extract_rich_text(resolved, max_chars=MAX_TEXT_BYTES) + if supported: + return Attachment( + name=resolved.name, + kind=kind, + path=str(resolved), + text=extracted, + size=size, + omitted=not bool(extracted.strip()), + reason=note, + media_type=mimetypes.guess_type(resolved.name)[0] or "application/octet-stream", + ) if not _looks_text(resolved): return Attachment( name=resolved.name, @@ -124,6 +159,9 @@ def render_attachments(attachments: list[Attachment]) -> str: header = f"[{item.kind} attachment: {item.name}]" if item.path: header += f" path={item.path}" + if item.is_image: + parts.append(f"{header}\n(Image pixels are attached to this message. Use this exact path with ImageStudio when editing.)") + continue if item.omitted and not item.text: parts.append(f"{header}\n({item.reason})") continue @@ -141,10 +179,72 @@ def attachments_from_payload(payload: list[dict[str, Any]] | None) -> list[Attac if not isinstance(raw, dict): continue kind = str(raw.get("kind") or "file") + encoded = raw.get("data_base64") + if isinstance(encoded, str) and encoded.strip(): + name = Path(str(raw.get("name") or "upload.bin")).name or "upload.bin" + try: + data = base64.b64decode(encoded, validate=True) + except (ValueError, TypeError) as exc: + items.append(Attachment(name=name, kind=kind, omitted=True, reason=f"invalid uploaded data: {exc}")) + continue + if len(data) > MAX_INLINE_UPLOAD_BYTES: + items.append(Attachment(name=name, kind=kind, size=len(data), omitted=True, + reason=f"inline upload exceeds {MAX_INLINE_UPLOAD_BYTES} bytes")) + continue + media_type = str(raw.get("media_type") or mimetypes.guess_type(name)[0] or "application/octet-stream") + items.append(Attachment( + name=name, + kind="image" if media_type.startswith("image/") or Path(name).suffix.lower() in IMAGE_SUFFIXES else kind, + size=len(data), + media_type=media_type, + is_image=media_type.startswith("image/") or Path(name).suffix.lower() in IMAGE_SUFFIXES, + data=data, + )) + continue if kind == "clipboard" or (not raw.get("path") and raw.get("text")): items.append(attach_clipboard_text(str(raw.get("text") or ""), name=str(raw.get("name") or "Clipboard"))) continue path = raw.get("path") if isinstance(path, str) and path.strip(): - items.append(attach_path(path, kind=kind if kind in {"file", "screenshot"} else "file")) + attached = attach_path(path, kind=kind if kind in {"file", "screenshot"} else "file") + display_name = str(raw.get("name") or "").strip() + if display_name: + attached = replace(attached, name=Path(display_name).name) + items.append(attached) return items + + +def image_source(item: Attachment) -> dict[str, str]: + """Return an Anthropic-compatible base64 image source, normalizing large/unsupported files.""" + if not item.is_image or not item.path: + raise ValueError("attachment is not an image") + path = Path(item.path) + raw = path.read_bytes() + media_type = item.media_type or mimetypes.guess_type(path.name)[0] or "image/png" + if media_type in {"image/png", "image/jpeg", "image/webp", "image/gif"} and len(raw) <= MAX_IMAGE_BYTES: + return {"type": "base64", "media_type": media_type, "data": base64.b64encode(raw).decode("ascii")} + + try: + from PIL import Image, ImageOps + except ImportError as exc: # pragma: no cover - installed by bootstrap + raise RuntimeError("Pillow is required to attach this image") from exc + with Image.open(path) as opened: + image = ImageOps.exif_transpose(opened) + image.thumbnail((4096, 4096), Image.Resampling.LANCZOS) + if image.mode not in {"RGB", "L"}: + background = Image.new("RGB", image.size, "white") + if "A" in image.getbands(): + background.paste(image, mask=image.getchannel("A")) + else: + background.paste(image.convert("RGB")) + image = background + quality = 92 + while True: + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=quality, optimize=True) + raw = buffer.getvalue() + if len(raw) <= MAX_IMAGE_BYTES or max(image.size) <= 512: + break + image.thumbnail((max(512, int(image.width * 0.8)), max(512, int(image.height * 0.8))), Image.Resampling.LANCZOS) + quality = max(65, quality - 7) + return {"type": "base64", "media_type": "image/jpeg", "data": base64.b64encode(raw).decode("ascii")} diff --git a/src/desktop/runtime.py b/src/desktop/runtime.py index 93a4833..a52a314 100644 --- a/src/desktop/runtime.py +++ b/src/desktop/runtime.py @@ -2,13 +2,20 @@ from __future__ import annotations +import os +import mimetypes +import re +import shutil import threading import uuid -from dataclasses import dataclass, field +import zipfile +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable from src.agent import Session +from src.agent.conversation import AttachmentContentBlock, ImageContentBlock, TextContentBlock from src.agent.session import empty_token_usage from src.command_system import ( CommandRegistry, @@ -17,9 +24,16 @@ register_builtin_commands, ) from src.agent.memory import remember_turn +from src.agent.checkpoints import create_checkpoint, list_checkpoints, prepare_retry, restore_checkpoint, undo_last_turn +from src.agent.history_search import search_history +from src.agent.event_log import append_runtime_event, read_runtime_events +from src.agent.autonomy import autonomy_status, record_permission_evidence +from src.agent.roster import get_agent as get_roster_agent, list_agents as list_roster_agents, read_inbox, remove_agent as remove_roster_agent, save_agent as save_roster_agent, send_message as send_roster_message from src.config import ( get_default_provider, get_desktop_settings, + get_finance_config, + get_local_runtime_credential, get_provider_config, has_configured_provider, is_provider_ready, @@ -27,6 +41,7 @@ public_config, set_api_key, set_default_provider, + set_alpaca_config, update_desktop_settings, ) from src.providers.huggingface_connect import ( @@ -36,6 +51,7 @@ ) from src.providers.local_endpoints import ( assert_local_or_lan_url, + discover_local_environment, list_local_models, normalize_openai_base, scan_local_endpoints, @@ -43,12 +59,31 @@ from src.cost_tracker import CostTracker from src.history import HistoryLog from src.providers import PROVIDER_INFO, get_provider_class +from src.providers.resilient import ResilientProvider, resilient from src.skills.loader import get_all_skills +from src.skills.frontmatter import parse_frontmatter +from src.skills.learning import analyze_git_history, learning_status, record_observation +from src.skills.library import ( + archive_user_skill, + ensure_learning_skill, + open_skill_library, + render_skill, + save_user_skill, + user_skill_library, + validate_skill_source, +) from src.tool_system.agent_loop import ToolEvent, run_agent_loop, summarize_tool_result, summarize_tool_use +from src.tool_system.audit import audit_path, read_actions, record_action from src.tool_system.context import ToolContext +from src.tool_system.permissions import ToolPermissionContext from src.tool_system.defaults import build_default_registry from src.tool_system.protocol import ToolCall +from src.tool_system.tools.terminal import discover_terminals +from src.tool_system.tools.three_d_studio import media_capabilities +from src.tool_system.tools.image_studio import ImageStudioTool +from src.tool_system.tools.system_admin import system_admin_capabilities from src.connectors.agents import get_saved_agent, invoke_agent, list_agent_tools, test_agent_endpoint +from src.connectors.external import authorization_request, exchange_authorization_code, test_service from src.connectors.git_common import workspace_git_status from src.connectors.github import GitHubConnector, poll_github_device_login, start_github_device_login from src.connectors.gitlab import GitLabConnector, poll_gitlab_device_login, start_gitlab_device_login @@ -60,16 +95,37 @@ public_connectors, read_connectors, save_agent, + save_external_service, save_mcp_server, + remove_external_service, set_agent_enabled, set_mcp_enabled, ) from src.install.record import read_install_record, resolve_source_dir from src.install.source import default_source_dir +from src.integrations.fooocus import FooocusManager +from src.integrations.kronos import KronosManager +from src.integrations.securo import SecuroManager +from src.integrations.claude_db import ClaudeDbManager +from src.integrations.procoder import ProcoderManager +from src.integrations.drawai import DrawAiManager +from src.integrations.character_studio import generate_character +from src.integrations.ecc import ( + catalog as ecc_catalog, + import_catalog as ecc_import_catalog, + memory_vault_export, + memory_vault_import, + security_scan as ecc_security_scan, + set_enabled as ecc_set_enabled, + status as ecc_status, + sync as ecc_sync, + ecc_cache_dir, +) from src.update import Updater from src.version import get_version +from src.doctor import run_doctor -from .attachments import Attachment, attachments_from_payload, render_attachments +from .attachments import Attachment, attach_path, attachments_from_payload, image_source, render_attachments def provider_limit_hint(exc: Exception | str) -> str: @@ -96,6 +152,75 @@ def provider_limit_hint(exc: Exception | str) -> str: ) +_LOCAL_ACTION_RE = re.compile( + r"(?ix)(?:" + r"\b(?:fix|debug|build|install|uninstall|run|execute|test|deploy|package|download|upload|" + r"delete|remove|rename|move|copy|clone|push|pull|commit|monitor|scan|connect|configure|" + r"browse|search|fetch|render|convert|trade|schedule|automate)\b|" + r"\b(?:file|folder|directory|workspace|project|repo|repository|github|gitlab|terminal|shell|" + r"powershell|bash|command|cli|script|source\s+code|database|server|process|service|system|" + r"image|photo|picture|fooocus|blender|3d|stl|obj|gltf|audio|voice|speech|transcribe|whisper|spreadsheet|workbook|pdf|document|" + r"connector|oauth|api|website|webpage|portfolio|market|stock|crypto|skill|agent|tool|cron|" + r"device|application|app|browser|chrome|edge|firefox|keyboard|mouse|click|screenshot|network)\b|" + r"https?://|```|(?:[A-Za-z]:\\)|(?:\./|\.\\)|\.(?:py|js|ts|tsx|jsx|json|md|txt|csv|xlsx|" + r"docx|pdf|png|jpe?g|webp|stl|obj|gltf|blend)\b)" +) + + +def local_chat_route(text: str, attachments: list[Attachment] | None = None) -> tuple[bool, set[str]]: + """Return (lightweight, visible tools) for an OpenAI-compatible local model. + + Ordinary conversation skips tool schemas entirely. Actionable requests get + a focused subset plus ToolSearch, which can expand the set on later turns. + """ + raw = str(text or "") + lower = raw.lower() + attached = list(attachments or []) + if not attached and not _LOCAL_ACTION_RE.search(raw): + return True, set() + + selected = {"ToolSearch", "AskUserQuestion"} + + def has(*terms: str) -> bool: + return any(term in lower for term in terms) + + if has("file", "folder", "directory", "workspace", "project", "code", "script", "repo", "github", "gitlab") or re.search(r"\.(?:py|js|ts|tsx|jsx|json|md|txt)\b", lower): + selected.update({"Read", "Write", "Edit", "Glob", "Grep", "Bash", "Terminal", "PowerShell", "LSP", "Repository", "Artifact"}) + if has("terminal", "shell", "powershell", "bash", "command", "cli", "process", "service", "system", "install", "monitor", "scan"): + selected.update({"Bash", "Terminal", "PowerShell", "SystemAdmin"}) + if has("device", "application", " app", "browser", "chrome", "edge", "firefox", "keyboard", "mouse", "click", "type text", "hotkey", "screenshot", "open file", "open url"): + selected.update({"DeviceControl", "Terminal", "SystemAdmin"}) + if has("browse", "search", "fetch", "website", "webpage", "http://", "https://"): + selected.update({"WebSearch", "WebFetch", "ContentReach"}) + if has("youtube", "caption", "subtitle", "rss", "atom", "feed"): + selected.update({"ContentReach", "WebFetch", "Artifact"}) + if has("network", "lan", "wifi", "ethernet", "port", "firewall", "ip address"): + selected.update({"WebSearch", "WebFetch", "Terminal", "PowerShell", "SystemAdmin", "DeviceControl"}) + if attached or has("image", "photo", "picture", "fooocus", "png", "jpg", "jpeg", "webp"): + selected.update({"Read", "VisionAnalyze", "ImageStudio", "LocalImage", "Artifact"}) + if any(str(item.media_type).startswith("audio/") for item in attached) or has("audio", "voice", "speech", "transcribe", "whisper", "wav", "mp3", "webm"): + selected.update({"Read", "SpeechStudio", "AudioStudio", "Artifact"}) + if has("blender", "3d", "stl", "obj", "gltf", "blend"): + selected.update({"ThreeDStudio", "Artifact", "Terminal"}) + if has("spreadsheet", "workbook", "csv", "xlsx", "pdf", "document", "docx", "notebook"): + selected.update({"Read", "Write", "Artifact", "NotebookEdit"}) + if has("trade", "portfolio", "market", "stock", "crypto", "business", "finance"): + selected.update({"BusinessManager", "FinanceMarkets", "Trading", "KronosForecast", "PersonalFinanceVault"}) + if has("connector", "oauth", "external", "mcp", "api"): + selected.update({"MCP", "ExternalAgent", "ListMcpResources", "ReadMcpResource"}) + if has("skill"): + selected.update({"Skill", "SkillManager"}) + if has("agent", "worker", "task", "team"): + selected.update({"SpawnWorkers", "AgentInstances", "TaskCreate", "TaskGet", "TaskList", "TaskUpdate", "TaskOutput", "TeamCreate", "TeamDelete"}) + if has("remember", "memory"): + selected.add("SharedMemory") + if has("schedule", "cron", "automate"): + selected.update({"CronCreate", "CronList", "CronDelete"}) + if has("package", "download", "artifact", "zip", "publish"): + selected.add("Artifact") + return False, selected + + EventCallback = Callable[[dict[str, Any]], None] @@ -105,6 +230,7 @@ class PermissionRequest: tool_name: str message: str suggestion: str | None = None + session_id: str = "" event: threading.Event = field(default_factory=threading.Event) allowed: bool = False always: bool = False @@ -114,12 +240,29 @@ class PermissionRequest: @dataclass class ChatJob: job_id: str + session_id: str = "" + mode: str = "chat" + started_at: str = field(default_factory=lambda: datetime.now().isoformat()) events: list[dict[str, Any]] = field(default_factory=list) condition: threading.Condition = field(default_factory=threading.Condition) done: bool = False cancelled: bool = False +@dataclass +class AgentInstance: + """All mutable agent state owned by one conversation.""" + + session: Session + workspace: Path + tool_context: ToolContext + command_context: Any + cost_tracker: CostTracker + history_log: HistoryLog + run_lock: threading.Lock = field(default_factory=threading.Lock) + active_jobs: set[str] = field(default_factory=set) + + class DesktopRuntime: """In-process desktop controller around the existing agent brain.""" @@ -132,12 +275,43 @@ def __init__( permission_timeout_s: float = 300.0, ) -> None: settings = get_desktop_settings() + self.full_device_access = bool(settings.get("full_device_access", False)) raw_workspace = workspace or settings.get("workspace") or Path.cwd() self.workspace = Path(raw_workspace).expanduser().resolve() self.workspace.mkdir(parents=True, exist_ok=True) + raw_projects = settings.get("projects_dir") or Path.home() / "Jonathan" / "Projects" + self.projects_dir = Path(raw_projects).expanduser().resolve() + ensure_learning_skill(source_dir=resolve_source_dir() or Path(__file__).resolve().parents[2]) self.stream = stream self.permission_timeout_s = permission_timeout_s self.provider_name = provider_name or get_default_provider() + self._device: dict[str, Any] = {} + # A local runtime is the zero-configuration fallback. Existing healthy + # cloud configuration remains the user's explicit preference. Once a + # provider is configured, move device-wide filesystem/port discovery + # off the startup path so the conversation window can open immediately. + provider_configured = has_configured_provider() + try: + if provider_configured: + self._device = { + "runtimes": [], "endpoints": [], "selected": None, + "scan_pending": True, "auto_started": False, + } + else: + self._device = discover_local_environment(auto_start=True) + selected = self._device.get("selected") + if not provider_configured and isinstance(selected, dict): + models = list(selected.get("models") or []) + if models: + local_key = get_local_runtime_credential(str(selected["base_url"])).get("api_key") or "local" + set_api_key( + "local", local_key, base_url=str(selected["base_url"]), + default_model=str(models[0]), + ) + set_default_provider("local") + self.provider_name = "local" + except Exception as exc: + self._device = {"runtimes": [], "endpoints": [], "selected": None, "error": str(exc)} self.provider = None self.session = Session.create(self.provider_name, "unconfigured", workspace=str(self.workspace)) self.tool_registry = build_default_registry() @@ -152,37 +326,118 @@ def __init__( cost_tracker=self.cost_tracker, history=self.history_log, ) + self._instances: dict[str, AgentInstance] = {} self._jobs: dict[str, ChatJob] = {} self._permissions: dict[str, PermissionRequest] = {} self._lock = threading.Lock() self.install_record = read_install_record() self.updater = Updater(resolve_source_dir() or Path.cwd()) - self._device: dict[str, Any] = {} + self.fooocus = FooocusManager(self.updater.source_dir) + self.kronos = KronosManager(self.updater.source_dir) + self.personal_finance = SecuroManager(self.updater.source_dir) + self.code_memory = ClaudeDbManager(self.updater.source_dir) + self.procoder = ProcoderManager(self.updater.source_dir) + self.drawai = DrawAiManager(self.updater.source_dir) + self._external_oauth: dict[str, dict[str, str]] = {} try: load_hook_files() except Exception: pass self._try_init_provider() self._restore_session() + if provider_configured: + self._device_thread = threading.Thread( + target=self._refresh_device_inventory, + daemon=True, + name="clawd-local-scan", + ) + self._device_thread.start() if self.install_record: self._update_thread = threading.Thread(target=self._auto_update_on_launch, daemon=True, name="clawd-update") self._update_thread.start() - def _make_context(self) -> ToolContext: - ctx = ToolContext(workspace_root=self.workspace) + def _refresh_device_inventory(self) -> None: + try: + self._device = discover_local_environment(auto_start=False) + except Exception as exc: + self._device = { + "runtimes": [], "endpoints": [], "selected": None, + "scan_pending": False, "error": str(exc), + } + + def _make_context(self, workspace: Path | None = None, session_id: str | None = None) -> ToolContext: + root = Path(workspace or self.workspace).resolve() + sid = session_id or (getattr(self, "session", None).session_id if getattr(self, "session", None) else "") + permission_context = ToolPermissionContext.from_iterables( + workspace_root=root, + allow_docs=self.full_device_access, + full_system_access=self.full_device_access, + ) + ctx = ToolContext(workspace_root=root, permission_context=permission_context) ctx.gate_destructive_tools = True - ctx.permission_handler = self._handle_permission_request + if self.full_device_access: + ctx.session_grants.add("*") + ctx.permission_handler = lambda tool, message, suggestion: self._handle_permission_request( + sid, ctx, tool, message, suggestion + ) ctx.ask_user = self._ask_user_questions ctx.mcp_clients = enabled_mcp_clients() - ctx.session_id = getattr(self, "session", None).session_id if getattr(self, "session", None) else None + ctx.session_id = sid or None + ctx.instance_reader = lambda action, target: self._read_instance(sid, action, target) + ctx.agent_roster = lambda action, payload: self._agent_roster_action(sid, action, payload) + ctx.artifact_publisher = lambda source, name=None: self._publish_session_artifact(sid, source, name) + ctx.audit_logger = record_action ctx.provider = getattr(self, "provider", None) return ctx + def _create_instance(self, session: Session, workspace: Path | None = None) -> AgentInstance: + root = Path(workspace or session.workspace or self.workspace).expanduser().resolve() + if not root.is_dir(): + root = self.workspace + cost_tracker = CostTracker() + history_log = HistoryLog() + context = self._make_context(root, session.session_id) + command_context = create_command_context( + workspace_root=root, + conversation=session.conversation, + cost_tracker=cost_tracker, + history=history_log, + ) + instance = AgentInstance( + session=session, + workspace=root, + tool_context=context, + command_context=command_context, + cost_tracker=cost_tracker, + history_log=history_log, + ) + self._instances[session.session_id] = instance + return instance + + def _agent_for_session(self, session_id: str | None = None) -> AgentInstance: + target_id = str(session_id or self.session.session_id or "").strip() + instance = self._instances.get(target_id) + if instance is not None: + return instance + loaded = Session.load(target_id) + if loaded is None: + raise ValueError(f"session not found: {target_id}") + return self._create_instance(loaded) + def _bind_session(self, session: Session) -> None: + if self.provider is not None: + session.provider = self.provider_name + session.model = self.provider.model + session.workspace = session.workspace or str(self.workspace) + instance = self._instances.get(session.session_id) or self._create_instance(session) + instance.session = session + instance.command_context.conversation = session.conversation self.session = session - self.session.workspace = self.session.workspace or str(self.workspace) - self.command_context.conversation = self.session.conversation - self.tool_context.session_id = self.session.session_id + self.workspace = instance.workspace + self.tool_context = instance.tool_context + self.command_context = instance.command_context + self.cost_tracker = instance.cost_tracker + self.history_log = instance.history_log try: update_desktop_settings(current_session_id=self.session.session_id) except Exception: @@ -219,7 +474,10 @@ def _restore_session(self) -> None: self._bind_session(self.session) def _refresh_mcp(self) -> None: - self.tool_context.mcp_clients = enabled_mcp_clients() + clients = enabled_mcp_clients() + self.tool_context.mcp_clients = clients + for instance in self._instances.values(): + instance.tool_context.mcp_clients = clients def _try_init_provider(self) -> None: try: @@ -236,10 +494,15 @@ def _try_init_provider(self) -> None: base_url=config.get("base_url"), model=config.get("default_model") or None, ) + self.provider = resilient(self.provider) if self.session.provider != self.provider_name or self.session.model == "unconfigured": self.session.provider = self.provider_name self.session.model = self.provider.model self.tool_context.provider = self.provider + for instance in self._instances.values(): + instance.tool_context.provider = self.provider + instance.session.provider = self.provider_name + instance.session.model = self.provider.model def needs_setup(self) -> bool: return not has_configured_provider() @@ -253,14 +516,32 @@ def status(self) -> dict[str, Any]: "product": "Jonathan Ai", "standalone": True, "workspace": str(self.workspace), + "projects_dir": str(self.projects_dir), "provider": self.provider_name, "model": getattr(self.provider, "model", None) or self.session.model, + "provider_health": self.provider.health() if isinstance(self.provider, ResilientProvider) else {"state": "unknown"}, "session": self.session.to_summary(), "config": cfg, "notify_on_complete": get_desktop_settings().get("notify_on_complete", True), "install": self.install_info(), "connectors": public_connectors(), "git": workspace_git_status(self.workspace), + "preview": self.preview_info(), + "artifacts": self.list_artifacts(), + "terminals": discover_terminals(), + "media": self.media_status(), + "fooocus": self.fooocus.status(), + "local_ai": self._device, + "administrator": system_admin_capabilities(), + "device_access": self.device_access_status(), + "earned_autonomy": autonomy_status(self.session.session_id), + "business_finance": self.business_finance_status(), + "kronos": self.kronos.status(), + "personal_finance": self.personal_finance.status(), + "code_memory": self.code_memory.status(), + "procoder": self.procoder.status(), + "drawai": self.drawai.status(), + "instances": self.instances_status(), "update": self.updater.last_check or { "source_dir": str(self.updater.source_dir), "local_sha": None, @@ -268,6 +549,68 @@ def status(self) -> dict[str, Any]: }, } + def device_access_status(self) -> dict[str, Any]: + settings = get_desktop_settings() + enabled = bool(settings.get("full_device_access", False)) + admin = system_admin_capabilities().get("administrator", False) + return { + "enabled": enabled, + "approved_at": settings.get("full_device_access_approved_at") or "", + "operating_system_scope": "administrator" if admin else "signed-in user", + "uac_required_for_elevation": bool(os.name == "nt" and not admin), + "capabilities": [ + "read and write files across mounted drives", + "run installed shells and command-line tools", + "launch installed applications and browsers", + "open local files and web URLs", + "use network tools and connected services", + "administer processes, services, packages, Git repositories, and system resources", + ], + } + + def configure_device_access(self, *, enabled: bool, confirmation: str = "") -> dict[str, Any]: + if enabled and str(confirmation or "").strip() != "ENABLE FULL ACCESS": + raise ValueError("Type ENABLE FULL ACCESS to approve persistent device and network access") + approved_at = datetime.now(timezone.utc).isoformat() if enabled else "" + update_desktop_settings( + full_device_access=enabled, + full_device_access_approved_at=approved_at, + ) + self.full_device_access = enabled + contexts: list[ToolContext] = [self.tool_context] + contexts.extend(instance.tool_context for instance in self._instances.values()) + seen: set[int] = set() + for context in contexts: + if id(context) in seen: + continue + seen.add(id(context)) + current = context.permission_context + context.permission_context = ToolPermissionContext.from_iterables( + current.deny_names, + current.deny_prefixes, + workspace_root=context.workspace_root, + additional_working_directories=current.additional_working_directories, + allow_docs=enabled or current.allow_docs, + full_system_access=enabled, + ) + if enabled: + context.session_grants.add("*") + else: + context.session_grants.discard("*") + record_action({ + "session_id": self.session.session_id, + "tool": "DeviceAccess", + "input": {"enabled": enabled}, + "permission": "explicit persistent user approval" if enabled else "user revoked", + "status": "enabled" if enabled else "revoked", + }) + return self.device_access_status() + + def device_inventory(self, *, limit: int = 2000) -> dict[str, Any]: + from src.device_control import discover_device_controls + + return discover_device_controls(limit=max(1, min(5000, int(limit)))) + def install_info(self) -> dict[str, Any]: record = self.install_record or read_install_record() or {} return { @@ -280,24 +623,119 @@ def install_info(self) -> dict[str, Any]: def update_status(self, *, refresh: bool = False) -> dict[str, Any]: return self.updater.status(refresh=refresh) + def audit_actions(self, limit: int = 100) -> dict[str, Any]: + return {"path": str(audit_path()), "actions": read_actions(limit)} + + def media_status(self) -> dict[str, Any]: + status = media_capabilities() + image = status.setdefault("image", {}) + try: + config = get_provider_config("openai") + image["ai_configured"] = bool(str(config.get("api_key") or "").strip()) + image["ai_endpoint"] = config.get("base_url") or "https://api.openai.com/v1" + image["ai_model"] = "gpt-image-1" + except Exception: + image["ai_configured"] = False + try: + image["fooocus"] = self.fooocus.status() + image["local_generation_ready"] = bool(image["fooocus"].get("running")) + except Exception as exc: + image["fooocus"] = {"running": False, "error": str(exc)} + return status + + def configure_image_provider(self, api_key: str, *, base_url: str = "", model: str = "") -> dict[str, Any]: + if not str(api_key or "").strip(): + raise ValueError("Image API key is required") + current = get_provider_config("openai") + set_api_key( + "openai", + str(api_key).strip(), + base_url=base_url.strip() or "https://api.openai.com/v1", + default_model=str(current.get("default_model") or "gpt-5.4"), + ) + return self.media_status() + + def run_image_action(self, payload: dict[str, Any], session_id: str | None = None) -> dict[str, Any]: + agent = self._agent_for_session(session_id) + tool_input = {key: value for key, value in payload.items() if key != "session_id" and value not in (None, "")} + record_action({"session_id": agent.session.session_id, "tool": "ImageStudio", "input": tool_input, + "permission": "direct user action", "status": "requested"}) + result = ImageStudioTool().run(tool_input, agent.tool_context) + if result.is_error: + raise ValueError(str(result.output)) + agent.session.save() + return result.output if isinstance(result.output, dict) else {"result": result.output} + + def fooocus_status(self) -> dict[str, Any]: + return self.fooocus.status() + + def fooocus_action(self, payload: dict[str, Any] | str) -> dict[str, Any]: + body = payload if isinstance(payload, dict) else {"action": payload} + agent = self._agent_for_session(str(body.get("session_id") or "") or None) + name = str(body.get("action") or "status").strip().lower() + if name == "status": + return self.fooocus.status() + if name in {"install", "repair"}: + return self.fooocus.install() + if name == "start": + return self.fooocus.start() + if name == "stop": + return self.fooocus.stop() + if name == "generate": + result = self.fooocus.generate( + str(body.get("prompt") or ""), + negative_prompt=str(body.get("negative_prompt") or ""), + width=int(body.get("width") or 1024), + height=int(body.get("height") or 1024), + performance=str(body.get("performance") or "Speed"), + seed=int(body["seed"]) if body.get("seed") is not None else None, + ) + artifacts = [self._publish_artifact(item["path"], item["name"]) for item in result["outputs"]] + for artifact in artifacts: + agent.session.record_artifact(artifact, caption="Generated with Jonathan local diffusion") + agent.session.save() + return {**result, "artifact": artifacts[0], "artifacts": artifacts} + if name == "outputs": + return {"outputs": self.fooocus.latest_outputs(), **self.fooocus.status()} + if name == "publish_latest": + latest = self.fooocus.latest_outputs(1) + if not latest: + raise ValueError("Jonathan's local image engine has not produced an image yet") + artifact = self._publish_artifact(latest[0]["path"], latest[0]["name"]) + agent.session.record_artifact(artifact, caption="Generated with Jonathan local diffusion") + agent.session.save() + return {"ok": True, "artifact": artifact, "artifacts": [artifact]} + raise ValueError(f"unsupported local image action: {name}") + + def business_finance_status(self) -> dict[str, Any]: + slot = get_finance_config().get("alpaca") or {} + try: + import importlib.util + market_data = importlib.util.find_spec("yfinance") is not None + except Exception: + market_data = False + return { + "business_database": str(Path.home() / ".clawd" / "business" / "business.db"), + "market_data": market_data, + "broker": {"provider": "alpaca", "configured": bool(slot.get("api_key") and slot.get("secret_key")), + "paper": bool(slot.get("paper", True)), "mode": "paper" if slot.get("paper", True) else "live"}, + } + + def configure_alpaca(self, api_key: str, secret_key: str, *, paper: bool = True) -> dict[str, Any]: + if not api_key.strip() or not secret_key.strip(): + raise ValueError("Alpaca API key and secret are required") + set_alpaca_config(api_key, secret_key, paper=paper) + return self.business_finance_status() + def apply_update(self) -> dict[str, Any]: result = self.updater.apply() self.install_record = read_install_record() return result def _auto_update_on_launch(self) -> None: - record = self.install_record or read_install_record() - if not record: - try: - self.updater.status(refresh=True) - except Exception: - return - return + """Check for updates without mutating the checkout behind the app.""" try: - status = self.updater.status(refresh=True) - if status.get("update_available") and not status.get("dirty") and not status.get("error"): - self.updater.apply() - self.install_record = read_install_record() + self.updater.status(refresh=True) except Exception: return @@ -375,11 +813,23 @@ def list_huggingface(self, token: str, search: str = "") -> dict[str, Any]: def cache_huggingface(self, repo_id: str, token: str) -> dict[str, Any]: return cache_huggingface_model(repo_id, token) - def scan_local(self, extra_url: str | None = None) -> dict[str, Any]: + def scan_local(self, extra_url: str | None = None, *, auto_start: bool = False) -> dict[str, Any]: extras = [extra_url] if extra_url else None if extra_url: assert_local_or_lan_url(extra_url) - return {"endpoints": scan_local_endpoints(extras)} + self._device = discover_local_environment(extras, auto_start=auto_start) + selected = self._device.get("selected") + if auto_start and isinstance(selected, dict) and selected.get("models"): + local_key = get_local_runtime_credential(str(selected["base_url"])).get("api_key") or "local" + set_api_key( + "local", local_key, base_url=str(selected["base_url"]), + default_model=str(selected["models"][0]), + ) + set_default_provider("local") + self.provider_name = "local" + self._try_init_provider() + self._device["connected"] = True + return self._device def list_local(self, base_url: str, api_key: str | None = None) -> dict[str, Any]: url = normalize_openai_base(base_url) @@ -388,6 +838,37 @@ def list_local(self, base_url: str, api_key: str | None = None) -> dict[str, Any def connectors_public(self) -> dict[str, Any]: return public_connectors() + def save_external_connector(self, payload: dict[str, Any]) -> dict[str, Any]: + return save_external_service( + name=str(payload.get("name") or ""), + base_url=str(payload.get("base_url") or ""), + auth_type=str(payload.get("auth_type") or "none"), + service_id=str(payload.get("id") or "") or None, + **{key: payload.get(key) or "" for key in ( + "username", "password", "api_key", "api_key_header", "bearer_token", + "authorization_url", "token_url", "client_id", "client_secret", "scopes", + )}, + ) + + def remove_external_connector(self, service_id: str) -> dict[str, Any]: + return remove_external_service(service_id) + + def test_external_connector(self, service_id: str) -> dict[str, Any]: + return test_service(service_id) + + def start_external_oauth(self, service_id: str) -> dict[str, Any]: + result = authorization_request(service_id) + self._external_oauth[service_id] = result + return result + + def finish_external_oauth(self, service_id: str, code: str, state: str = "") -> dict[str, Any]: + pending = self._external_oauth.get(service_id) or {} + if state and pending.get("state") and state != pending["state"]: + raise ValueError("OAuth state did not match the pending connection") + result = exchange_authorization_code(service_id, code, redirect_uri=pending.get("redirect_uri") or "urn:ietf:wg:oauth:2.0:oob") + self._external_oauth.pop(service_id, None) + return {**result, "connectors": public_connectors()} + def connect_github(self, token: str, owner: str | None = None) -> dict[str, Any]: result = GitHubConnector().login_with_token(token, owner=owner or DEFAULT_GITHUB_OWNER) return {"ok": True, **result, "connectors": public_connectors()} @@ -425,7 +906,7 @@ def gitlab_projects(self) -> dict[str, Any]: return {"projects": GitLabConnector().list_projects()} def clone_repo(self, forge: str, repo: str, dest: str | None = None) -> dict[str, Any]: - target = Path(dest).expanduser() if dest else Path.home() / "Jonathan" / Path(str(repo).rstrip("/").split("/")[-1].replace(".git", "")) + target = Path(dest).expanduser() if dest else self.projects_dir / Path(str(repo).rstrip("/").split("/")[-1].replace(".git", "")) if forge == "gitlab": result = GitLabConnector().clone(repo, target) else: @@ -542,6 +1023,9 @@ def set_workspace(self, path: str | Path) -> dict[str, Any]: workspace = Path(path).expanduser().resolve() if not workspace.exists() or not workspace.is_dir(): raise ValueError(f"workspace is not a directory: {workspace}") + current = self._agent_for_session() + if current.active_jobs: + raise ValueError("wait for this conversation agent to finish before changing its workspace") self.workspace = workspace update_desktop_settings(workspace=str(workspace)) grants = set(self.tool_context.session_grants) @@ -554,17 +1038,348 @@ def set_workspace(self, path: str | Path) -> dict[str, Any]: cost_tracker=self.cost_tracker, history=self.history_log, ) + current.workspace = workspace + current.tool_context = self.tool_context + current.command_context = self.command_context + current.session = self.session return self.status() + def set_projects_dir(self, path: str | Path) -> dict[str, Any]: + projects_dir = Path(path).expanduser().resolve() + projects_dir.mkdir(parents=True, exist_ok=True) + if not projects_dir.is_dir(): + raise ValueError(f"project download location is not a directory: {projects_dir}") + self.projects_dir = projects_dir + update_desktop_settings(projects_dir=str(projects_dir)) + return {"projects_dir": str(projects_dir), "artifacts": self.list_artifacts()} + + @property + def artifacts_dir(self) -> Path: + return self.projects_dir / "Jonathan Ai Downloads" + + def list_artifacts(self) -> list[dict[str, Any]]: + root = self.artifacts_dir + if not root.exists(): + return [] + rows: list[dict[str, Any]] = [] + for path in root.iterdir(): + if not path.is_file(): + continue + stat = path.stat() + media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + is_image = media_type.startswith("image/") + rows.append({ + "id": path.name, + "name": path.name, + "size": stat.st_size, + "updated_at": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "download_url": f"/api/artifacts/download?name={path.name}", + "view_url": f"/api/artifacts/view?name={path.name}" if is_image else "", + "media_type": media_type, + "is_image": is_image, + }) + rows.sort(key=lambda row: row["updated_at"], reverse=True) + return rows[:30] + + def resolve_artifact(self, name: str) -> Path: + root = self.artifacts_dir.resolve() + target = (root / Path(name).name).resolve() + if target.parent != root or not target.is_file(): + raise ValueError("artifact not found") + return target + + def _publish_artifact(self, source: str | Path, preferred_name: str | None = None) -> dict[str, Any]: + path = Path(source).expanduser().resolve() + if not path.exists(): + raise ValueError(f"generated file does not exist: {path}") + self.artifacts_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + raw_name = preferred_name or path.name + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_name).strip("-.") or "jonathan-artifact" + if path.is_dir(): + if not safe_name.lower().endswith(".zip"): + safe_name += ".zip" + target = self.artifacts_dir / f"{stamp}-{safe_name}" + with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + for current, dirs, names in os.walk(path): + dirs[:] = [name for name in dirs if name not in {".git", ".venv", "node_modules", "__pycache__"}] + for name in names: + item = Path(current) / name + archive.write(item, item.relative_to(path).as_posix()) + else: + suffix = path.suffix + if suffix and not safe_name.lower().endswith(suffix.lower()): + safe_name += suffix + target = self.artifacts_dir / f"{stamp}-{safe_name}" + shutil.copy2(path, target) + return next(row for row in self.list_artifacts() if row["name"] == target.name) + + def _publish_session_artifact( + self, + session_id: str, + source: str | Path, + preferred_name: str | None = None, + ) -> dict[str, Any]: + artifact = self._publish_artifact(source, preferred_name) + instance = self._instances.get(session_id) + if instance is not None: + instance.session.record_artifact(artifact) + return artifact + + def package_workspace(self, session_id: str | None = None) -> dict[str, Any]: + root = (self._agent_for_session(session_id).workspace if session_id else self.workspace).resolve() + if not root.is_dir(): + raise ValueError("workspace is not available") + self.artifacts_dir.mkdir(parents=True, exist_ok=True) + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", root.name).strip("-.") or "jonathan-project" + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + + # Prefer a built, installable output when the project already produced one. + distributable_suffixes = {".exe", ".msi", ".apk", ".dmg", ".deb", ".rpm", ".whl"} + candidates: list[Path] = [] + for folder in ("dist", "build", "release", "packaging"): + base = root / folder + if base.is_dir(): + candidates.extend( + path for path in base.rglob("*") + if path.is_file() and path.suffix.lower() in distributable_suffixes + ) + if candidates: + source = max(candidates, key=lambda path: path.stat().st_mtime) + target = self.artifacts_dir / f"{safe_name}-{stamp}{source.suffix.lower()}" + shutil.copy2(source, target) + else: + target = self.artifacts_dir / f"{safe_name}-{stamp}.zip" + ignored_dirs = { + ".git", ".hg", ".svn", ".venv", "venv", "node_modules", "__pycache__", + ".pytest_cache", ".mypy_cache", ".ruff_cache", "Jonathan Ai Downloads", + } + with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + for current, dirs, names in os.walk(root): + dirs[:] = [name for name in dirs if name not in ignored_dirs] + current_path = Path(current) + for name in names: + path = current_path / name + if path.stat().st_size <= 250 * 1024 * 1024: + archive.write(path, path.relative_to(root).as_posix()) + artifact = self.list_artifacts()[0] + return {"ok": True, "artifact": artifact, "artifacts": self.list_artifacts()} + + def preview_info(self) -> dict[str, Any]: + root = self.workspace.resolve() + ignored = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache"} + files: list[str] = [] + try: + for current, dirs, names in os.walk(root): + dirs[:] = [name for name in dirs if name not in ignored] + current_path = Path(current) + for name in names: + files.append((current_path / name).relative_to(root).as_posix()) + if len(files) >= 250: + break + if len(files) >= 250: + break + except OSError: + pass + entry = next((name for name in ("dist/index.html", "build/index.html", "index.html", "public/index.html") if (root / name).is_file()), "") + return { + "workspace": str(root), + "files": files, + "entry": entry, + "entry_url": f"/preview/{entry}" if entry else "", + } + + def resolve_preview_path(self, relative: str) -> Path: + root = self.workspace.resolve() + target = (root / relative.lstrip("/\\")).resolve() + if target != root and root not in target.parents: + raise ValueError("preview path is outside the workspace") + if not target.is_file(): + raise ValueError("preview file not found") + return target + def list_sessions(self) -> list[dict[str, Any]]: - return Session.list_sessions() + hidden = set(get_desktop_settings().get("hidden_session_ids") or []) + active = {job.session_id for job in self._jobs.values() if not job.done} + return [ + {**row, "agent_state": "working" if row.get("session_id") in active else "idle"} + for row in Session.list_sessions() + if row.get("session_id") not in hidden + ] + + def search_conversations(self, query: str, limit: int = 30) -> dict[str, Any]: + return search_history(query, limit=limit) + + def doctor(self, *, repair: bool = False) -> dict[str, Any]: + return run_doctor(repair=repair) + + def list_agent_profiles(self) -> list[dict[str, Any]]: + summaries = {row["session_id"]: row for row in Session.list_sessions()} + active = {job.session_id for job in self._jobs.values() if not job.done} + rows = [] + for agent in list_roster_agents(): + session_id = str(agent.get("canonical_session_id") or "") + inbox = read_inbox(str(agent.get("id") or ""), limit=200) + rows.append({ + **agent, + "session": summaries.get(session_id), + "state": "working" if session_id in active else "idle", + "unread": sum(1 for item in inbox if not item.get("read_at")), + }) + return rows + + def save_agent_profile(self, payload: dict[str, Any]) -> dict[str, Any]: + existing = get_roster_agent(str(payload.get("id") or "")) if payload.get("id") else None + session_id = str((existing or {}).get("canonical_session_id") or "") + session = Session.load(session_id) if session_id else None + if session is None: + session = Session.create( + str(payload.get("provider") or self.provider_name), + str(payload.get("model") or getattr(self.provider, "model", None) or self.session.model), + workspace=str(payload.get("workspace") or self.workspace), + ) + session.rename(str(payload.get("name") or "Agent")) + session_id = session.session_id + elif payload.get("name"): + session.rename(str(payload["name"])) + saved = save_roster_agent(payload, canonical_session_id=session_id) + return {"agent": saved, "agents": self.list_agent_profiles()} + + def remove_agent_profile(self, agent_id: str) -> dict[str, Any]: + result = remove_roster_agent(agent_id) + return {**result, "agents": self.list_agent_profiles()} + + def open_agent_profile(self, agent_id: str) -> dict[str, Any]: + agent = get_roster_agent(agent_id) + if agent is None: + raise ValueError("Agent profile not found.") + loaded = self.load_session(str(agent.get("canonical_session_id") or "")) + return {"agent": agent, **loaded} + + def run_agent_profile(self, agent_id: str, message: str, *, sender_session_id: str = "") -> dict[str, Any]: + agent = get_roster_agent(agent_id) + if agent is None: + raise ValueError("Agent profile not found.") + prompt = str(message or "").strip() + if not prompt: + raise ValueError("Message is empty.") + sender = sender_session_id or self.session.session_id + mail = send_roster_message(sender_session_id=sender, recipient_agent_id=agent_id, text=prompt) + instructions = str(agent.get("instructions") or "").strip() + framed = f"Your persistent role:\n{instructions}\n\nMessage from another Jonathan agent/session:\n{prompt}" + job_id = self.start_chat(framed, session_id=str(agent.get("canonical_session_id") or "")) + return {"job_id": job_id, "session_id": self._require_job(job_id).session_id, "message": mail} + + def agent_inbox(self, agent_id: str, *, mark_read: bool = False) -> dict[str, Any]: + if get_roster_agent(agent_id) is None: + raise ValueError("Agent profile not found.") + return {"agent_id": agent_id, "messages": read_inbox(agent_id, mark_read=mark_read)} + + def _agent_roster_action(self, sender_session_id: str, action: str, payload: dict[str, Any]) -> dict[str, Any]: + agent_id = str(payload.get("agent_id") or "") + if action == "list": + return {"agents": self.list_agent_profiles()} + if action == "inbox": + return self.agent_inbox(agent_id, mark_read=bool(payload.get("mark_read"))) + if action == "send": + return {"message": send_roster_message(sender_session_id=sender_session_id, recipient_agent_id=agent_id, text=str(payload.get("message") or ""))} + if action == "run": + return self.run_agent_profile(agent_id, str(payload.get("message") or ""), sender_session_id=sender_session_id) + raise ValueError("Unsupported agent roster action.") + + def checkpoints(self, session_id: str | None = None) -> dict[str, Any]: + target = str(session_id or self.session.session_id) + return {"session_id": target, "checkpoints": list_checkpoints(target)} + + def restore_session_checkpoint(self, checkpoint_id: str = "", session_id: str | None = None) -> dict[str, Any]: + agent = self._agent_for_session(session_id) + result = restore_checkpoint(agent.session, checkpoint_id) + if agent.session.session_id == self.session.session_id: + self._bind_session(agent.session) + return result + + def undo_session_turn(self, session_id: str | None = None) -> dict[str, Any]: + agent = self._agent_for_session(session_id) + result = undo_last_turn(agent.session) + if agent.session.session_id == self.session.session_id: + self._bind_session(agent.session) + return result + + def retry_session_turn(self, session_id: str | None = None) -> dict[str, Any]: + agent = self._agent_for_session(session_id) + prompt = prepare_retry(agent.session) + job_id = self.start_chat(prompt, session_id=agent.session.session_id) + return {"job_id": job_id, "session_id": agent.session.session_id, "prompt": prompt} + + def instances_status(self) -> list[dict[str, Any]]: + active_by_session: dict[str, list[ChatJob]] = {} + with self._lock: + jobs = list(self._jobs.values()) + for job in jobs: + if not job.done: + active_by_session.setdefault(job.session_id, []).append(job) + rows: list[dict[str, Any]] = [] + summaries = {row["session_id"]: row for row in Session.list_sessions()} + for session_id, instance in self._instances.items(): + summary = summaries.get(session_id) or instance.session.to_summary() + running = active_by_session.get(session_id, []) + rows.append({ + "session_id": session_id, + "title": summary.get("title") or session_id, + "workspace": str(instance.workspace), + "state": "working" if running else "idle", + "jobs": [ + {"job_id": job.job_id, "mode": job.mode, "started_at": job.started_at} + for job in running + ], + "message_count": summary.get("message_count", 0), + }) + for session_id, summary in summaries.items(): + if session_id in self._instances: + continue + rows.append({ + "session_id": session_id, + "title": summary.get("title") or session_id, + "workspace": summary.get("workspace") or "", + "state": "idle", + "jobs": [], + "message_count": summary.get("message_count", 0), + }) + rows.sort(key=lambda row: (row["state"] != "working", row["title"].lower())) + return rows + + def peek_session(self, session_id: str) -> dict[str, Any]: + instance = self._agent_for_session(session_id) + return { + "session": instance.session.to_summary(), + "messages": instance.session.export_messages(), + "agent": next((row for row in self.instances_status() if row["session_id"] == session_id), {}), + } + + def _read_instance(self, requester_id: str, action: str, target_id: str) -> dict[str, Any]: + if action == "list": + return {"requester_session_id": requester_id, "instances": self.instances_status()} + snapshot = self.peek_session(target_id) + messages = snapshot["messages"][-50:] + for message in messages: + content = str(message.get("content") or "") + if len(content) > 12_000: + message["content"] = content[:12_000] + "\n… [truncated]" + return { + "requester_session_id": requester_id, + "session": snapshot["session"], + "agent": snapshot["agent"], + "messages": messages, + } def new_session(self) -> dict[str, Any]: previous_id = self.session.session_id - try: - self.save_session() - except Exception: - pass + current = self._instances.get(previous_id) + if current is None or not current.active_jobs: + try: + self.save_session() + except Exception: + pass model = getattr(self.provider, "model", None) or self.session.model created = Session.create(self.provider_name, model, workspace=str(self.workspace)) created.save() @@ -577,17 +1392,27 @@ def new_session(self) -> dict[str, Any]: "previous_session_id": previous_id, } + def remove_session(self, session_id: str) -> dict[str, Any]: + target_id = str(session_id or "").strip() + if Session.load(target_id) is None: + raise ValueError(f"session not found: {target_id}") + settings = get_desktop_settings() + hidden = list(settings.get("hidden_session_ids") or []) + if target_id not in hidden: + hidden.append(target_id) + update_desktop_settings(hidden_session_ids=hidden) + replacement = self.new_session() if target_id == self.session.session_id else None + return { + "removed": target_id, + "retained_in_memory": True, + "session": replacement or self.session.to_summary(), + "messages": [] if replacement else self.session.export_messages(), + } + def load_session(self, session_id: str) -> dict[str, Any]: - loaded = Session.load(session_id) - if loaded is None: - raise ValueError(f"session not found: {session_id}") - if loaded.workspace: - try: - self.set_workspace(loaded.workspace) - except ValueError: - pass - self._bind_session(loaded) - return {**loaded.to_summary(), "messages": loaded.export_messages()} + instance = self._agent_for_session(session_id) + self._bind_session(instance.session) + return {**instance.session.to_summary(), "messages": instance.session.export_messages()} def save_session(self) -> dict[str, Any]: self.session.workspace = str(self.workspace) @@ -601,6 +1426,18 @@ def export_current_messages(self) -> dict[str, Any]: "messages": self.session.export_messages(), } + def session_events(self, session_id: str | None = None, limit: int = 500) -> dict[str, Any]: + target = str(session_id or self.session.session_id) + if Session.load(target) is None and target != self.session.session_id: + raise ValueError(f"session not found: {target}") + return read_runtime_events(target, limit) + + def earned_autonomy_status(self, session_id: str | None = None) -> dict[str, Any]: + target = str(session_id or self.session.session_id) + if Session.load(target) is None and target != self.session.session_id: + raise ValueError(f"session not found: {target}") + return autonomy_status(target) + def rename_session(self, session_id: str | None, title: str) -> dict[str, Any]: target_id = str(session_id or self.session.session_id or "").strip() if not target_id: @@ -628,6 +1465,232 @@ def list_skills(self) -> list[dict[str, Any]]: for s in skills ] + def skill_library_status(self) -> dict[str, Any]: + ensure_learning_skill(source_dir=resolve_source_dir() or Path(__file__).resolve().parents[2]) + root = user_skill_library(source_dir=resolve_source_dir() or Path(__file__).resolve().parents[2]) + items: list[dict[str, Any]] = [] + for directory in sorted(root.iterdir(), key=lambda item: item.name.lower()): + path = directory / "SKILL.md" + if directory.name.startswith(".") or not path.is_file(): + continue + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + validation = validate_skill_source(directory.name, content) + items.append({**validation, "path": str(path), "content": content}) + return {"path": str(root), "skills": items} + + def save_library_skill(self, payload: dict[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip().lower().replace("_", "-").replace(" ", "-") + content = str(payload.get("content") or "") + if not content.strip(): + content = render_skill( + name=name, + description=str(payload.get("description") or "").strip(), + instructions=str(payload.get("instructions") or "").strip(), + version=str(payload.get("version") or "1.0.0"), + when_to_use=str(payload.get("when_to_use") or ""), + allowed_tools=[str(item) for item in payload.get("allowed_tools") or []], + user_invocable=bool(payload.get("user_invocable", True)), + ) + saved = save_user_skill(name, content, source_dir=resolve_source_dir() or Path(__file__).resolve().parents[2]) + record_action({ + "session_id": self.session.session_id, + "tool": "SkillLibrary", + "input": {"name": name}, + "permission": "direct user action", + "status": "saved", + }) + return {"saved": saved, **self.skill_library_status()} + + def archive_library_skill(self, name: str) -> dict[str, Any]: + archived = archive_user_skill(name, source_dir=resolve_source_dir() or Path(__file__).resolve().parents[2]) + return {"archived": archived, **self.skill_library_status()} + + def open_library_folder(self) -> dict[str, Any]: + path = open_skill_library(source_dir=resolve_source_dir() or Path(__file__).resolve().parents[2]) + return {"opened": True, "path": str(path)} + + def ecc_integration(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Manage the audited ECC catalog without loading it all into prompts.""" + body = dict(payload or {}) + action = str(body.get("action") or "status").strip().lower() + source = resolve_source_dir() or Path(__file__).resolve().parents[2] + if action == "status": + return ecc_status(source) + if action == "sync": + return ecc_sync(source) + if action == "scan": + return ecc_security_scan(ecc_cache_dir(source)) + if action == "import": + skills = body.get("skills") + agents = body.get("agents") + enable = body.get("enable") + result = ecc_import_catalog( + source, + skill_names=[str(item) for item in skills] if isinstance(skills, list) else None, + agent_names=[str(item) for item in agents] if isinstance(agents, list) else None, + enable_names=[str(item) for item in enable] if isinstance(enable, list) else None, + allow_unsafe=bool(body.get("allow_unsafe", False)), + ) + record_action({ + "session_id": self.session.session_id, + "tool": "ECCIntegration", + "input": {"action": "import", "skill_count": len(result.get("imported_skills") or []), "agent_count": len(result.get("imported_agents") or [])}, + "permission": "direct user action", + "status": "imported", + }) + return result + if action in {"enable", "disable"}: + names = body.get("skills") or body.get("names") or [] + if not isinstance(names, list) or not names: + raise ValueError("Select at least one imported ECC skill.") + return ecc_set_enabled([str(item) for item in names], action == "enable", source) + if action == "memory_export": + return memory_vault_export() + if action == "memory_import": + return memory_vault_import(str(body.get("path") or "")) + if action == "learning_status": + return learning_status(self.workspace) + if action == "analyze_git": + return analyze_git_history(self.workspace, int(body.get("commits") or 200)) + if action == "activate_draft": + draft = analyze_git_history(self.workspace, int(body.get("commits") or 200)) if not body.get("content") else { + "name": str(body.get("name") or ""), "content": str(body.get("content") or "") + } + saved = save_user_skill(str(draft["name"]), str(draft["content"]), source_dir=source) + return {"ok": True, "saved": saved, "learning": learning_status(self.workspace), **self.skill_library_status()} + if action == "agent_profile": + name = str(body.get("name") or "").strip().lower() + available = {str(item.get("name")): item for item in ecc_catalog(source).get("agents") or []} + item = available.get(name) + if not item: + raise ValueError("ECC agent template was not found. Synchronize the catalog first.") + content = Path(str(item["path"])).read_text(encoding="utf-8") + parsed = parse_frontmatter(content) + title = name.replace("-", " ").title() + return self.save_agent_profile({ + "name": title, + "workspace": str(self.workspace), + "instructions": parsed.body.strip(), + "source": "ecc", + "source_revision": ecc_status(source).get("revision") or "", + }) + raise ValueError(f"Unsupported ECC action: {action}") + + def kronos_integration(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Manage local, research-only Kronos OHLCV forecasting.""" + body = dict(payload or {}) + action = str(body.get("action") or "status").strip().lower() + if action == "status": + return self.kronos.status() + if action in {"install", "sync", "repair"}: + result = self.kronos.install() if action != "sync" else self.kronos.sync() + record_action({"session_id": self.session.session_id, "tool": "KronosForecast", + "input": {"action": action}, "permission": "direct user action", "status": "ready"}) + return result + if action == "forecast": + agent = self._agent_for_session(str(body.get("session_id") or "") or None) + if not body.get("input_csv"): + raise ValueError("Select an OHLCV CSV first.") + source = agent.tool_context.ensure_allowed_path(str(body["input_csv"])) + target = agent.tool_context.ensure_allowed_path(str(body.get("output_csv") or f"finance/kronos-{source.stem}-forecast.csv")) + result = self.kronos.forecast(source, target, model=str(body.get("model") or "mini"), + pred_len=int(body.get("pred_len") or 24), lookback=int(body.get("lookback") or 400), + sample_count=int(body.get("sample_count") or 1), + temperature=float(body.get("temperature") or 1.0), top_p=float(body.get("top_p") or 0.9), + timeout=int(body.get("timeout") or 1800)) + artifact = self._publish_artifact(target, target.name) + agent.session.record_artifact(artifact, caption="Kronos research forecast") + agent.session.save() + record_action({"session_id": agent.session.session_id, "tool": "KronosForecast", + "input": {"action": action, "model": body.get("model") or "mini", "pred_len": body.get("pred_len") or 24}, + "permission": "direct user action", "status": "forecasted"}) + return {**result, "artifact": artifact, "artifacts": [artifact]} + raise ValueError(f"Unsupported Kronos action: {action}") + + def personal_finance_integration(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Control the isolated AGPL personal-finance application.""" + body = dict(payload or {}) + action = str(body.get("action") or "status").strip().lower() + if action == "status": result = self.personal_finance.status() + elif action == "sync": result = self.personal_finance.sync() + elif action in {"install", "repair"}: result = self.personal_finance.install() + elif action == "start": result = self.personal_finance.start() + elif action == "stop": result = self.personal_finance.stop() + elif action == "logs": result = self.personal_finance.logs(int(body.get("limit") or 200)) + else: raise ValueError(f"Unsupported personal finance action: {action}") + if action != "status": + record_action({"session_id": self.session.session_id, "tool": "PersonalFinanceVault", + "input": {"action": action}, "permission": "direct user action", "status": "complete"}) + return result + + def code_memory_integration(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Run the isolated local code graph without installing Claude hooks.""" + body = dict(payload or {}) + action = str(body.get("action") or "status").strip().lower() + agent = self._agent_for_session(str(body.get("session_id") or "") or None) + result = self.code_memory.execute( + action, + workspace=agent.tool_context.workspace_root, + query=str(body.get("query") or ""), + target=str(body.get("target") or ""), + mode=str(body.get("mode") or "text"), + force=bool(body.get("force")), + limit=int(body.get("limit") or 100), + ) + if action not in {"status", "search", "usages", "explain", "path"}: + record_action({"session_id": agent.session.session_id, "tool": "CodeMemory", + "input": {"action": action}, "permission": "direct user action", "status": "complete"}) + return result + + def procoder_integration(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Run an explicitly selected, report-first engineering quality controller.""" + body = dict(payload or {}) + action = str(body.get("action") or "status").strip().lower() + agent = self._agent_for_session(str(body.get("session_id") or "") or None) + result = self.procoder.execute(action, workspace=agent.tool_context.workspace_root, + argument=str(body.get("argument") or ""), deep=bool(body.get("deep")), + coverage=bool(body.get("coverage")), timeout=int(body.get("timeout") or 1800)) + if action not in {"status", "doctor"}: + record_action({"session_id": agent.session.session_id, "tool": "Procoder", + "input": {"action": action}, "permission": "direct user action", "status": "complete"}) + return result + + def drawai_integration(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Create editable SVG/PPTX assets from an uploaded raster image.""" + body = dict(payload or {}) + action = str(body.get("action") or "status").strip().lower() + agent = self._agent_for_session(str(body.get("session_id") or "") or None) + if action == "status": result = self.drawai.status() + elif action == "sync": result = self.drawai.sync() + elif action in {"install", "repair"}: result = self.drawai.install(models=True, device=str(body.get("device") or "cpu")) + elif action == "convert": + if not body.get("image"): raise ValueError("Select an uploaded raster image first.") + source = agent.tool_context.ensure_allowed_path(str(body["image"])) + target = agent.tool_context.ensure_allowed_path(str(body.get("output_dir") or f"media/editable-{source.stem}")) + result = self.drawai.convert(source, target, device=str(body.get("device") or "cpu"), timeout=int(body.get("timeout") or 7200)) + published = [self._publish_artifact(Path(path), Path(path).name) for path in result["artifacts"]] + for artifact in published: agent.session.record_artifact(artifact, caption="Editable graphics output") + agent.session.save() + result["artifacts"] = published + else: raise ValueError(f"Unsupported DrawAI action: {action}") + if action != "status": + record_action({"session_id": agent.session.session_id, "tool": "EditableGraphics", + "input": {"action": action}, "permission": "direct user action", "status": "complete"}) + return result + + def character_studio(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + body=dict(payload or {}); agent=self._agent_for_session(str(body.get("session_id") or "") or None) + target=agent.tool_context.ensure_allowed_path(str(body.get("output_dir") or "media/characters")) + result=generate_character(target,seed=str(body.get("seed") or "jonathan"),name=str(body.get("name") or ""),species=str(body.get("species") or "auto"),medium=str(body.get("medium") or "ink")) + published=[self._publish_artifact(Path(path),Path(path).name) for path in result["artifacts"]] + for artifact in published: agent.session.record_artifact(artifact,caption="Procedural character asset") + agent.session.save(); result["artifacts"]=published + record_action({"session_id":agent.session.session_id,"tool":"CharacterStudio","input":{"seed":body.get("seed"),"species":body.get("species")},"permission":"direct user action","status":"complete"}) + return result + def list_commands(self) -> list[dict[str, Any]]: builtins = [ {"name": "/", "description": "Show commands and skills"}, @@ -665,41 +1728,131 @@ def list_tools(self) -> list[dict[str, Any]]: for spec in self.tool_registry.list_specs() ] - def attach_files(self, payload: list[dict[str, Any]] | None) -> list[dict[str, Any]]: - return [item.to_dict() for item in attachments_from_payload(payload)] + def _stage_attachments(self, agent: AgentInstance, attachments: list[Attachment]) -> list[Attachment]: + """Copy explicitly selected files into a per-session tool-readable staging folder.""" + if not attachments: + return [] + target_dir = (Path.home() / ".clawd" / "attachments" / agent.session.session_id).resolve() + target_dir.mkdir(parents=True, exist_ok=True) + roots = list(agent.tool_context.permission_context.additional_working_directories) + if target_dir not in roots: + roots.append(target_dir) + current = agent.tool_context.permission_context + agent.tool_context.permission_context = ToolPermissionContext.from_iterables( + current.deny_names, + current.deny_prefixes, + workspace_root=agent.tool_context.workspace_root, + additional_working_directories=roots, + allow_docs=current.allow_docs, + full_system_access=current.full_system_access, + ) + staged: list[Attachment] = [] + for item in attachments: + if item.data: + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", item.name).strip(".-") or "attachment" + destination = target_dir / f"{uuid.uuid4().hex[:8]}-{safe_name}" + destination.write_bytes(item.data) + parsed = attach_path(destination, kind="screenshot" if item.kind == "screenshot" else "file") + staged.append(replace( + parsed, + name=item.name, + media_type=item.media_type or parsed.media_type, + is_image=item.is_image or parsed.is_image, + )) + continue + if not item.path or item.omitted and not item.is_image: + staged.append(item) + continue + source = Path(item.path).expanduser().resolve() + try: + source.relative_to(target_dir) + staged.append(item) + continue + except ValueError: + pass + if not source.is_file(): + staged.append(replace(item, omitted=True, reason="file not found")) + continue + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", source.name).strip(".-") or "attachment" + destination = target_dir / f"{uuid.uuid4().hex[:8]}-{safe_name}" + shutil.copy2(source, destination) + staged.append(replace(item, path=str(destination), name=source.name)) + return staged + + def attach_files(self, payload: list[dict[str, Any]] | None, session_id: str | None = None) -> list[dict[str, Any]]: + agent = self._agent_for_session(session_id) + rows: list[dict[str, Any]] = [] + for item in self._stage_attachments(agent, attachments_from_payload(payload)): + row = item.to_dict() + if item.path and Path(item.path).is_file(): + stored_name = Path(item.path).name + query = f"session_id={agent.session.session_id}&name={stored_name}" + row["download_url"] = f"/api/attachments/download?{query}" + if item.is_image: + row["preview_url"] = f"/api/attachments/view?{query}" + rows.append(row) + return rows + + def resolve_attachment(self, session_id: str, name: str) -> Path: + safe_session = re.sub(r"[^A-Za-z0-9_-]+", "", str(session_id or "")) + if not safe_session: + raise ValueError("attachment session is required") + root = (Path.home() / ".clawd" / "attachments" / safe_session).resolve() + target = (root / Path(name).name).resolve() + if target.parent != root or not target.is_file(): + raise ValueError("attachment not found") + return target def start_chat( self, text: str, *, attachments: list[dict[str, Any]] | None = None, + session_id: str | None = None, ) -> str: - job = ChatJob(job_id=uuid.uuid4().hex) + agent = self._agent_for_session(session_id) + job = ChatJob(job_id=uuid.uuid4().hex, session_id=agent.session.session_id, mode="chat") with self._lock: + if agent.active_jobs: + raise ValueError("this conversation agent is already working; open another conversation to run in parallel") self._jobs[job.job_id] = job + agent.active_jobs.add(job.job_id) + create_checkpoint(agent.session, reason="before-chat") + parsed_attachments = self._stage_attachments(agent, attachments_from_payload(attachments)) + # Finish the deterministic setup error before returning. This avoids + # leaving a pointless daemon thread racing shutdown/test cleanup when + # no provider exists, while configured providers remain asynchronous. + if self.provider is None: + self._run_chat_job(job, agent, text, parsed_attachments) + return job.job_id thread = threading.Thread( target=self._run_chat_job, - args=(job, text, attachments_from_payload(attachments)), + args=(job, agent, text, parsed_attachments), daemon=True, name=f"clawd-chat-{job.job_id[:8]}", ) thread.start() return job.job_id - def start_multi_agent(self, text: str) -> str: - job = ChatJob(job_id=uuid.uuid4().hex) + def start_multi_agent(self, text: str, *, session_id: str | None = None, mode: str = "balanced") -> str: + agent = self._agent_for_session(session_id) + job = ChatJob(job_id=uuid.uuid4().hex, session_id=agent.session.session_id, mode="workers") with self._lock: + if agent.active_jobs: + raise ValueError("this conversation agent is already working; open another conversation to run in parallel") self._jobs[job.job_id] = job + agent.active_jobs.add(job.job_id) + create_checkpoint(agent.session, reason="before-workers") thread = threading.Thread( target=self._run_multi_agent_job, - args=(job, text), + args=(job, agent, text, mode), daemon=True, name=f"clawd-workers-{job.job_id[:8]}", ) thread.start() return job.job_id - def _run_multi_agent_job(self, job: ChatJob, text: str) -> None: + def _run_multi_agent_job(self, job: ChatJob, agent: AgentInstance, text: str, mode: str = "balanced") -> None: from src.agent.multi_agent import run_internal_workers try: @@ -714,24 +1867,28 @@ def _run_multi_agent_job(self, job: ChatJob, text: str) -> None: if not goal: self._finish(job, {"type": "error", "error": "Message is empty."}) return - self.session.conversation.add_user_message(goal) + agent.session.conversation.add_user_message(goal) self._emit(job, {"type": "user", "text": goal}) - self._emit(job, {"type": "workers_started", "goal": goal}) + self._emit(job, {"type": "workers_started", "goal": goal, "mode": mode}) result = run_internal_workers( provider=self.provider, goal=goal, - parent_session_id=self.session.session_id, + parent_session_id=agent.session.session_id, + mode=mode, ) for worker in result.get("workers") or []: self._emit(job, {"type": "worker", **worker}) answer = str(result.get("answer") or result.get("combined") or "") - self.session.conversation.add_assistant_message(answer) - self.session.save() + agent.session.conversation.add_assistant_message(answer) + agent.session.record_usage(result.get("usage") or {}) + agent.session.save() self._finish(job, { "type": "done", "text": answer, "workers": result.get("workers") or [], - "session": self.session.to_summary(), + "worker_mode": result.get("mode") or mode, + "review": result.get("review") or "", + "session": agent.session.to_summary(), }) except Exception as exc: self._finish(job, {"type": "error", "error": str(exc)}) @@ -754,6 +1911,7 @@ def pending_permissions(self) -> list[dict[str, Any]]: return [ { "request_id": req.request_id, + "session_id": req.session_id, "tool_name": req.tool_name, "message": req.message, "suggestion": req.suggestion, @@ -781,14 +1939,28 @@ def resolve_permission(self, request_id: str, decision: str) -> dict[str, Any]: else: raise ValueError(f"unknown permission decision: {decision}") req.event.set() + try: + append_runtime_event(req.session_id, "permission_decision", { + "request_id": request_id, "tool_name": req.tool_name, "decision": choice, "allowed": req.allowed, + }, workspace=str(self.workspace)) + except Exception: + pass + try: + record_permission_evidence(req.session_id, req.tool_name, choice, request_id) + except Exception: + pass return {"request_id": request_id, "decision": choice, "allowed": req.allowed} def cancel_job(self, job_id: str) -> None: job = self._require_job(job_id) job.cancelled = True + try: + append_runtime_event(job.session_id, "job_cancelled", {"mode": job.mode}, job_id=job.job_id, workspace=str(self.workspace)) + except Exception: + pass with self._lock: for req in self._permissions.values(): - if not req.event.is_set(): + if not req.event.is_set() and req.session_id == job.session_id: req.allowed = False req.event.set() @@ -801,18 +1973,34 @@ def _require_job(self, job_id: str) -> ChatJob: def _emit(self, job: ChatJob, event: dict[str, Any]) -> None: event.setdefault("job_id", job.job_id) + event.setdefault("session_id", job.session_id) with job.condition: job.events.append(event) job.condition.notify_all() + if event.get("type") != "token": + try: + append_runtime_event(job.session_id, str(event.get("type") or "event"), + {key: value for key, value in event.items() if key not in {"job_id", "session_id"}}, + job_id=job.job_id, workspace=str(self._instances.get(job.session_id).workspace if self._instances.get(job.session_id) else self.workspace)) + except Exception: + # The live turn remains authoritative if an OS/filesystem issue + # temporarily prevents optional recovery evidence from writing. + pass def _finish(self, job: ChatJob, event: dict[str, Any]) -> None: self._emit(job, event) with job.condition: job.done = True job.condition.notify_all() + with self._lock: + instance = self._instances.get(job.session_id) + if instance is not None: + instance.active_jobs.discard(job.job_id) def _handle_permission_request( self, + session_id: str, + tool_context: ToolContext, tool_name: str, message: str, suggestion: str | None, @@ -822,6 +2010,7 @@ def _handle_permission_request( tool_name=tool_name, message=message, suggestion=suggestion, + session_id=session_id, ) with self._lock: self._permissions[req.request_id] = req @@ -834,14 +2023,14 @@ def _handle_permission_request( "suggestion": suggestion, } for job in jobs: - if not job.done: + if not job.done and job.session_id == session_id: self._emit(job, payload) if not req.event.wait(timeout=self.permission_timeout_s): return False, False if req.always: - self.tool_context.session_grants.add(tool_name.lower()) + tool_context.session_grants.add(tool_name.lower()) if req.enable_docs: - self.tool_context.permission_context.allow_docs = True + tool_context.permission_context.allow_docs = True return req.allowed, False def _ask_user_questions(self, questions: list[dict]) -> dict[str, str]: @@ -856,7 +2045,7 @@ def _ask_user_questions(self, questions: list[dict]) -> dict[str, str]: answers[question_text] = label return answers - def _run_chat_job(self, job: ChatJob, text: str, attachments: list[Attachment]) -> None: + def _run_chat_job(self, job: ChatJob, agent: AgentInstance, text: str, attachments: list[Attachment]) -> None: try: self._emit(job, {"type": "job_started", "text": text}) if self.provider is None: @@ -871,16 +2060,61 @@ def _run_chat_job(self, job: ChatJob, text: str, attachments: list[Attachment]) rendered = render_attachments(attachments) if rendered: combined = f"{combined}\n\n{rendered}" if combined else rendered + image_briefs: list[str] = [] + wants_ocr = bool(re.search(r"(?i)\b(?:ocr|read|scan|text|word|letter|caption|sign)\b", text or "")) + for attachment in attachments: + if not attachment.is_image or not attachment.path or attachment.omitted: + continue + self._emit(job, {"type": "attachment_scan", "name": attachment.name, "ocr": wants_ocr}) + try: + from src.image_vision import compact_image_brief, inspect_image + + scan = inspect_image(attachment.path, run_ocr=wants_ocr) + image_briefs.append(f"{attachment.name}: {compact_image_brief(scan)}") + except Exception as exc: + image_briefs.append(f"{attachment.name}: local pixel scan unavailable ({exc})") + if image_briefs: + combined += "\n\n[Fast local image scan]\n" + "\n".join(image_briefs) + combined += ( + "\n\n[Attachment handling instruction]\n" + "Focus on the user's attached files and request. Inspect the supplied image pixels or extracted " + "document content directly. Use VisionAnalyze for detailed color, shape, object, QR, or OCR evidence. " + "Do not claim an attachment is unavailable. For an image change, use " + "ImageStudio with the exact staged source path above; use add_text/remove_text for precise pixel-" + "preserving changes and ai_edit for prompt-driven variations. For attached audio or a voice recording, " + "use SpeechStudio transcribe with the exact staged path. Return generated files as artifacts." + ) if not combined.strip(): self._finish(job, {"type": "error", "error": "Message is empty."}) return if combined.lstrip().startswith("/"): - handled = self._handle_slash(job, combined.strip()) + handled = self._handle_slash(job, agent, combined.strip()) if handled: return - self.session.conversation.add_user_message(combined) + image_blocks = [] + attachment_blocks = [] + for attachment in attachments: + if attachment.is_image and not attachment.omitted: + image_blocks.append(ImageContentBlock(source=image_source(attachment))) + elif attachment.path and Path(attachment.path).is_file(): + stored_name = Path(attachment.path).name + query = f"session_id={agent.session.session_id}&name={stored_name}" + attachment_blocks.append(AttachmentContentBlock( + name=attachment.name, + download_url=f"/api/attachments/download?{query}", + media_type=attachment.media_type or "application/octet-stream", + size=attachment.size, + )) + if attachments: + agent.session.conversation.add_user_message([ + TextContentBlock(text=combined, display_text=text.strip()), + *image_blocks, + *attachment_blocks, + ]) + else: + agent.session.conversation.add_user_message(combined) self._emit(job, {"type": "user", "text": combined, "attachments": [a.to_dict() for a in attachments]}) def on_event(ev: ToolEvent) -> None: @@ -914,16 +2148,23 @@ def on_text_chunk(chunk: str) -> None: self._emit(job, {"type": "token", "text": chunk}) try: + lightweight = False + selected_tool_names = None + if self.provider_name == "local": + lightweight, routed_tools = local_chat_route(combined, attachments) + selected_tool_names = None if lightweight else routed_tools result = run_agent_loop( - conversation=self.session.conversation, + conversation=agent.session.conversation, provider=self.provider, tool_registry=self.tool_registry, - tool_context=self.tool_context, - max_turns=20, + tool_context=agent.tool_context, + max_turns=1 if lightweight else (12 if self.provider_name == "local" else 20), stream=self.stream, verbose=False, on_event=on_event, on_text_chunk=on_text_chunk, + lightweight=lightweight, + selected_tool_names=selected_tool_names, ) except Exception as loop_exc: from src.tool_system.schema_sanitize import is_input_schema_type_error @@ -931,10 +2172,10 @@ def on_text_chunk(chunk: str) -> None: if not is_input_schema_type_error(loop_exc): raise result = run_agent_loop( - conversation=self.session.conversation, + conversation=agent.session.conversation, provider=self.provider, tool_registry=self.tool_registry, - tool_context=self.tool_context, + tool_context=agent.tool_context, max_turns=20, stream=self.stream, verbose=False, @@ -942,25 +2183,38 @@ def on_text_chunk(chunk: str) -> None: on_text_chunk=on_text_chunk, omit_tools=True, ) - usage = self.session.record_usage(result.usage) - if self.session.conversation.messages: - self.session.save() + usage = agent.session.record_usage(result.usage) + if agent.session.conversation.messages: + agent.session.save() try: remember_turn( - session_id=self.session.session_id, - title=self.session.display_title(), + session_id=agent.session.session_id, + title=agent.session.display_title(), user_text=combined, assistant_text=result.response_text or "", ) except Exception: pass + try: + record_observation( + agent.workspace, + session_id=agent.session.session_id, + request=text, + response=result.response_text or "", + tool_events=[dict(event) for event in job.events if event.get("type") in {"tool_use", "tool_result", "tool_error"}], + successful=True, + ) + except Exception: + # Learning is optional evidence capture and must never delay or + # break the first visible answer in a conversation. + pass self._finish(job, { "type": "done", "text": result.response_text, "usage": usage, "turn_usage": result.usage, "num_turns": result.num_turns, - "session": self.session.to_summary(), + "session": agent.session.to_summary(), }) except Exception as exc: hint = provider_limit_hint(exc) @@ -970,7 +2224,7 @@ def on_text_chunk(chunk: str) -> None: "provider_limit": bool(hint), }) - def _handle_slash(self, job: ChatJob, raw: str) -> bool: + def _handle_slash(self, job: ChatJob, agent: AgentInstance, raw: str) -> bool: if raw == "/": self._finish(job, { "type": "done", @@ -987,7 +2241,14 @@ def _handle_slash(self, job: ChatJob, raw: str) -> bool: self._finish(job, {"type": "done", "text": _HELP_TEXT, "kind": "command"}) return True if cmd in {"new"}: - created = self.new_session() + if self.session.session_id == agent.session.session_id: + created = self.new_session() + else: + model = getattr(self.provider, "model", None) or agent.session.model + fresh = Session.create(self.provider_name, model, workspace=str(agent.workspace)) + fresh.save() + self._create_instance(fresh, agent.workspace) + created = {**fresh.to_summary(), "messages": [], "previous_session_id": agent.session.session_id} self._finish(job, { "type": "done", "text": "Started a new chat.", @@ -997,28 +2258,32 @@ def _handle_slash(self, job: ChatJob, raw: str) -> bool: }) return True if cmd in {"clear", "reset"}: - self.session.conversation.clear() - self.session.token_usage = empty_token_usage() - if not self.session.custom_title: - self.session.title = "New chat" - self.save_session() + agent.session.conversation.clear() + agent.session.token_usage = empty_token_usage() + if not agent.session.custom_title: + agent.session.title = "New chat" + agent.session.workspace = str(agent.workspace) + agent.session.save() self._finish(job, { "type": "done", "text": "Conversation cleared.", "kind": "command", - "session": self.session.to_summary(), + "session": agent.session.to_summary(), "messages": [], }) return True if cmd == "save": - summary = self.save_session() + agent.session.workspace = str(agent.workspace) + agent.session.save() + summary = agent.session.to_summary() self._finish(job, {"type": "done", "text": f"Session saved: {summary['session_id']}", "kind": "command"}) return True if cmd == "load": if not args: self._finish(job, {"type": "error", "error": "Usage: /load "}) return True - summary = self.load_session(args.strip()) + snapshot = self.peek_session(args.strip()) + summary = {**snapshot["session"], "messages": snapshot["messages"]} self._finish(job, {"type": "done", "text": f"Session loaded: {summary['session_id']}", "kind": "command", "session": summary}) return True if cmd == "tools": @@ -1042,12 +2307,12 @@ def _handle_slash(self, job: ChatJob, raw: str) -> bool: payload: dict[str, Any] = {} if len(tool_parts) == 2: payload = json.loads(tool_parts[1]) - result = self.tool_registry.dispatch(ToolCall(name=tool_parts[0], input=payload), self.tool_context) + result = self.tool_registry.dispatch(ToolCall(name=tool_parts[0], input=payload), agent.tool_context) self._finish(job, {"type": "done", "text": json.dumps(result.output, indent=2, ensure_ascii=False), "kind": "command"}) return True try: - success, result_text, error = execute_command_sync(cmd, args, self.command_context) + success, result_text, error = execute_command_sync(cmd, args, agent.command_context) if success: self._finish(job, {"type": "done", "text": result_text or "", "kind": "command"}) return True @@ -1057,15 +2322,15 @@ def _handle_slash(self, job: ChatJob, raw: str) -> bool: except Exception: pass - if self._try_run_skill(job, cmd, args): + if self._try_run_skill(job, agent, cmd, args): return True return False - def _try_run_skill(self, job: ChatJob, skill_name: str, args: str) -> bool: + def _try_run_skill(self, job: ChatJob, agent: AgentInstance, skill_name: str, args: str) -> bool: try: result = self.tool_registry.dispatch( ToolCall(name="Skill", input={"skill": skill_name, "args": args}), - self.tool_context, + agent.tool_context, ) except Exception: return False @@ -1077,12 +2342,12 @@ def _try_run_skill(self, job: ChatJob, skill_name: str, args: str) -> bool: return False self._emit(job, {"type": "skill", "name": payload.get("commandName", skill_name)}) # Recurse into the normal agent path with the skill prompt. - self.session.conversation.add_user_message(prompt) + agent.session.conversation.add_user_message(prompt) result_loop = run_agent_loop( - conversation=self.session.conversation, + conversation=agent.session.conversation, provider=self.provider, tool_registry=self.tool_registry, - tool_context=self.tool_context, + tool_context=agent.tool_context, max_turns=20, stream=self.stream, verbose=False, @@ -1097,15 +2362,15 @@ def _try_run_skill(self, job: ChatJob, skill_name: str, args: str) -> bool: }), on_text_chunk=lambda chunk: self._emit(job, {"type": "token", "text": chunk}) if chunk else None, ) - usage = self.session.record_usage(result_loop.usage) - self.session.save() + usage = agent.session.record_usage(result_loop.usage) + agent.session.save() self._finish(job, { "type": "done", "text": result_loop.response_text, "usage": usage, "turn_usage": result_loop.usage, "num_turns": result_loop.num_turns, - "session": self.session.to_summary(), + "session": agent.session.to_summary(), }) return True diff --git a/src/desktop/server.py b/src/desktop/server.py index 5cac418..4691bf2 100644 --- a/src/desktop/server.py +++ b/src/desktop/server.py @@ -7,8 +7,10 @@ from __future__ import annotations import json +import mimetypes import os import secrets +import sys import threading import time import webbrowser @@ -19,6 +21,8 @@ from urllib.parse import parse_qs, urlparse from .runtime import DesktopRuntime +from src.tool_system.audit import record_action +from src.version import get_version WEB_DIR = Path(__file__).resolve().parent / "web" DEFAULT_HOST = "127.0.0.1" @@ -55,9 +59,14 @@ def url(self) -> str: def handle_api(self, method: str, path: str, query: dict[str, list[str]], body: dict[str, Any] | None) -> tuple[int, Any]: runtime = self.runtime body = body or {} + if method != "GET" and path not in {"/api/chat", "/api/multi-agent", "/api/attachments", "/api/permissions"}: + record_action({"session_id": runtime.session.session_id, "tool": f"DesktopAPI {path}", + "input": body, "permission": "direct user action", "status": "requested"}) if path == "/api/health": return 200, {"ok": True, "url": self.url} + if path == "/api/ready": + return 200, {"ok": True, "version": get_version()} if path == "/api/status" and method == "GET": return 200, runtime.status() @@ -87,11 +96,29 @@ def handle_api(self, method: str, path: str, query: dict[str, list[str]], body: str(body.get("api_key") or body.get("token") or ""), ) if path == "/api/connectors/local/scan" and method == "POST": - return 200, runtime.scan_local(body.get("base_url") or body.get("url")) + return 200, runtime.scan_local( + body.get("base_url") or body.get("url"), auto_start=bool(body.get("auto_start")), + ) if path == "/api/connectors/local/models" and method == "POST": return 200, runtime.list_local(str(body.get("base_url") or ""), api_key=body.get("api_key")) if path == "/api/connectors" and method == "GET": return 200, runtime.connectors_public() + if path == "/api/fooocus/status" and method == "GET": + return 200, runtime.fooocus_status() + if path == "/api/fooocus/action" and method == "POST": + return 200, runtime.fooocus_action(body) + if path == "/api/connectors/external" and method == "POST": + return 200, runtime.save_external_connector(body) + if path == "/api/connectors/external/remove" and method == "POST": + return 200, runtime.remove_external_connector(str(body.get("id") or "")) + if path == "/api/connectors/external/test" and method == "POST": + return 200, runtime.test_external_connector(str(body.get("id") or "")) + if path == "/api/connectors/external/oauth/start" and method == "POST": + return 200, runtime.start_external_oauth(str(body.get("id") or "")) + if path == "/api/connectors/external/oauth/exchange" and method == "POST": + return 200, runtime.finish_external_oauth( + str(body.get("id") or ""), str(body.get("code") or ""), str(body.get("state") or ""), + ) if path == "/api/connectors/github/login" and method == "POST": return 200, runtime.connect_github(str(body.get("token") or ""), owner=body.get("owner")) if path == "/api/connectors/gitlab/login" and method == "POST": @@ -161,6 +188,14 @@ def handle_api(self, method: str, path: str, query: dict[str, list[str]], body: return 200, runtime.inbound_hook(str(body.get("text") or body.get("prompt") or "")) if path == "/api/workspace" and method == "POST": return 200, runtime.set_workspace(str(body.get("path") or "")) + if path == "/api/projects-dir" and method == "POST": + return 200, runtime.set_projects_dir(str(body.get("path") or "")) + if path == "/api/preview" and method == "GET": + return 200, runtime.preview_info() + if path == "/api/artifacts" and method == "GET": + return 200, {"artifacts": runtime.list_artifacts()} + if path == "/api/artifacts/package" and method == "POST": + return 200, runtime.package_workspace(str(body.get("session_id") or "") or None) if path == "/api/sessions" and method == "GET": return 200, {"sessions": runtime.list_sessions(), "current": runtime.session.to_summary()} if path == "/api/sessions" and method == "POST": @@ -174,22 +209,140 @@ def handle_api(self, method: str, path: str, query: dict[str, list[str]], body: str(body.get("session_id") or body.get("id") or ""), str(body.get("title") or ""), ) + if path == "/api/sessions/remove" and method == "POST": + return 200, runtime.remove_session(str(body.get("session_id") or body.get("id") or "")) if path == "/api/sessions/messages" and method == "GET": return 200, runtime.export_current_messages() + if path == "/api/sessions/events" and method == "GET": + session_id = (query.get("session_id") or [""])[0] + limit = int((query.get("limit") or ["500"])[0] or 500) + return 200, runtime.session_events(session_id or None, limit) + if path == "/api/search" and method == "GET": + term = (query.get("q") or [""])[0] + limit = int((query.get("limit") or ["30"])[0] or 30) + return 200, runtime.search_conversations(term, limit) + if path == "/api/checkpoints" and method == "GET": + return 200, runtime.checkpoints((query.get("session_id") or [""])[0] or None) + if path == "/api/checkpoints/restore" and method == "POST": + return 200, runtime.restore_session_checkpoint(str(body.get("checkpoint_id") or ""), str(body.get("session_id") or "") or None) + if path == "/api/sessions/undo" and method == "POST": + return 200, runtime.undo_session_turn(str(body.get("session_id") or "") or None) + if path == "/api/sessions/retry" and method == "POST": + return 200, runtime.retry_session_turn(str(body.get("session_id") or "") or None) + if path == "/api/doctor" and method == "GET": + return 200, runtime.doctor(repair=False) + if path == "/api/doctor/repair" and method == "POST": + return 200, runtime.doctor(repair=True) + if path == "/api/agent-profiles" and method == "GET": + return 200, {"agents": runtime.list_agent_profiles()} + if path == "/api/agent-profiles" and method == "POST": + return 200, runtime.save_agent_profile(body) + if path == "/api/agent-profiles/remove" and method == "POST": + return 200, runtime.remove_agent_profile(str(body.get("agent_id") or body.get("id") or "")) + if path == "/api/agent-profiles/open" and method == "POST": + return 200, runtime.open_agent_profile(str(body.get("agent_id") or body.get("id") or "")) + if path == "/api/agent-profiles/run" and method == "POST": + return 200, runtime.run_agent_profile( + str(body.get("agent_id") or body.get("id") or ""), + str(body.get("message") or body.get("text") or ""), + sender_session_id=str(body.get("sender_session_id") or runtime.session.session_id), + ) + if path == "/api/agent-profiles/inbox" and method == "POST": + return 200, runtime.agent_inbox(str(body.get("agent_id") or body.get("id") or ""), mark_read=bool(body.get("mark_read"))) + if path == "/api/instances" and method == "GET": + return 200, {"instances": runtime.instances_status(), "current_session_id": runtime.session.session_id} + if path == "/api/instances/peek" and method == "GET": + session_id = (query.get("session_id") or [""])[0] + return 200, runtime.peek_session(session_id) + if path == "/api/terminals" and method == "GET": + return 200, {"terminals": runtime.status().get("terminals") or []} + if path == "/api/device-access" and method == "GET": + return 200, runtime.device_access_status() + if path == "/api/autonomy" and method == "GET": + session_id = (query.get("session_id") or [""])[0] + return 200, runtime.earned_autonomy_status(session_id or None) + if path == "/api/device-access" and method == "POST": + return 200, runtime.configure_device_access( + enabled=bool(body.get("enabled")), + confirmation=str(body.get("confirmation") or ""), + ) + if path == "/api/device/inventory" and method == "GET": + limit = int((query.get("limit") or ["2000"])[0] or 2000) + return 200, runtime.device_inventory(limit=limit) + if path == "/api/media/capabilities" and method == "GET": + return 200, runtime.status().get("media") or {} + if path == "/api/media/image-provider" and method == "POST": + return 200, runtime.configure_image_provider( + str(body.get("api_key") or ""), + base_url=str(body.get("base_url") or ""), + model=str(body.get("model") or ""), + ) + if path == "/api/media/image" and method == "POST": + return 200, runtime.run_image_action(body, str(body.get("session_id") or "") or None) + if path == "/api/audit" and method == "GET": + limit = int((query.get("limit") or ["100"])[0] or 100) + return 200, runtime.audit_actions(limit) + if path == "/api/finance/alpaca" and method == "POST": + return 200, runtime.configure_alpaca(str(body.get("api_key") or ""), str(body.get("secret_key") or ""), paper=bool(body.get("paper", True))) if path == "/api/skills" and method == "GET": return 200, {"skills": runtime.list_skills()} + if path == "/api/skills/library" and method == "GET": + return 200, runtime.skill_library_status() + if path == "/api/skills/save" and method == "POST": + return 200, runtime.save_library_skill(body) + if path == "/api/skills/archive" and method == "POST": + return 200, runtime.archive_library_skill(str(body.get("name") or "")) + if path == "/api/skills/open" and method == "POST": + return 200, runtime.open_library_folder() + if path == "/api/integrations/ecc" and method == "GET": + return 200, runtime.ecc_integration({"action": "status"}) + if path == "/api/integrations/ecc" and method == "POST": + return 200, runtime.ecc_integration(body) + if path == "/api/integrations/kronos" and method == "GET": + return 200, runtime.kronos_integration({"action": "status"}) + if path == "/api/integrations/kronos" and method == "POST": + return 200, runtime.kronos_integration(body) + if path == "/api/integrations/personal-finance" and method == "GET": + return 200, runtime.personal_finance_integration({"action": "status"}) + if path == "/api/integrations/personal-finance" and method == "POST": + return 200, runtime.personal_finance_integration(body) + if path == "/api/integrations/code-memory" and method == "GET": + return 200, runtime.code_memory_integration({"action": "status"}) + if path == "/api/integrations/code-memory" and method == "POST": + return 200, runtime.code_memory_integration(body) + if path == "/api/integrations/procoder" and method == "GET": + return 200, runtime.procoder_integration({"action": "status"}) + if path == "/api/integrations/procoder" and method == "POST": + return 200, runtime.procoder_integration(body) + if path == "/api/integrations/drawai" and method == "GET": + return 200, runtime.drawai_integration({"action": "status"}) + if path == "/api/integrations/drawai" and method == "POST": + return 200, runtime.drawai_integration(body) + if path == "/api/media/character" and method == "POST": + return 200, runtime.character_studio(body) if path == "/api/commands" and method == "GET": return 200, {"commands": runtime.list_commands()} if path == "/api/tools" and method == "GET": return 200, {"tools": runtime.list_tools()} if path == "/api/attachments" and method == "POST": - return 200, {"attachments": runtime.attach_files(body.get("attachments") or body.get("files"))} + return 200, {"attachments": runtime.attach_files( + body.get("attachments") or body.get("files"), + str(body.get("session_id") or "") or None, + )} if path == "/api/chat" and method == "POST": - job_id = runtime.start_chat(str(body.get("text") or ""), attachments=body.get("attachments")) - return 200, {"job_id": job_id} + job_id = runtime.start_chat( + str(body.get("text") or ""), + attachments=body.get("attachments"), + session_id=str(body.get("session_id") or "") or None, + ) + return 200, {"job_id": job_id, "session_id": runtime._require_job(job_id).session_id} if path == "/api/multi-agent" and method == "POST": - job_id = runtime.start_multi_agent(str(body.get("text") or body.get("goal") or "")) - return 200, {"job_id": job_id} + job_id = runtime.start_multi_agent( + str(body.get("text") or body.get("goal") or ""), + session_id=str(body.get("session_id") or "") or None, + mode=str(body.get("mode") or "balanced"), + ) + return 200, {"job_id": job_id, "session_id": runtime._require_job(job_id).session_id} if path == "/api/jobs/events" and method == "GET": job_id = (query.get("job_id") or [""])[0] after = int((query.get("after") or ["0"])[0] or 0) @@ -306,6 +459,47 @@ def _dispatch(self, method: str) -> None: self._sse(query) return + if path.startswith("/preview/"): + if not server._authorized(self.headers): + self._send(HTTPStatus.UNAUTHORIZED, b"unauthorized", "text/plain") + return + try: + target = server.runtime.resolve_preview_path(path[len("/preview/"):]) + ctype = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + self._send(200, target.read_bytes(), ctype) + except ValueError as exc: + self._send(HTTPStatus.NOT_FOUND, str(exc).encode("utf-8"), "text/plain; charset=utf-8") + return + + if path in {"/api/artifacts/download", "/api/artifacts/view"}: + if not server._authorized(self.headers): + self._send(HTTPStatus.UNAUTHORIZED, b"unauthorized", "text/plain") + return + try: + target = server.runtime.resolve_artifact((query.get("name") or [""])[0]) + ctype = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + disposition = "inline" if path.endswith("/view") else "attachment" + self._send(200, target.read_bytes(), ctype, [("Content-Disposition", f'{disposition}; filename="{target.name}"')]) + except ValueError as exc: + self._send(HTTPStatus.NOT_FOUND, str(exc).encode("utf-8"), "text/plain; charset=utf-8") + return + + if path in {"/api/attachments/download", "/api/attachments/view"}: + if not server._authorized(self.headers): + self._send(HTTPStatus.UNAUTHORIZED, b"unauthorized", "text/plain") + return + try: + target = server.runtime.resolve_attachment( + (query.get("session_id") or [""])[0], + (query.get("name") or [""])[0], + ) + ctype = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + disposition = "inline" if path.endswith("/view") else "attachment" + self._send(200, target.read_bytes(), ctype, [("Content-Disposition", f'{disposition}; filename="{target.name}"')]) + except ValueError as exc: + self._send(HTTPStatus.NOT_FOUND, str(exc).encode("utf-8"), "text/plain; charset=utf-8") + return + if path.startswith("/api/"): if path != "/api/health" and not server._authorized(self.headers): status, body, ctype = _json_bytes({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED) @@ -404,7 +598,29 @@ def run_desktop( open_browser: bool = True, token: str | None = None, ) -> int: + from src.install.runtime_process import remove_process_record, write_process_record + server = create_server(host=host, port=port, workspace=workspace, token=token) + source_dir = Path(os.environ.get("CLAWD_SOURCE_DIR") or Path(__file__).resolve().parents[2]).resolve() + managed_by_electron = os.environ.get("CLAWD_DESKTOP_MANAGED_BY_ELECTRON") == "1" + managed_by_launcher = os.environ.get("CLAWD_DESKTOP_MANAGED_BY_LAUNCHER") == "1" + record_owner_pid = os.getppid() if managed_by_launcher else os.getpid() + record_owner_executable = "" + if managed_by_launcher: + candidates = [ + source_dir / ".venv" / "Scripts" / "pythonw.exe", + source_dir / ".venv" / "Scripts" / "python.exe", + ] + record_owner_executable = str(next((path for path in candidates if path.exists()), "")) + else: + record_owner_executable = sys.executable + if not managed_by_electron and record_owner_executable: + write_process_record( + source_dir, + owner_pid=record_owner_pid, + kind="python", + owner_executable=record_owner_executable, + ) print(f"Jonathan Ai desktop host: {server.url}") print("Bound to localhost only. API keys stay in ~/.clawd/config.json.") if open_browser: @@ -415,4 +631,6 @@ def run_desktop( print("\nStopping desktop host.") finally: server.stop() + if not managed_by_electron: + remove_process_record(source_dir, owner_pid=record_owner_pid) return 0 diff --git a/src/desktop/web/app.js b/src/desktop/web/app.js index 4744b51..bd193e4 100644 --- a/src/desktop/web/app.js +++ b/src/desktop/web/app.js @@ -5,10 +5,22 @@ const state = { sessions: [], attachments: [], pendingPermission: null, + permissionQueue: [], sending: false, currentJob: null, + currentSessionId: null, + activeJobs: new Map(), + pendingSessions: new Set(), + changedSessions: new Set(), menuSessionId: null, renamingId: null, + projectChanged: false, + previewOpen: false, + selectedExternalId: null, + externalOauth: null, + agentProfiles: [], + librarySkills: [], + selectedSkill: null, }; const $ = (id) => document.getElementById(id); @@ -21,11 +33,17 @@ async function api(path, options = {}) { return data; } -function renderMarkdown(text) { - const escaped = String(text || "") +function escapeHtml(value) { + return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">"); + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderMarkdown(text) { + const escaped = escapeHtml(text); const withCode = escaped.replace(/```([\s\S]*?)```/g, (_, code) => `
${code}
`); return withCode .replace(/^### (.+)$/gm, "

$1

") @@ -49,34 +67,226 @@ function addBubble(role, text, extraClass) { return node; } +function appendConversationImage(target, image) { + const source = image?.source || {}; + const previewUrl = image?.view_url || image?.preview_url || (source.data + ? `data:${source.media_type || image.media_type || "image/png"};base64,${source.data}` + : image?.download_url); + if (!previewUrl) return; + const name = image?.name || "conversation-image.png"; + const frame = document.createElement("figure"); + frame.className = "conversation-image"; + const previewLink = document.createElement("a"); + previewLink.href = previewUrl; + previewLink.target = "_blank"; + previewLink.rel = "noopener"; + previewLink.title = `Open ${name}`; + const preview = document.createElement("img"); + preview.className = "conversation-image-preview"; + preview.src = previewUrl; + preview.alt = name; + preview.loading = "lazy"; + previewLink.appendChild(preview); + const caption = document.createElement("figcaption"); + const label = document.createElement("span"); + label.textContent = image?.caption || name; + const download = document.createElement("a"); + download.className = "conversation-image-download"; + download.href = image?.download_url || previewUrl; + download.download = name; + download.textContent = "Download"; + caption.append(label, download); + frame.append(previewLink, caption); + target.appendChild(frame); +} + +function appendConversationArtifact(target, artifact) { + if (!artifact?.download_url) return; + if (artifact.is_image || /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i.test(artifact.name || "")) { + appendConversationImage(target, artifact); + return; + } + const link = document.createElement("a"); + link.className = "tool-download"; + link.href = artifact.download_url; + link.download = artifact.name || "download"; + link.textContent = `Download ${artifact.name || "file"}`; + target.appendChild(link); +} + +function appendConversationAttachment(target, attachment) { + if (attachment?.is_image && (attachment.preview_url || attachment.download_url)) { + appendConversationImage(target, attachment); + return; + } + if (!attachment?.download_url) return; + appendConversationArtifact(target, { + ...attachment, + is_image: false, + name: attachment.name || "attachment", + }); +} + +function addArtifactBubble(artifacts, caption = "Generated file") { + const items = Array.isArray(artifacts) ? artifacts : [artifacts]; + const valid = items.filter((item) => item?.download_url); + if (!valid.length) return null; + const bubble = addBubble("assistant", caption, "media-bubble"); + for (const artifact of valid) appendConversationArtifact(bubble, artifact); + return bubble; +} + +function newSkillSource(name = "new-skill") { + return `---\nname: "${name}"\ndescription: "Describe the specific reusable workflow and when it applies."\n---\n\n# ${name}\n\nWrite concise, actionable instructions here. Keep credentials and one-off chat details out of skills.\n`; +} + +function selectLibrarySkill(skill) { + state.selectedSkill = skill?.name || null; + $("skill-name").value = skill?.name || ""; + $("skill-content").value = skill?.content || ""; + $("skill-status").textContent = skill?.valid === false + ? (skill.errors || []).join(" ") + : "Changes are UTF-8 and become available to every agent immediately."; + for (const card of $("skills-list").querySelectorAll(".skill-card")) { + card.classList.toggle("active", card.dataset.name === state.selectedSkill); + } +} + +function renderSkillLibrary(data) { + state.librarySkills = data.skills || []; + $("skills-path").textContent = data.path || "—"; + $("skills-path").title = data.path || ""; + const list = $("skills-list"); + list.innerHTML = ""; + for (const skill of state.librarySkills) { + const card = document.createElement("button"); + card.type = "button"; + card.className = "skill-card"; + card.dataset.name = skill.name; + const title = document.createElement("strong"); + title.textContent = skill.name; + const description = document.createElement("small"); + description.textContent = skill.description || "No description"; + const validity = document.createElement("small"); + validity.textContent = skill.valid ? `v${skill.version || "1.0.0"} · ready` : "Needs attention"; + card.append(title, description, validity); + card.addEventListener("click", () => selectLibrarySkill(skill)); + list.appendChild(card); + } + const selected = state.librarySkills.find((item) => item.name === state.selectedSkill); + if (selected) selectLibrarySkill(selected); + else if (state.librarySkills.length) selectLibrarySkill(state.librarySkills[0]); + else selectLibrarySkill(null); +} + +async function refreshSkillLibrary() { + const data = await api("/api/skills/library"); + renderSkillLibrary(data); + return data; +} + +function renderEccCatalog(data) { + state.eccCatalog = data || {}; + const counts = data?.counts || {}; + const revision = String(data?.revision || "").slice(0, 8); + $("ecc-summary").textContent = data?.available + ? `${counts.skills || 0} skills · ${counts.agents || 0} agents · ${counts.enabled || 0} enabled${revision ? ` · ${revision}` : ""}` + : "Not synchronized"; + const query = ($("ecc-filter")?.value || "").trim().toLowerCase(); + const list = $("ecc-skill-list"); + list.innerHTML = ""; + for (const skill of (data?.skills || []).filter((item) => !query || `${item.name} ${item.description}`.toLowerCase().includes(query)).slice(0, 300)) { + const card = document.createElement("div"); + card.className = "ecc-skill-card"; + const label = document.createElement("span"); + label.textContent = skill.name; + label.title = skill.description || skill.name; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ghost"; + button.textContent = !skill.installed ? "Import" : (skill.enabled ? "Disable" : "Enable"); + button.addEventListener("click", async () => { + button.disabled = true; + try { + const body = !skill.installed + ? { action: "import", skills: [skill.name], agents: [], enable: [skill.name] } + : { action: skill.enabled ? "disable" : "enable", skills: [skill.name] }; + const result = await api("/api/integrations/ecc", { method: "POST", body: JSON.stringify(body) }); + renderEccCatalog(result.catalog || result); + } catch (err) { $("skill-status").textContent = err.message; } + finally { button.disabled = false; } + }); + card.append(label, button); + list.appendChild(card); + } +} + +async function refreshEccCatalog() { + const data = await api("/api/integrations/ecc"); + renderEccCatalog(data); + return data; +} + function renderTranscript(messages) { $("transcript").innerHTML = ""; for (const msg of messages || []) { const role = msg.role || "assistant"; - const text = typeof msg.content === "string" ? msg.content : ""; - if (!text.trim()) continue; - addBubble(role, text); + const content = msg.content; + const text = typeof content === "string" ? content : (Array.isArray(content) + ? content.filter((block) => block?.type === "text").map((block) => block.text || "").join("\n") : ""); + let bubble = text.trim() ? addBubble(role, text) : null; + if (Array.isArray(content)) { + for (const block of content) { + if (block?.type === "image" && block.source?.data) { + bubble = bubble || addBubble(role, "", "media-bubble"); + appendConversationImage(bubble, block); + } else if (block?.type === "artifact") { + bubble = bubble || addBubble(role, block.caption || "Generated file", "media-bubble"); + appendConversationArtifact(bubble, block); + } else if (block?.type === "attachment") { + bubble = bubble || addBubble(role, "", "media-bubble"); + appendConversationAttachment(bubble, block); + } + } + } } } -function addToolCard(event) { - const node = document.createElement("article"); - node.className = "bubble tool-card"; - const summary = event.summary || event.error || ""; - node.innerHTML = `${event.tool_name || event.type}
${summary}
`; - $("transcript").appendChild(node); - node.scrollIntoView({ block: "end" }); - return node; -} - -function setBusy(busy) { +function updateBusyUi() { + const sessionId = state.currentSessionId; + const busy = state.pendingSessions.has(sessionId) + || [...state.activeJobs.values()].some((job) => job.sessionId === sessionId); state.sending = busy; $("send").disabled = busy; + $("workers").disabled = busy; + const robot = $("working-robot"); + if (robot) robot.classList.toggle("hidden", !busy); + if (!busy && $("working-progress")) $("working-progress").textContent = "Working…"; +} + +function setProgress(label) { + if ($("working-progress") && label) $("working-progress").textContent = label; +} + +function setBusy(busy, sessionId = state.currentSessionId) { + if (!sessionId) return; + if (busy) state.pendingSessions.add(sessionId); + else state.pendingSessions.delete(sessionId); + updateBusyUi(); } function showPermission(event) { + if (state.pendingPermission) { + if (state.pendingPermission.request_id !== event.request_id + && !state.permissionQueue.some((item) => item.request_id === event.request_id)) { + state.permissionQueue.push(event); + } + return; + } state.pendingPermission = event; - $("perm-title").textContent = `Allow ${event.tool_name}?`; + const session = state.sessions.find((item) => item.session_id === event.session_id); + const agentLabel = session?.title || String(event.session_id || "").slice(-8); + $("perm-title").textContent = `Allow ${event.tool_name} for ${agentLabel || "this agent"}?`; $("perm-message").textContent = event.message || ""; $("permission-banner").classList.remove("hidden"); } @@ -84,6 +294,8 @@ function showPermission(event) { function hidePermission() { state.pendingPermission = null; $("permission-banner").classList.add("hidden"); + const next = state.permissionQueue.shift(); + if (next) queueMicrotask(() => showPermission(next)); } async function decidePermission(decision) { @@ -97,13 +309,16 @@ async function decidePermission(decision) { async function refreshStatus() { state.status = await api("/api/status"); + state.currentSessionId = state.status.session?.session_id || state.currentSessionId; $("workspace-path").textContent = state.status.workspace; $("workspace-path").title = state.status.workspace; + $("projects-path").textContent = state.status.projects_dir || "—"; + $("projects-path").title = state.status.projects_dir || ""; const model = state.status.model || "unconfigured"; $("model-line").textContent = `${state.status.provider} · ${model}`; $("chat-title").textContent = state.status.session?.title || "New chat"; if ($("app-version")) { - const ver = state.status.version || "0.2.8"; + const ver = state.status.version || "0.4.8"; $("app-version").textContent = `v${ver} · standalone`; document.title = `Jonathan Ai ${ver}`; } @@ -115,12 +330,116 @@ async function refreshStatus() { : "Not a git repo"; } renderConnectorStatus(state.status.connectors || {}); + renderFooocusStatus(state.status.fooocus || state.status.media?.image?.fooocus || {}); + renderTerminals(state.status.terminals || []); + renderMediaStatus(state.status.media || {}); + renderAdminStatus(state.status.administrator || {}); + renderDeviceAccess(state.status.device_access || {}); + renderAutonomyStatus(state.status.earned_autonomy || {}); + renderFinanceStatus(state.status.business_finance || {}); + renderKronosStatus(state.status.kronos || {}); + renderPersonalFinanceStatus(state.status.personal_finance || {}); + renderCodeMemoryStatus(state.status.code_memory || {}); + renderProcoderStatus(state.status.procoder || {}); + renderDrawAiStatus(state.status.drawai || {}); + renderArtifacts(state.status.artifacts || []); if (state.status.needs_setup) { $("setup-modal").classList.remove("hidden"); } else { $("setup-modal").classList.add("hidden"); } renderUpdate(state.status.update || {}); + updateBusyUi(); +} + +function formatBytes(bytes) { + const value = Number(bytes || 0); + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`; + return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +function renderArtifacts(items) { + const box = $("artifact-list"); + if (!box) return; + box.innerHTML = ""; + if (!items.length) { + box.innerHTML = '
No packaged downloads yet.
'; + return; + } + for (const item of items) { + const row = document.createElement("div"); + row.className = "artifact-item"; + const link = document.createElement("a"); + link.href = item.download_url; + link.download = item.name; + link.textContent = item.name; + link.title = "Download packaged project"; + const size = document.createElement("span"); + size.className = "muted"; + size.textContent = formatBytes(item.size); + row.append(link, size); + box.appendChild(row); + } +} + +async function refreshArtifacts() { + const data = await api("/api/artifacts"); + renderArtifacts(data.artifacts || []); + return data.artifacts || []; +} + +async function packageProject(automatic = false, sessionId = state.currentSessionId) { + if (!automatic && sessionId === state.currentSessionId) addBubble("system", "Packaging the current project…"); + try { + const data = await api("/api/artifacts/package", { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); + renderArtifacts(data.artifacts || []); + const artifact = data.artifact; + if (artifact && sessionId === state.currentSessionId) { + const node = addBubble("system", ""); + node.innerHTML = `Project ready: ${artifact.name} (${formatBytes(artifact.size)})`; + } + } catch (err) { + if (!automatic) addBubble("system", err.message); + } +} + +async function refreshPreview() { + const data = await api("/api/preview"); + $("preview-path").textContent = data.workspace || "Workspace"; + const files = $("preview-files"); + files.innerHTML = ""; + for (const name of data.files || []) { + const row = document.createElement("div"); + row.className = "preview-file"; + row.textContent = name; + row.title = name; + files.appendChild(row); + } + const frame = $("preview-frame"); + const empty = $("preview-empty"); + if (data.entry_url) { + frame.src = `${data.entry_url}?preview=${Date.now()}`; + frame.classList.remove("hidden"); + empty.classList.add("hidden"); + } else { + frame.removeAttribute("src"); + frame.classList.add("hidden"); + empty.classList.remove("hidden"); + } + await refreshArtifacts(); +} + +async function setPreviewOpen(open) { + state.previewOpen = open; + $("app").classList.toggle("preview-open", open); + $("preview-pane").classList.toggle("hidden", !open); + $("toggle-preview").textContent = open ? "Hide preview" : "Preview"; + if (open) await refreshPreview(); } function formatActivity(iso) { @@ -141,7 +460,8 @@ function renderUsage(session) { const inn = usage.input_tokens || 0; const out = usage.output_tokens || 0; const total = usage.total_tokens || inn + out; - node.textContent = `${inn} in · ${out} out · ${total} total (informational)`; + const approximation = usage.estimated ? "~" : ""; + node.textContent = `${approximation}${inn} in · ${approximation}${out} out · ${approximation}${total} total (informational)`; node.title = "Token counts for this chat only. Informational — never a quota, paywall, or purchase path."; } @@ -213,7 +533,7 @@ async function refreshSessions() { for (const session of state.sessions) { const btn = document.createElement("button"); btn.type = "button"; - btn.className = `session-item${session.session_id === current ? " active" : ""}`; + btn.className = `session-item${session.session_id === current ? " active" : ""}${session.agent_state === "working" ? " working" : ""}`; btn.dataset.sessionId = session.session_id; const tokens = session.token_usage?.total_tokens || 0; const title = session.title || session.session_id; @@ -247,17 +567,13 @@ async function refreshSessions() { heading.textContent = title; const meta = document.createElement("div"); meta.className = "muted"; - meta.textContent = `${formatActivity(session.updated_at)} · ${tokens} tok`; + meta.textContent = `${session.agent_state === "working" ? "Agent working · " : ""}${formatActivity(session.updated_at)} · ${tokens} tok`; btn.appendChild(heading); btn.appendChild(meta); } btn.addEventListener("click", async () => { if (state.renamingId === session.session_id) return; - const loaded = await api("/api/sessions/load", { method: "POST", body: JSON.stringify({ session_id: session.session_id }) }); - renderTranscript(loaded.messages || []); - await refreshStatus(); - await refreshSessions(); - $("prompt").focus(); + await loadAgentSession(session.session_id); }); btn.addEventListener("dblclick", (event) => { event.preventDefault(); @@ -270,6 +586,42 @@ async function refreshSessions() { }); list.appendChild(btn); } + await refreshInstances(); +} + +async function loadAgentSession(sessionId) { + const loaded = await api("/api/sessions/load", { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); + state.currentSessionId = loaded.session_id; + renderTranscript(loaded.messages || []); + await refreshStatus(); + await refreshSessions(); + updateBusyUi(); + $("prompt").focus(); +} + +async function refreshInstances() { + const box = $("instance-list"); + if (!box) return; + const data = await api("/api/instances"); + const instances = data.instances || []; + box.innerHTML = ""; + for (const instance of instances) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `instance-item ${instance.state || "idle"}`; + button.title = `${instance.workspace || ""}\n${instance.state === "working" ? "Click to cross-view this running agent" : "Click to view this agent"}`; + const dot = document.createElement("span"); + dot.className = "agent-dot"; + const copy = document.createElement("span"); + copy.className = "instance-copy"; + copy.textContent = `${instance.title || instance.session_id}${instance.state === "working" ? " · working" : ""}`; + button.append(dot, copy); + button.addEventListener("click", () => loadAgentSession(instance.session_id)); + box.appendChild(button); + } } async function refreshCommands() { @@ -331,6 +683,7 @@ function renderConnectorStatus(connectors) { ? `Connected ${connectors.github.login || ""} · owner ${connectors.github.owner}` : "Not connected"; if ($("gh-owner") && connectors.github.owner) $("gh-owner").value = connectors.github.owner; + if ($("card-github-state")) $("card-github-state").textContent = connectors.github.configured ? `Connected · ${connectors.github.login || connectors.github.owner}` : "Not connected"; } if ($("gl-status") && connectors.gitlab) { $("gl-status").textContent = connectors.gitlab.configured @@ -338,9 +691,398 @@ function renderConnectorStatus(connectors) { : "Not connected"; if (connectors.gitlab.host) $("gl-host").value = connectors.gitlab.host; if (connectors.gitlab.owner) $("gl-owner").value = connectors.gitlab.owner; + if ($("card-gitlab-state")) $("card-gitlab-state").textContent = connectors.gitlab.configured ? `Connected · ${connectors.gitlab.login || connectors.gitlab.owner}` : "Not connected"; } renderMcpList(connectors.mcp || []); renderAgentList(connectors.agents || []); + renderExternalList(connectors.services || []); + if ($("card-mcp-state")) $("card-mcp-state").textContent = connectors.mcp?.length ? `${connectors.mcp.length} saved` : "Add server"; + if ($("card-agents-state")) $("card-agents-state").textContent = connectors.agents?.length ? `${connectors.agents.length} saved` : "Add agent"; + if ($("card-external-state")) $("card-external-state").textContent = connectors.services?.length ? `${connectors.services.length} saved` : "Add service"; +} + +function openControl(panelId) { + $("control-modal").classList.remove("hidden"); + for (const id of ["search-panel", "agent-hub-panel", "doctor-panel"]) $(id).classList.toggle("hidden", id !== panelId); +} + +async function runHistorySearch() { + const query = $("history-query").value.trim(); + const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=50`); + $("history-meta").textContent = `${data.documents || 0} indexed entries · ${data.fts5 ? "fast full-text search" : "basic search"}`; + const box = $("history-results"); + box.innerHTML = ""; + for (const hit of data.hits || []) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "scan-item search-hit"; + const title = document.createElement("strong"); + title.textContent = `${hit.title || "Memory"} · ${hit.role || hit.kind}`; + const snippet = document.createElement("small"); + snippet.textContent = String(hit.snippet || "").replace(/[\[\]]/g, ""); + row.append(title, snippet); + if (hit.session_id) row.addEventListener("click", async () => { + try { + await loadAgentSession(hit.session_id); + $("control-modal").classList.add("hidden"); + } catch (_err) { + addBubble("system", "That result is retained in shared memory; its original conversation is no longer available."); + } + }); + box.appendChild(row); + } + if (!(data.hits || []).length) box.innerHTML = '
No matching conversations or memory facts.
'; +} + +function clearProfileForm() { + $("profile-id").value = ""; + $("profile-name").value = ""; + $("profile-workspace").value = state.status?.workspace || ""; + $("profile-instructions").value = ""; +} + +async function refreshAgentProfiles() { + const data = await api("/api/agent-profiles"); + state.agentProfiles = data.agents || []; + const floor = $("agent-floor"); + floor.innerHTML = ""; + const command = document.createElement("div"); command.className = "agent-desk command-desk"; command.innerHTML = 'Jonathan commandCurrent conversation'; floor.appendChild(command); + for (const profile of state.agentProfiles) { + const desk = document.createElement("button"); desk.type = "button"; desk.className = `agent-desk ${profile.state || "idle"}`; + const tokens = profile.session?.token_usage || {}; desk.innerHTML = ''; + desk.querySelector("strong").textContent = profile.name || "Agent"; desk.querySelector("small").textContent = `${profile.state || "idle"} · ${(tokens.input_tokens || 0) + (tokens.output_tokens || 0)} tokens · ${profile.unread || 0} unread`; + desk.addEventListener("click", async () => { const loaded=await api("/api/agent-profiles/open",{method:"POST",body:JSON.stringify({agent_id:profile.id})}); state.currentSessionId=loaded.session_id; renderTranscript(loaded.messages||[]); $("control-modal").classList.add("hidden"); await refreshStatus(); await refreshSessions(); }); floor.appendChild(desk); + } + if (!state.agentProfiles.length) { const empty=document.createElement("div"); empty.className="agent-desk empty-desk"; empty.innerHTML="Open deskCreate a named agent below"; floor.appendChild(empty); } + const box = $("profile-list"); + box.innerHTML = ""; + for (const profile of state.agentProfiles) { + const card = document.createElement("article"); + card.className = "profile-card"; + const tokens = profile.session?.token_usage || {}; + card.innerHTML = `
`; + card.querySelector("strong").textContent = `${profile.name}${profile.state === "working" ? " · working" : ""}`; + const approximation = tokens.estimated ? "~" : ""; + card.querySelector("small").textContent = `${approximation}${tokens.input_tokens || 0} in · ${approximation}${tokens.output_tokens || 0} out · ${profile.unread || 0} unread`; + const actions = card.querySelector(".profile-actions"); + const addAction = (label, fn, cls = "ghost") => { + const button = document.createElement("button"); button.type = "button"; button.className = cls; button.textContent = label; button.addEventListener("click", fn); actions.appendChild(button); + }; + addAction("Open", async () => { + const loaded = await api("/api/agent-profiles/open", { method: "POST", body: JSON.stringify({ agent_id: profile.id }) }); + state.currentSessionId = loaded.session_id; renderTranscript(loaded.messages || []); $("control-modal").classList.add("hidden"); await refreshStatus(); await refreshSessions(); + }); + addAction("Run", async () => { + const message = window.prompt(`Send work to ${profile.name}`, ""); + if (!message) return; + const result = await api("/api/agent-profiles/run", { method: "POST", body: JSON.stringify({ agent_id: profile.id, message, sender_session_id: state.currentSessionId }) }); + watchJob(result.job_id, result.session_id); await refreshAgentProfiles(); + }); + addAction("Edit", () => { + $("profile-id").value = profile.id; $("profile-name").value = profile.name || ""; $("profile-workspace").value = profile.workspace || ""; $("profile-instructions").value = profile.instructions || ""; + }); + addAction("Remove", async () => { + if (!window.confirm(`Remove ${profile.name} from the roster? Its conversation will remain searchable.`)) return; + await api("/api/agent-profiles/remove", { method: "POST", body: JSON.stringify({ agent_id: profile.id }) }); await refreshAgentProfiles(); + }, "danger"); + box.appendChild(card); + } + if (!state.agentProfiles.length) box.innerHTML = '
No named agents yet. Create one above.
'; +} + +async function showDoctor(repair = false) { + const data = await api(repair ? "/api/doctor/repair" : "/api/doctor", repair ? { method: "POST", body: "{}" } : {}); + $("doctor-summary").textContent = `${data.ok ? "Core runtime ready" : "Attention needed"} · ${data.next_action || ""}${data.repaired?.length ? ` · repaired ${data.repaired.join(", ")}` : ""}`; + const box = $("doctor-results"); box.innerHTML = ""; + for (const check of data.checks || []) { + const row = document.createElement("div"); row.className = `scan-item ${check.ok ? "ok" : ""}`; row.innerHTML = ""; row.querySelector("strong").textContent = `${check.ok ? "✓" : "!"} ${check.name}`; row.querySelector("small").textContent = check.detail || ""; box.appendChild(row); + } +} + +function renderFooocusStatus(status) { + if (!status) return; + const stateLabel = status.dependencies_ready ? "Ready" : "Needs dependencies"; + if ($("card-fooocus-state")) $("card-fooocus-state").textContent = stateLabel; + const box = $("fooocus-status"); + if (!box) return; + box.innerHTML = ""; + const rows = [ + ["State", stateLabel], + ["Engine", `${status.backend || "diffusers"} · ${status.model || "local model"}`], + ["Runtime", status.dependencies_ready ? "Python packages ready" : `Missing: ${(status.missing || []).join(", ")}`], + ["Outputs", status.outputs || "—"], + ]; + for (const [label, value] of rows) { + const row = document.createElement("div"); + row.className = "scan-item"; + row.innerHTML = `${label}`; + row.querySelector("small").textContent = value; + box.appendChild(row); + } + if (status.log && (status.starting || !status.dependencies_ready)) { + const log = document.createElement("pre"); + log.className = "connector-log"; + log.textContent = status.log; + box.appendChild(log); + } +} + +function renderExternalList(items) { + const box = $("external-list"); + if (!box) return; + box.innerHTML = ""; + for (const item of items) { + const card = document.createElement("button"); + card.type = "button"; + card.className = `connector-mini-card${state.selectedExternalId === item.id ? " active" : ""}`; + card.innerHTML = `${item.name}${item.auth_type} · ${item.configured ? "configured" : "needs credentials"}`; + card.addEventListener("click", () => { + state.selectedExternalId = item.id; + $("external-name").value = item.name || ""; + $("external-url").value = item.base_url || ""; + $("external-auth").value = item.auth_type || "none"; + $("external-username").value = item.username || ""; + $("external-header").value = item.api_key_header || "X-API-Key"; + $("external-auth-url").value = item.authorization_url || ""; + $("external-token-url").value = item.token_url || ""; + $("external-client-id").value = item.client_id || ""; + $("external-scopes").value = item.scopes || ""; + updateExternalAuthFields(); + renderExternalList(items); + }); + box.appendChild(card); + } +} + +function openConnectorPanel(name) { + document.querySelectorAll("[data-connector-panel]").forEach((panel) => { + panel.classList.toggle("hidden", panel.dataset.connectorPanel !== name); + }); + $("connector-dashboard").classList.add("hidden"); + $("connector-detail").classList.remove("hidden"); +} + +function closeConnectorPanel() { + $("connector-detail").classList.add("hidden"); + $("connector-dashboard").classList.remove("hidden"); +} + +function updateExternalAuthFields() { + const auth = $("external-auth").value; + const visible = new Set( + auth === "password" || auth === "basic" ? ["external-username", "external-password"] + : auth === "api_key" ? ["external-token", "external-header"] + : auth === "bearer" ? ["external-token"] + : auth === "oauth2" ? ["external-auth-url", "external-token-url", "external-client-id", "external-client-secret", "external-scopes", "external-code"] + : [] + ); + for (const id of ["external-username", "external-password", "external-token", "external-header", "external-auth-url", "external-token-url", "external-client-id", "external-client-secret", "external-scopes", "external-code"]) { + $(id).closest("label").classList.toggle("hidden", !visible.has(id)); + } + $("external-oauth-start").classList.toggle("hidden", auth !== "oauth2"); + $("external-oauth-finish").classList.toggle("hidden", auth !== "oauth2"); +} + +function showDirectImageResult(data) { + const box = $("image-result"); + box.innerHTML = ""; + const row = document.createElement("div"); + row.className = "scan-item"; + const title = document.createElement("strong"); + title.textContent = `${data.action || "Image"} · ${data.width || "?"}×${data.height || "?"}${data.engine ? ` · ${data.engine}` : ""}`; + row.appendChild(title); + const artifact = data.artifact || (data.artifacts || [])[0]; + if (artifact?.download_url) { + const link = document.createElement("a"); + link.className = "tool-download"; + link.href = artifact.download_url; + link.download = artifact.name || "image"; + link.textContent = `Download ${artifact.name || "image"}`; + row.appendChild(link); + const preview = document.createElement("img"); + preview.className = "direct-image-preview"; + preview.src = artifact.view_url || artifact.download_url; + preview.alt = artifact.name || "Image result"; + row.appendChild(preview); + } + box.appendChild(row); +} + +function renderTerminals(items) { + const box = $("terminal-list"); + if (!box) return; + box.innerHTML = ""; + for (const item of items) { + const row = document.createElement("div"); + row.className = "scan-item"; + row.innerHTML = `${item.name}
${item.path}
`; + box.appendChild(row); + } + if (!items.length) box.innerHTML = '
No supported terminal discovered.
'; +} + +function renderMediaStatus(media) { + const box = $("media-status"); + if (!box) return; + const image = media.image || {}; + const threeD = media.three_d || {}; + box.innerHTML = ""; + const rows = [ + ["Image Studio", image.pillow && image.opencv, image.pillow && image.opencv ? "Ready · creation, editing, text removal, conversion" : "Dependencies will repair on restart"], + ["Local 3D", threeD.trimesh, threeD.trimesh ? "Ready · GLB/GLTF/OBJ/STL/PLY" : "Dependencies will repair on restart"], + ["Blender", threeD.blender, threeD.blender ? `Ready · ${threeD.blender_path || "installed"}` : "Optional · needed for textures, renders, BLEND/FBX/USD"], + ]; + for (const [label, ready, detail] of rows) { + const row = document.createElement("div"); + row.className = "scan-item media-engine"; + row.innerHTML = `${ready ? "●" : "○"} ${label}${detail}`; + box.appendChild(row); + } + const threeBox = $("media-3d-status"); + if (threeBox) { + threeBox.innerHTML = rows.slice(1).map(([label, ready, detail]) => `
${ready ? "●" : "○"} ${label}${detail}
`).join(""); + } + if ($("card-images-state")) $("card-images-state").textContent = image.ai_configured ? "AI + local ready" : "Local ready · add AI key"; + if ($("image-api-url") && image.ai_endpoint) $("image-api-url").value = image.ai_endpoint; +} + +function renderAdminStatus(admin) { + const box = $("admin-status"); + if (!box) return; + const privilege = admin.administrator ? "Administrator privileges active" : "Standard privileges · elevation requested when required"; + box.innerHTML = `
${admin.ready ? "●" : "○"} Administrator tools${privilege}
` + + `
● Action audit${admin.audit_path || "~/.clawd/audit/actions.jsonl"}
` + + `
${admin.git ? "●" : "○"} Git automationGitHub and GitLab clone, fetch, pull, review-branch push
`; +} + +function renderDeviceAccess(access) { + const box = $("device-access-status"); + if (!box) return; + const enabled = Boolean(access.enabled); + const scope = access.operating_system_scope || "signed-in user"; + const approval = access.approved_at ? ` · approved ${new Date(access.approved_at).toLocaleString()}` : ""; + box.innerHTML = `
${enabled ? "● Full access enabled" : "○ Full access disabled"}OS scope: ${scope}${approval}
` + + `
${access.uac_required_for_elevation ? "○" : "●"} Administrator elevation${access.uac_required_for_elevation ? "Windows will display UAC when administrator rights are required" : "Administrator rights are active"}
`; + const button = $("device-access-toggle"); + button.textContent = enabled ? "Revoke full access" : "Enable full access"; + button.className = enabled ? "danger" : "primary"; + if ($("card-device-state")) $("card-device-state").textContent = enabled ? "Full access enabled" : "Approval gated"; +} + +function renderAutonomyStatus(data) { + const box = $("autonomy-status"); + if (!box) return; + box.innerHTML = ""; + const profiles = data.profiles || []; + if (!profiles.length) { + box.innerHTML = '
Execute with approvalNo reviewed tool decisions yet. Access stays approval-gated.
'; + return; + } + for (const profile of profiles) { + const row = document.createElement("div"); + row.className = "scan-item media-engine"; + const confidence = Math.round(Number(profile.clean_approval_lower_bound || 0) * 100); + row.innerHTML = ""; + row.querySelector("strong").textContent = `${profile.workflow} · ${profile.recommended_level_label}`; + row.querySelector("span").textContent = `${profile.clean_approvals} clean / ${profile.total_decisions} decisions · ≥${confidence}% confidence · ${profile.risk} risk ceiling: ${profile.autonomy_ceiling_label}`; + box.appendChild(row); + } + const note = document.createElement("div"); + note.className = "muted"; + note.textContent = data.enforcement || "Explicit user approval remains the enforcement point."; + box.appendChild(note); +} + +function renderDeviceInventory(data) { + const box = $("device-inventory"); + if (!box) return; + box.innerHTML = ""; + const counts = data.counts || {}; + const summary = document.createElement("div"); + summary.className = "scan-item media-engine ok"; + const title = document.createElement("strong"); + title.textContent = `● ${counts.applications || 0} applications · ${counts.browsers || 0} browsers · ${counts.command_line_tools || 0} CLI tools`; + const detail = document.createElement("span"); + detail.className = "muted"; + detail.textContent = "Agents can address any listed executable by name or absolute path."; + summary.append(title, detail); + box.appendChild(summary); + for (const [label, items] of [["Browsers", data.browsers || []], ["Applications", data.applications || []], ["Command-line tools", data.command_line_tools || []]]) { + if (!items.length) continue; + const row = document.createElement("div"); + row.className = "scan-item media-engine"; + const heading = document.createElement("strong"); + heading.textContent = label; + const names = document.createElement("span"); + names.className = "muted"; + names.textContent = items.slice(0, 24).map((item) => item.name).join(", ") + (items.length > 24 ? ` … and ${items.length - 24} more` : ""); + row.append(heading, names); + box.appendChild(row); + } +} + +function renderFinanceStatus(finance) { + const box = $("finance-status"); + if (!box) return; + const broker = finance.broker || {}; + box.innerHTML = `
${finance.market_data ? "●" : "○"} Market data & portfolio${finance.market_data ? "Ready" : "Dependencies repair on restart"}
` + + `
● Business manager${finance.business_database || "Local encrypted profile"}
` + + `
${broker.configured ? "●" : "○"} Alpaca broker${broker.configured ? `Connected · ${broker.mode}` : "Optional · not connected"}
`; + if ($("alpaca-mode")) $("alpaca-mode").value = broker.paper === false ? "live" : "paper"; + if ($("card-finance-state")) $("card-finance-state").textContent = broker.configured ? `Alpaca · ${broker.mode}` : "Markets ready · broker optional"; +} + +function renderKronosStatus(status) { + if (!status) return; + const ready = Boolean(status.source_ready && status.runtime_ready); + if ($("card-kronos-state")) $("card-kronos-state").textContent = ready ? "Local engine ready" : status.source_ready ? "Runtime pending" : "Not installed"; + const box = $("kronos-status"); + if (!box) return; + box.innerHTML = `
${ready ? "●" : "○"} Local engine${ready ? "Ready" : "Install or repair to continue"}
` + + `
EngineJonathan-native probabilistic forecast
` + + `
SafetyResearch only · no automatic orders
`; +} + +function renderPersonalFinanceStatus(status) { + if (!status) return; + const label = status.running ? "Running locally" : status.runtime_ready ? "Ready to start" : status.source_ready ? "Needs Docker / Podman" : "Not installed"; + if ($("card-personal-finance-state")) $("card-personal-finance-state").textContent = label; + const box = $("personal-finance-status"); + if (!box) return; + box.innerHTML = `
${status.running ? "●" : "○"} Service${label}
` + + `
Database${escapeHtml(status.database || status.path || "Local SQLite")}
` + + `
IsolationEmbedded Jonathan vault · no containers or payment
`; +} + +function renderCodeMemoryStatus(status) { + if (!status) return; + const ready = Boolean(status.runtime_ready); + if ($("card-code-memory-state")) $("card-code-memory-state").textContent = ready ? "Local graph ready" : status.source_ready ? "Build pending" : "Not installed"; + const box = $("code-memory-status"); + if (!box) return; + box.innerHTML = `
${ready ? "●" : "○"} Local engine${ready ? "Ready" : "Install or repair to continue"}
` + + `
Database${escapeHtml(status.database || "Local SQLite")}
` + + `
PrivacyLocal only · no hooks · no payment
`; +} + +function renderProcoderStatus(status) { + if (!status) return; + const ready = Boolean(status.runtime_ready && status.checksum_verified); + if ($("card-procoder-state")) $("card-procoder-state").textContent = ready ? `Verified · v${status.version || "?"}` : status.source_ready ? "Verification pending" : "Not installed"; + const box = $("procoder-status"); + if (!box) return; + box.innerHTML = `
${ready ? "●" : "○"} Controller${ready ? `SHA-256 verified · v${escapeHtml(status.version || "")}` : "Install or verify to continue"}
` + + `
AuthorityBuilt in · reports only · no hooks, edits, commits or tags
`; +} + +function renderDrawAiStatus(status) { + if (!status) return; + const ready = Boolean(status.runtime_ready); + const label = ready ? "Models ready" : status.dependencies_ready ? "Models optional" : status.source_ready ? "Dependencies pending" : "Not installed"; + if ($("card-drawai-state")) $("card-drawai-state").textContent = label; + const box = $("drawai-status"); if (!box) return; + box.innerHTML = `
${ready ? "●" : "○"} Editable graphics${label}
` + + `
OutputsEditable SVG · native-shape PPTX · rendered PNG
`; } function renderMcpList(items) { @@ -493,12 +1235,33 @@ function renderAttachments() { async function addAttachmentPayload(payload) { const data = await api("/api/attachments", { method: "POST", - body: JSON.stringify({ attachments: payload }), + body: JSON.stringify({ attachments: payload, session_id: state.currentSessionId }), }); state.attachments.push(...(data.attachments || [])); renderAttachments(); } +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} + +async function fileAttachmentPayload(file) { + const nativePath = file?.path || (native?.getPathForFile ? native.getPathForFile(file) : ""); + if (nativePath) return { kind: "file", path: nativePath, name: file.name }; + return { + kind: "file", + name: file.name || "upload.bin", + media_type: file.type || "application/octet-stream", + data_base64: arrayBufferToBase64(await file.arrayBuffer()), + }; +} + function renderPalette(query) { const q = (query || "").toLowerCase(); const matches = state.commands.filter((item) => { @@ -524,42 +1287,129 @@ function renderPalette(query) { palette.classList.remove("hidden"); } -function watchJob(jobId) { +function watchJob(jobId, sessionId) { state.currentJob = jobId; + state.activeJobs.set(jobId, { sessionId }); + state.pendingSessions.delete(sessionId); + updateBusyUi(); let assistant = null; - const source = new EventSource(`/api/events?job_id=${encodeURIComponent(jobId)}`); - source.onmessage = (msg) => { - const event = JSON.parse(msg.data); + let accumulated = ""; + let after = 0; + let finalized = false; + let polling = false; + let source = null; + + const finishJob = async () => { + if (finalized) return; + finalized = true; + source?.close(); + state.activeJobs.delete(jobId); + state.pendingSessions.delete(sessionId); + updateBusyUi(); + if (state.currentSessionId === sessionId) { + try { + const snapshot = await api(`/api/instances/peek?session_id=${encodeURIComponent(sessionId)}`); + renderTranscript(snapshot.messages || []); + } catch (err) { + addBubble("system", `Response saved, but the transcript could not refresh: ${err.message}`); + } + } + await Promise.allSettled([refreshSessions(), refreshStatus(), refreshArtifacts()]); + }; + + const pollJob = async () => { + if (polling || finalized) return; + polling = true; + if (state.currentSessionId === sessionId) setProgress("Reconnecting to response…"); + let failures = 0; + while (!finalized) { + try { + const payload = await api(`/api/jobs/events?job_id=${encodeURIComponent(jobId)}&after=${after}`); + failures = 0; + for (const event of payload.events || []) { + handleEvent(event); + } + after = Math.max(after, Number(payload.after) || after); + if (payload.done) { + await finishJob(); + return; + } + } catch (err) { + failures += 1; + if (failures >= 4) { + if (state.currentSessionId === sessionId) { + addBubble("system", `Response connection failed: ${err.message}. Reloading saved conversation…`); + } + await finishJob(); + return; + } + } + await new Promise((resolve) => window.setTimeout(resolve, 650)); + } + }; + + function handleEvent(event) { + const visible = state.currentSessionId === sessionId; if (event.type === "stream_end") { - source.close(); - setBusy(false); - refreshSessions(); - refreshStatus(); + void finishJob(); + return; + } + after += 1; + if (event.type === "job_started") { + if (visible) setProgress("Reading attachments…"); + return; + } + if (event.type === "attachment_scan") { + if (visible) setProgress(event.ocr ? "Reading image text…" : "Recognizing image…"); return; } if (event.type === "user") { return; } if (event.type === "token") { - if (!assistant) assistant = addBubble("assistant", ""); - assistant.dataset.raw = (assistant.dataset.raw || "") + event.text; - assistant.innerHTML = renderMarkdown(assistant.dataset.raw); - assistant.scrollIntoView({ block: "end" }); + if (visible) setProgress("Writing response…"); + accumulated += event.text; + if (visible) { + if (!assistant || !assistant.isConnected) assistant = addBubble("assistant", ""); + assistant.dataset.raw = accumulated; + assistant.innerHTML = renderMarkdown(accumulated); + assistant.scrollIntoView({ block: "end" }); + } return; } if (event.type === "tool_use" || event.type === "tool_result" || event.type === "tool_error") { - addToolCard(event); + const toolName = String(event.tool_name || "").toLowerCase(); + if (event.type === "tool_use" && ["write", "edit", "multiedit", "notebookedit", "bash", "powershell"].some((name) => toolName.includes(name))) { + state.changedSessions.add(sessionId); + } + if (visible && event.type === "tool_use") { + const labels = { + imagestudio: "Editing image…", + visionanalyze: "Analyzing image…", + fooocus: "Generating image…", + read: "Reading file…", + write: "Creating file…", + edit: "Editing file…", + bash: "Running command…", + powershell: "Running PowerShell…", + artifact: "Packaging download…", + }; + setProgress(labels[toolName] || "Working…"); + } + if (visible && event.type === "tool_result") { + const output = event.tool_output && typeof event.tool_output === "object" ? event.tool_output : {}; + const artifacts = Array.isArray(output.artifacts) ? output.artifacts : (output.artifact ? [output.artifact] : []); + if (artifacts.length) addArtifactBubble(artifacts, "Ready to download"); + } + if (visible && state.previewOpen && event.type === "tool_result") refreshPreview().catch(() => {}); return; } if (event.type === "workers_started") { - addBubble("system", "Planning isolated workers. Chats stay separate; they share memory only."); + if (visible) setProgress("Planning agents…"); return; } if (event.type === "worker") { - addToolCard({ - tool_name: event.title || event.worker_id || "worker", - summary: event.answer || event.prompt || "", - }); + if (visible) setProgress("Agents are working…"); return; } if (event.type === "permission_request") { @@ -568,50 +1418,74 @@ function watchJob(jobId) { return; } if (event.type === "done") { - if (Array.isArray(event.messages)) { + if (visible && Array.isArray(event.messages)) { renderTranscript(event.messages); if (event.text) addBubble("system", event.text); - } else if (event.text && !(assistant && assistant.dataset.raw)) { + } else if (visible && event.text && !(assistant && assistant.dataset.raw)) { addBubble(event.kind === "command" ? "system" : "assistant", event.text); } - if (event.session) { + if (visible && event.session) { renderUsage(event.session); refreshStatus(); refreshSessions(); - } else if (event.usage) { + } else if (visible && event.usage) { renderUsage({ token_usage: event.usage }); } if (native?.notify && event.kind !== "command") { native.notify("Jonathan Ai finished", (event.text || "Done").slice(0, 120)); } + if (event.kind !== "command" && state.changedSessions.has(sessionId)) { + state.changedSessions.delete(sessionId); + packageProject(true, sessionId); + } else { + refreshArtifacts().catch(() => {}); + } return; } if (event.type === "error") { - addBubble("system", event.error || "Error"); + if (visible) addBubble("system", event.error || "Error"); if (event.needs_setup) $("setup-modal").classList.remove("hidden"); if (event.provider_limit && $("settings-modal")) { $("settings-modal").classList.remove("hidden"); } } + } + + source = new EventSource(`/api/events?job_id=${encodeURIComponent(jobId)}`); + source.onmessage = (msg) => { + try { + handleEvent(JSON.parse(msg.data)); + } catch (err) { + if (state.currentSessionId === sessionId) addBubble("system", `Response event error: ${err.message}`); + } }; source.onerror = () => { source.close(); - setBusy(false); + void pollJob(); }; } async function sendMessage(path = "/api/chat") { const text = $("prompt").value; if (!text.trim() && !state.attachments.length) return; - addBubble("user", text || "(attachments)"); + const sessionId = state.currentSessionId; + if (!sessionId) return; + const pendingAttachments = [...state.attachments]; + const userBubble = addBubble("user", text || "", pendingAttachments.some((item) => item.is_image) ? "media-bubble" : ""); + for (const item of pendingAttachments) { + appendConversationAttachment(userBubble, item); + } $("prompt").value = ""; $("slash-palette").classList.add("hidden"); - setBusy(true); + state.changedSessions.delete(sessionId); + setBusy(true, sessionId); try { const data = await api(path, { method: "POST", body: JSON.stringify({ text, + session_id: sessionId, + mode: path === "/api/multi-agent" ? ($("worker-mode")?.value || "balanced") : undefined, attachments: state.attachments.map((item) => ({ kind: item.kind, path: item.path, @@ -622,16 +1496,126 @@ async function sendMessage(path = "/api/chat") { }); state.attachments = []; renderAttachments(); - watchJob(data.job_id); + watchJob(data.job_id, data.session_id || sessionId); } catch (err) { - setBusy(false); - addBubble("system", err.message); + setBusy(false, sessionId); + if (state.currentSessionId === sessionId) addBubble("system", err.message); } } function bindUi() { $("send").addEventListener("click", () => sendMessage("/api/chat")); $("workers")?.addEventListener("click", () => sendMessage("/api/multi-agent")); + $("open-search").addEventListener("click", () => { openControl("search-panel"); $("history-query").focus(); }); + $("open-agent-hub").addEventListener("click", async () => { openControl("agent-hub-panel"); clearProfileForm(); await refreshAgentProfiles(); }); + $("open-skills").addEventListener("click", async () => { + $("skills-modal").classList.remove("hidden"); + try { await Promise.all([refreshSkillLibrary(), refreshEccCatalog()]); } catch (err) { $("skill-status").textContent = err.message; } + }); + $("skills-close").addEventListener("click", () => $("skills-modal").classList.add("hidden")); + $("skills-new").addEventListener("click", () => { + state.selectedSkill = null; + $("skill-name").value = "new-skill"; + $("skill-content").value = newSkillSource("new-skill"); + $("skill-status").textContent = "Rename the skill and edit its instructions, then validate and save."; + $("skill-name").focus(); + $("skill-name").select(); + }); + $("skill-name").addEventListener("input", () => { + if (state.selectedSkill) return; + const name = $("skill-name").value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-"); + const current = $("skill-content").value; + $("skill-content").value = current + .replace(/^name: .*$/m, `name: "${name}"`) + .replace(/^# .*$/m, `# ${name}`); + }); + $("skill-save").addEventListener("click", async () => { + $("skill-status").textContent = "Validating and saving…"; + try { + const data = await api("/api/skills/save", { + method: "POST", + body: JSON.stringify({ name: $("skill-name").value, content: $("skill-content").value }), + }); + state.selectedSkill = data.saved?.name || $("skill-name").value; + renderSkillLibrary(data); + $("skill-status").textContent = `Saved and ready for every agent: ${data.saved?.path || "SKILL.md"}`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("skill-archive").addEventListener("click", async () => { + const name = state.selectedSkill || $("skill-name").value.trim(); + if (!name || !window.confirm(`Archive ${name}? It remains recoverable in the library's .archive folder.`)) return; + try { + const data = await api("/api/skills/archive", { method: "POST", body: JSON.stringify({ name }) }); + state.selectedSkill = null; + renderSkillLibrary(data); + $("skill-status").textContent = `Archived ${name}; the file remains recoverable.`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("skills-open-folder").addEventListener("click", async () => { + try { + const data = await api("/api/skills/open", { method: "POST", body: "{}" }); + $("skill-status").textContent = `Opened ${data.path}`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("skill-learn-project").addEventListener("click", async () => { + $("skill-status").textContent = "Analyzing sanitized local Git history…"; + try { + const draft = await api("/api/integrations/ecc", { method: "POST", body: JSON.stringify({ action: "analyze_git", commits: 200 }) }); + state.selectedSkill = null; + $("skill-name").value = draft.name; + $("skill-content").value = draft.content; + $("skill-status").textContent = `Drafted from ${draft.analyzed_commits} commits at confidence ${draft.confidence}. Review it, then Validate & save to activate.`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("ecc-filter").addEventListener("input", () => renderEccCatalog(state.eccCatalog || {})); + $("ecc-sync").addEventListener("click", async () => { + $("skill-status").textContent = "Synchronizing the ECC catalog…"; + try { + const result = await api("/api/integrations/ecc", { method: "POST", body: JSON.stringify({ action: "sync" }) }); + renderEccCatalog(result.catalog || result); + $("skill-status").textContent = `ECC synchronized at ${String(result.revision || "").slice(0, 12)}.`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("ecc-scan").addEventListener("click", async () => { + $("skill-status").textContent = "Scanning ECC prompts, scripts, hooks, MCP files, and credentials…"; + try { + const result = await api("/api/integrations/ecc", { method: "POST", body: JSON.stringify({ action: "scan" }) }); + $("skill-status").textContent = `Scanned ${result.files_scanned} files: ${result.critical} critical and ${result.high} high findings. Import checks only content that can be activated.`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("ecc-import-all").addEventListener("click", async () => { + if (!window.confirm("Import the complete ECC skills and agent-template catalog? Only the curated skill set will be enabled in agent context.")) return; + $("skill-status").textContent = "Auditing and importing the complete ECC catalog…"; + try { + const result = await api("/api/integrations/ecc", { method: "POST", body: JSON.stringify({ action: "import" }) }); + renderEccCatalog(result.catalog || result); + $("skill-status").textContent = `Imported ${result.imported_skills.length} skills and ${result.imported_agents.length} agent templates; ${result.enabled_skills.length} curated skills are enabled.`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("ecc-memory-export").addEventListener("click", async () => { + try { + const result = await api("/api/integrations/ecc", { method: "POST", body: JSON.stringify({ action: "memory_export" }) }); + $("skill-status").textContent = `Exported ${result.count} unreviewed memory-vault documents to ${result.path}.`; + } catch (err) { $("skill-status").textContent = err.message; } + }); + $("open-doctor").addEventListener("click", async () => { openControl("doctor-panel"); await showDoctor(false); }); + $("control-close").addEventListener("click", () => $("control-modal").classList.add("hidden")); + $("history-search").addEventListener("click", runHistorySearch); + $("history-query").addEventListener("keydown", (event) => { if (event.key === "Enter") runHistorySearch().catch((err) => $("history-meta").textContent = err.message); }); + $("profile-clear").addEventListener("click", clearProfileForm); + $("profile-save").addEventListener("click", async () => { + await api("/api/agent-profiles", { method: "POST", body: JSON.stringify({ id: $("profile-id").value, name: $("profile-name").value, workspace: $("profile-workspace").value, instructions: $("profile-instructions").value }) }); clearProfileForm(); await refreshAgentProfiles(); + }); + $("doctor-run").addEventListener("click", () => showDoctor(false).catch((err) => $("doctor-summary").textContent = err.message)); + $("doctor-repair").addEventListener("click", () => showDoctor(true).catch((err) => $("doctor-summary").textContent = err.message)); + $("undo-turn").addEventListener("click", async () => { + try { const result = await api("/api/sessions/undo", { method: "POST", body: JSON.stringify({ session_id: state.currentSessionId }) }); renderTranscript(result.messages || []); await refreshStatus(); await refreshSessions(); } + catch (err) { addBubble("system", err.message); } + }); + $("retry-turn").addEventListener("click", async () => { + try { const result = await api("/api/sessions/retry", { method: "POST", body: JSON.stringify({ session_id: state.currentSessionId }) }); renderTranscript((await api("/api/sessions/messages")).messages || []); watchJob(result.job_id, result.session_id); } + catch (err) { addBubble("system", err.message); } + }); $("prompt").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); @@ -657,6 +1641,20 @@ function bindUi() { hideSessionMenu(); if (session) startInlineRename(session); }); + $("session-menu-remove").addEventListener("click", async () => { + const sessionId = state.menuSessionId; + const session = state.sessions.find((item) => item.session_id === sessionId); + hideSessionMenu(); + if (!sessionId) return; + if (!window.confirm(`Remove “${session?.title || "this conversation"}” from the sidebar? Its full history stays in persistent memory.`)) return; + const result = await api("/api/sessions/remove", { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); + if (Array.isArray(result.messages)) renderTranscript(result.messages); + await refreshStatus(); + await refreshSessions(); + }); document.addEventListener("click", (event) => { if (!$("session-menu")?.contains(event.target)) hideSessionMenu(); }); @@ -675,7 +1673,21 @@ function bindUi() { if (!path) return; await api("/api/workspace", { method: "POST", body: JSON.stringify({ path }) }); await refreshStatus(); + if (state.previewOpen) await refreshPreview(); + }); + $("pick-projects").addEventListener("click", async () => { + let path = ""; + if (native?.pickFolder) path = await native.pickFolder(); + else path = window.prompt("Project and download folder", state.status?.projects_dir || "") || ""; + if (!path) return; + await api("/api/projects-dir", { method: "POST", body: JSON.stringify({ path }) }); + await refreshStatus(); + if (state.previewOpen) await refreshArtifacts(); }); + $("toggle-preview").addEventListener("click", () => setPreviewOpen(!state.previewOpen)); + $("close-preview").addEventListener("click", () => setPreviewOpen(false)); + $("refresh-preview").addEventListener("click", refreshPreview); + $("package-project").addEventListener("click", () => packageProject(false)); $("attach-files").addEventListener("click", async () => { if (native?.pickFiles) { const paths = await native.pickFiles(); @@ -686,11 +1698,7 @@ function bindUi() { }); $("file-input").addEventListener("change", async (event) => { const files = [...event.target.files]; - const payload = []; - for (const file of files) { - if (file.path) payload.push({ kind: "file", path: file.path }); - else payload.push({ kind: "text", name: file.name, text: await file.text() }); - } + const payload = await Promise.all(files.map(fileAttachmentPayload)); if (payload.length) await addAttachmentPayload(payload); event.target.value = ""; }); @@ -820,18 +1828,34 @@ function bindUi() { const wireLocal = (prefix) => { const box = $(`${prefix}-local-results`); $(`${prefix}-local-scan`).addEventListener("click", async () => { - box.textContent = "Scanning loopback ports…"; + box.textContent = "Scanning installed runtimes, model folders, and loopback ports…"; try { const extra = $(`${prefix}-url`).value; const data = await api("/api/connectors/local/scan", { method: "POST", - body: JSON.stringify({ base_url: extra || "" }), + body: JSON.stringify({ base_url: extra || "", auto_start: true }), }); renderScanResults(box, data.endpoints, (item) => { $(`${prefix}-url`).value = item.base_url; fillModelList(`${prefix}-model-list`, item.models || []); if (item.models?.[0]) $(`${prefix}-model`).value = item.models[0]; }); + for (const runtime of data.runtimes || []) { + const row = document.createElement("div"); + row.className = "scan-item"; + const modelCount = (runtime.models || []).filter((item) => item.type !== "embedding").length || (runtime.model_files || []).length; + const resources = runtime.resources || {}; + const details = [`${modelCount} local model(s)`, runtime.executable || "model cache"]; + if (resources.model_storage_bytes) details.push(`${formatBytes(resources.model_storage_bytes)} stored`); + if (resources.resident_memory_bytes) details.push(`${formatBytes(resources.resident_memory_bytes)} live RAM`); + if (resources.system_available_memory_bytes) details.push(`${formatBytes(resources.system_available_memory_bytes)} RAM free`); + if (resources.fit && resources.fit !== "unknown") details.push(`${resources.fit} estimated fit`); + row.innerHTML = ``; + row.querySelector("strong").textContent = `${runtime.label} installed`; + row.querySelector("small").textContent = details.join(" · "); + box.appendChild(row); + } + if (data.connected) box.insertAdjacentHTML("afterbegin", '
Connected automaticallyThe reachable installed model is now Jonathan’s active provider.
'); } catch (err) { box.textContent = err.message; } @@ -860,9 +1884,315 @@ function bindUi() { $("open-connectors").addEventListener("click", async () => { $("connectors-modal").classList.remove("hidden"); + closeConnectorPanel(); await refreshStatus(); }); $("connectors-close").addEventListener("click", () => $("connectors-modal").classList.add("hidden")); + $("connector-detail-close").addEventListener("click", closeConnectorPanel); + document.querySelectorAll(".connector-card[data-panel]").forEach((card) => { + card.addEventListener("click", () => openConnectorPanel(card.dataset.panel)); + }); + $("device-access-toggle").addEventListener("click", async () => { + const enabled = Boolean(state.status?.device_access?.enabled); + if (enabled) { + if (!window.confirm("Revoke Full Device & Network Access? Agents will return to workspace-only access and per-action approvals.")) return; + try { + const access = await api("/api/device-access", { method: "POST", body: JSON.stringify({ enabled: false }) }); + state.status.device_access = access; + renderDeviceAccess(access); + $("device-access-message").textContent = "Full access revoked. Workspace boundaries and approval prompts are active."; + } catch (err) { $("device-access-message").textContent = err.message; } + return; + } + const confirmation = window.prompt("This grants every Jonathan agent persistent access to files, applications, browsers, shells, CLIs, and network tools available to your Windows account. Type ENABLE FULL ACCESS to continue.", ""); + if (confirmation === null) return; + try { + const access = await api("/api/device-access", { method: "POST", body: JSON.stringify({ enabled: true, confirmation }) }); + state.status.device_access = access; + renderDeviceAccess(access); + $("device-access-message").textContent = "Full access enabled and audit logged. Windows UAC still controls administrator-only actions."; + } catch (err) { $("device-access-message").textContent = err.message; } + }); + $("device-inventory-scan").addEventListener("click", async () => { + $("device-access-message").textContent = "Scanning registered applications, browsers, and PATH tools…"; + try { + const data = await api("/api/device/inventory?limit=5000"); + renderDeviceInventory(data); + $("device-access-message").textContent = "Installed-tool inventory is ready."; + } catch (err) { $("device-access-message").textContent = err.message; } + }); + const runFooocusAction = async (action, message, extra = {}) => { + const node = $("fooocus-message"); + node.textContent = message; + try { + const data = await api("/api/fooocus/action", { + method: "POST", + body: JSON.stringify({ action, session_id: state.currentSessionId, ...extra }), + }); + renderFooocusStatus(data); + if ((action === "publish_latest" || action === "generate") && data.artifact) { + node.innerHTML = ""; + const link = document.createElement("a"); + link.href = data.artifact.download_url; + link.textContent = `Download ${data.artifact.name}`; + link.setAttribute("download", data.artifact.name); + node.appendChild(link); + addArtifactBubble(data.artifacts || data.artifact, "Generated with Jonathan local diffusion"); + await refreshArtifacts(); + } else { + node.textContent = data.dependencies_ready ? "Jonathan's local image engine is ready." : "Done."; + } + state.status.fooocus = data; + } catch (err) { + node.textContent = err.message; + } + }; + $("fooocus-install").addEventListener("click", () => runFooocusAction("install", "Installing Jonathan's local image dependencies…")); + $("fooocus-start").addEventListener("click", () => runFooocusAction("start", "Verifying Jonathan's local image engine…")); + $("fooocus-generate").addEventListener("click", () => runFooocusAction("generate", "Generating locally; first model download and CPU generation can take several minutes…", { + prompt: $("fooocus-prompt").value, + width: Number($("fooocus-width").value || 1024), + height: Number($("fooocus-height").value || 1024), + performance: $("fooocus-performance").value, + })); + $("fooocus-stop").addEventListener("click", () => runFooocusAction("stop", "Releasing local image state…")); + $("fooocus-latest").addEventListener("click", () => runFooocusAction("publish_latest", "Publishing the newest local output…")); + $("fooocus-open").addEventListener("click", () => runFooocusAction("outputs", "Refreshing local outputs…")); + $("connector-open-settings").addEventListener("click", () => { + $("connectors-modal").classList.add("hidden"); + $("settings-modal").classList.remove("hidden"); + }); + $("image-provider-save").addEventListener("click", async () => { + try { + const data = await api("/api/media/image-provider", { + method: "POST", + body: JSON.stringify({ api_key: $("image-api-key").value, base_url: $("image-api-url").value, model: $("image-api-model").value }), + }); + $("image-api-key").value = ""; + $("image-provider-status").textContent = "Image generation and prompt editing are connected."; + renderMediaStatus(data); + } catch (err) { + $("image-provider-status").textContent = err.message; + } + }); + $("image-use-attachment").addEventListener("click", () => { + const image = [...state.attachments].reverse().find((item) => item.is_image || item.kind === "image" || item.kind === "screenshot"); + $("image-source").value = image?.path || ""; + $("image-provider-status").textContent = image ? `Using ${image.name}` : "Attach an image to the conversation first."; + }); + $("image-run").addEventListener("click", async () => { + try { + let regions; + if ($("image-regions").value.trim()) regions = JSON.parse($("image-regions").value); + const data = await api("/api/media/image", { + method: "POST", + body: JSON.stringify({ + session_id: state.currentSessionId, + action: $("image-action").value, + engine: $("image-engine").value, + source: $("image-source").value, + overlay: $("image-overlay").value, + prompt: $("image-prompt").value, + endpoint: $("image-api-url").value, + model: $("image-api-model").value, + text: $("image-text").value, + output: $("image-output").value, + width: Number($("image-width").value || 1024), + height: Number($("image-height").value || 1024), + x: Number($("image-x").value || 0), + y: Number($("image-y").value || 0), + angle: Number($("image-angle").value || 0), + direction: $("image-direction").value, + regions, + }), + }); + showDirectImageResult(data); + addArtifactBubble(data.artifacts || data.artifact, `${data.action || "Image"} complete`); + refreshArtifacts().catch(() => {}); + } catch (err) { + $("image-result").innerHTML = `
${String(err.message || err)}
`; + } + }); + $("external-save").addEventListener("click", async () => { + try { + const auth = $("external-auth").value; + const token = $("external-token").value; + const connectors = await api("/api/connectors/external", { + method: "POST", + body: JSON.stringify({ + id: state.selectedExternalId, + name: $("external-name").value, + base_url: $("external-url").value, + auth_type: auth, + username: $("external-username").value, + password: $("external-password").value, + api_key: auth === "api_key" ? token : "", + api_key_header: $("external-header").value, + bearer_token: auth === "bearer" ? token : "", + authorization_url: $("external-auth-url").value, + token_url: $("external-token-url").value, + client_id: $("external-client-id").value, + client_secret: $("external-client-secret").value, + scopes: $("external-scopes").value, + }), + }); + $("external-password").value = ""; $("external-token").value = ""; $("external-client-secret").value = ""; + const saved = (connectors.services || []).find((item) => item.name === $("external-name").value); + state.selectedExternalId = saved?.id || state.selectedExternalId; + state.status.connectors = connectors; + renderConnectorStatus(connectors); + $("external-status").textContent = "Connection saved locally."; + } catch (err) { $("external-status").textContent = err.message; } + }); + $("external-auth").addEventListener("change", updateExternalAuthFields); + updateExternalAuthFields(); + $("external-test").addEventListener("click", async () => { + if (!state.selectedExternalId) { $("external-status").textContent = "Save or select a service first."; return; } + try { + const data = await api("/api/connectors/external/test", { method: "POST", body: JSON.stringify({ id: state.selectedExternalId }) }); + $("external-status").textContent = data.reachable ? `Connected · HTTP ${data.status}` : `Connection failed · HTTP ${data.status || "error"}`; + } catch (err) { $("external-status").textContent = err.message; } + }); + $("external-oauth-start").addEventListener("click", async () => { + if (!state.selectedExternalId) { $("external-status").textContent = "Save or select the OAuth service first."; return; } + try { + const data = await api("/api/connectors/external/oauth/start", { method: "POST", body: JSON.stringify({ id: state.selectedExternalId }) }); + state.externalOauth = { id: state.selectedExternalId, state: data.state }; + window.open(data.authorization_url, "_blank", "noopener"); + $("external-status").textContent = "Authorization opened. Sign in, then paste the returned code below."; + } catch (err) { $("external-status").textContent = err.message; } + }); + $("external-oauth-finish").addEventListener("click", async () => { + try { + const data = await api("/api/connectors/external/oauth/exchange", { method: "POST", body: JSON.stringify({ id: state.selectedExternalId, code: $("external-code").value, state: state.externalOauth?.state || "" }) }); + $("external-code").value = ""; state.externalOauth = null; + state.status.connectors = data.connectors; renderConnectorStatus(data.connectors); + $("external-status").textContent = "OAuth connection complete."; + } catch (err) { $("external-status").textContent = err.message; } + }); + $("external-remove").addEventListener("click", async () => { + if (!state.selectedExternalId || !window.confirm("Remove this external connection?")) return; + try { + const connectors = await api("/api/connectors/external/remove", { method: "POST", body: JSON.stringify({ id: state.selectedExternalId }) }); + state.selectedExternalId = null; state.status.connectors = connectors; renderConnectorStatus(connectors); + $("external-status").textContent = "Connection removed."; + } catch (err) { $("external-status").textContent = err.message; } + }); + $("alpaca-save").addEventListener("click", async () => { + const live = $("alpaca-mode").value === "live"; + if (live && !window.confirm("Connect LIVE trading with real money? Every order will still require a separate approval.")) return; + try { + const data = await api("/api/finance/alpaca", { + method: "POST", + body: JSON.stringify({ api_key: $("alpaca-key").value, secret_key: $("alpaca-secret").value, paper: !live }), + }); + $("alpaca-key").value = ""; + $("alpaca-secret").value = ""; + $("alpaca-message").textContent = `Saved locally · ${data.broker?.mode || "paper"} mode`; + renderFinanceStatus(data); + } catch (err) { + $("alpaca-message").textContent = err.message; + } + }); + $("kronos-install").addEventListener("click", async () => { + $("kronos-message").textContent = "Installing Kronos locally; the selected model downloads only when first used…"; + try { + const data = await api("/api/integrations/kronos", { method: "POST", body: JSON.stringify({ action: "install" }) }); + renderKronosStatus(data); + $("kronos-message").textContent = "Kronos is ready for local research forecasts."; + } catch (err) { + $("kronos-message").textContent = err.message; + } + }); + $("kronos-run").addEventListener("click", async () => { + if (!$("kronos-input").value.trim()) { $("kronos-message").textContent = "Choose a timestamped OHLCV CSV first."; return; } + $("kronos-message").textContent = "Running the local market forecast; first use downloads the selected model…"; + try { + const data = await api("/api/integrations/kronos", { method: "POST", body: JSON.stringify({ + action: "forecast", input_csv: $("kronos-input").value.trim(), output_csv: $("kronos-output").value.trim(), + model: $("kronos-model").value, pred_len: Number($("kronos-pred-len").value || 24), + lookback: Number($("kronos-lookback").value || 400), + }) }); + addArtifactBubble(data.artifacts || data.artifact, "Kronos research forecast"); + $("kronos-message").textContent = `Forecast complete · ${data.rows} rows · ${data.device} · research only.`; + await refreshStatus(); + } catch (err) { + $("kronos-message").textContent = err.message; + } + }); + const runPersonalFinanceAction = async (action, message) => { + $("personal-finance-message").textContent = message; + try { + const data = await api("/api/integrations/personal-finance", { method: "POST", body: JSON.stringify({ action }) }); + state.status.personal_finance = data; + renderPersonalFinanceStatus(data); + $("personal-finance-message").textContent = data.restart_required ? "Container runtime installed; restart Windows or Docker Desktop, then press Start." : data.running ? "Personal finance vault is running locally." : "Done."; + return data; + } catch (err) { + $("personal-finance-message").textContent = err.message; + return null; + } + }; + $("personal-finance-install").addEventListener("click", () => runPersonalFinanceAction("install", "Preparing the isolated finance source and container runtime…")); + $("personal-finance-start").addEventListener("click", () => runPersonalFinanceAction("start", "Building and starting the isolated finance services…")); + $("personal-finance-stop").addEventListener("click", () => runPersonalFinanceAction("stop", "Stopping the finance services while preserving their volumes…")); + $("personal-finance-open").addEventListener("click", async () => { + let status = state.status.personal_finance || {}; + if (!status.running) status = await runPersonalFinanceAction("start", "Starting the isolated finance services…") || status; + if (!status.running) return; + if (!status.url) { $("personal-finance-message").textContent = "The native vault is available to Jonathan through the PersonalFinanceVault tool; it has no separate web service."; return; } + await setPreviewOpen(true); + $("preview-path").textContent = status.url || "http://127.0.0.1:3132/"; + $("preview-frame").src = status.url || "http://127.0.0.1:3132/"; + $("preview-frame").classList.remove("hidden"); + $("preview-empty").classList.add("hidden"); + }); + const runCodeMemory = async (payload, message) => { + $("code-memory-message").textContent = message; + try { + const data = await api("/api/integrations/code-memory", { method: "POST", body: JSON.stringify(payload) }); + state.status.code_memory = data; + renderCodeMemoryStatus(data); + $("code-memory-result").textContent = data.output || "Complete."; + $("code-memory-message").textContent = "Complete. Data remains in Jonathan’s local code-memory database."; + return data; + } catch (err) { + $("code-memory-message").textContent = err.message; + return null; + } + }; + $("code-memory-install").addEventListener("click", () => runCodeMemory({ action: "install" }, "Installing and building the isolated local engine…")); + $("code-memory-scan").addEventListener("click", () => runCodeMemory({ action: "scan" }, "Incrementally mapping this workspace…")); + $("code-memory-run").addEventListener("click", () => { + const action = $("code-memory-action").value; + const query = $("code-memory-query").value.trim(); + if (!query) { $("code-memory-message").textContent = "Enter a query, symbol, or note first."; return; } + runCodeMemory({ action, query, target: $("code-memory-target").value.trim(), limit: Number($("code-memory-limit").value || 100) }, "Running the local code-memory query…"); + }); + const runProcoder = async (payload, message) => { + $("procoder-message").textContent = message; + try { + const data = await api("/api/integrations/procoder", { method: "POST", body: JSON.stringify(payload) }); + state.status.procoder = data; renderProcoderStatus(data); + $("procoder-result").textContent = data.output || (data.runtime_ready ? "Ready." : "No report output."); + $("procoder-message").textContent = data.ok === false ? `Report found blocking or unchecked work (exit ${data.exit_code}).` : "Report completed."; + } catch (err) { $("procoder-message").textContent = err.message; } + }; + $("procoder-install").addEventListener("click", () => runProcoder({ action: "install" }, "Verifying Jonathan's built-in engineering controller…")); + $("procoder-run").addEventListener("click", () => runProcoder({ action: $("procoder-action").value, deep: $("procoder-deep").checked }, "Running the selected engineering report…")); + $("drawai-use-attachment").addEventListener("click", () => { const image = [...state.attachments].reverse().find((item) => item.is_image || item.kind === "image" || item.kind === "screenshot"); $("drawai-input").value = image?.path || ""; $("drawai-message").textContent = image ? `Using ${image.name}` : "Upload an image in the conversation first."; }); + const runDrawAi = async (payload, message) => { + $("drawai-message").textContent = message; + try { + const data = await api("/api/integrations/drawai", { method: "POST", body: JSON.stringify(payload) }); + state.status.drawai = data; renderDrawAiStatus(data); + if (data.artifacts) addArtifactBubble(data.artifacts, "Editable graphics outputs"); + $("drawai-message").textContent = data.artifacts ? `Complete · ${data.artifacts.length} downloadable files.` : "DrawAI is ready."; + } catch (err) { $("drawai-message").textContent = err.message; } + }; + $("drawai-install").addEventListener("click", () => runDrawAi({ action: "install", device: $("drawai-device").value }, "Verifying Jonathan's built-in editable-graphics dependencies…")); + $("drawai-run").addEventListener("click", () => { const image = $("drawai-input").value.trim(); if (!image) { $("drawai-message").textContent = "Select an uploaded image first."; return; } runDrawAi({ action: "convert", image, output_dir: $("drawai-output").value.trim(), device: $("drawai-device").value }, "Reconstructing editable SVG and PPTX artifacts…"); }); + $("character-generate").addEventListener("click", async () => { $("character-message").textContent="Drawing the deterministic SVG rig…"; try { const data=await api("/api/media/character",{method:"POST",body:JSON.stringify({name:$("character-name").value,seed:$("character-seed").value,species:$("character-species").value,medium:$("character-medium").value})}); addArtifactBubble(data.artifacts,"Procedural character assets"); $("character-message").textContent=`Created ${data.name} · ${data.species}.`; } catch(err){ $("character-message").textContent=err.message; } }); $("open-repo").addEventListener("click", async () => { try { await api("/api/git/open", { method: "POST", body: "{}" }); @@ -1056,11 +2386,7 @@ function bindUi() { event.preventDefault(); $("drop-hint").classList.add("hidden"); const files = [...event.dataTransfer.files]; - const payload = []; - for (const file of files) { - if (file.path) payload.push({ kind: "file", path: file.path }); - else payload.push({ kind: "text", name: file.name, text: await file.text() }); - } + const payload = await Promise.all(files.map(fileAttachmentPayload)); if (payload.length) await addAttachmentPayload(payload); }); } @@ -1084,6 +2410,44 @@ async function boot() { } catch (_err) { renderUpdate({ error: "offline" }); } + let voiceRecorder = null; + let voiceStream = null; + let voiceChunks = []; + $("voice-record").addEventListener("click", async () => { + const button = $("voice-record"); + if (voiceRecorder?.state === "recording") { + voiceRecorder.stop(); + return; + } + try { + voiceStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + voiceChunks = []; + const preferred = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : ""; + voiceRecorder = new MediaRecorder(voiceStream, preferred ? { mimeType: preferred } : undefined); + voiceRecorder.addEventListener("dataavailable", (event) => { if (event.data?.size) voiceChunks.push(event.data); }); + voiceRecorder.addEventListener("stop", async () => { + button.textContent = "Voice"; + button.classList.remove("primary"); + voiceStream?.getTracks().forEach((track) => track.stop()); + const type = voiceRecorder.mimeType || "audio/webm"; + const file = new File(voiceChunks, `voice-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`, { type }); + if (file.size) { + await addAttachmentPayload([await fileAttachmentPayload(file)]); + if (!$("prompt").value.trim()) $("prompt").value = "Transcribe this voice recording and follow the request."; + } + voiceChunks = []; + }, { once: true }); + voiceRecorder.start(); + button.textContent = "Stop voice"; + button.classList.add("primary"); + } catch (err) { + addBubble("system", `Microphone unavailable: ${err.message}`); + } + }); + window.setInterval(() => { + if (state.renamingId) refreshInstances().catch(() => {}); + else refreshSessions().catch(() => {}); + }, 1800); } boot().catch((err) => addBubble("system", err.message)); diff --git a/src/desktop/web/index.html b/src/desktop/web/index.html index 6367b1b..46e0db3 100644 --- a/src/desktop/web/index.html +++ b/src/desktop/web/index.html @@ -15,7 +15,7 @@ Jonathan Ai robot
Jonathan Ai - v0.2.8 · standalone + v0.4.8 · standalone
@@ -32,14 +32,24 @@

+
+
Project downloads
+
+ +
Conversations
- + @@ -54,6 +64,10 @@

New chat

Checking updates… + + + +
@@ -82,13 +96,38 @@

New chat

+ - +
+ + +
Agent instances
+ +
+ +