diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02ba992 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Development Commands + +```bash +# Development (hot-reload GUI + Go rebuild) +wails3 dev + +# Production build (preferred) +core build + +# Frontend-only development +cd frontend && npm install && npm run dev + +# Go tests +core go test # All tests +core go test --run TestName # Single test +core go cov # Coverage report +core go cov --open # Coverage in browser + +# Quality assurance +core go qa # Format + vet + lint + test +core go qa full # + race detector, vuln scan, security audit +core go fmt # Format only +core go lint # Lint only + +# Frontend tests +cd frontend && npm run test +``` + +## Architecture + +**Dual-mode application**: a single Go binary that operates as either a GUI desktop app (Wails 3 + Angular) or a headless CI/CD daemon, determined at startup by the `--headless` flag or display availability. + +### GUI Mode (default) +`main()` → Wails application with embedded Angular frontend, system tray (macOS: accessory app, no Dock icon), and an MCP HTTP server on **port 9877**. The `MCPBridge` service orchestrates `WebviewService` (window/DOM automation), `BrainService` (vector knowledge store via OpenBrain), and a WebSocket hub. + +### Headless Mode (`--headless` or no display) +`startHeadless()` → Daemon with PID file at `~/.core/core-ide.pid`, health check on **port 9878**, and a minimal MCP server on **port 9877**. Polls Forgejo repos (configured via `CORE_REPOS` env var) every 60 seconds and runs jobs through an ordered handler pipeline: PublishDraft → SendFix → DismissReviews → EnableAutoMerge → TickParent → Dispatch. + +### Key Services +- **`MCPBridge`** (`mcp_bridge.go`) — Central GUI service. Registers 29 webview tools + 4 brain tools via HTTP MCP protocol. Routes: `/health`, `/mcp`, `/mcp/tools`, `/mcp/call`, `/ws`. +- **`WebviewService`** (`webview_svc.go`) — Wails v3 window management and DOM interaction. Many tools are stubbed pending CDP integration. +- **`BrainService`** (`brain_mcp.go`) — Wraps `lifecycle.Client` for OpenBrain vector store (remember/recall/forget). Shared by both modes. +- **`ClaudeBridge`** (`claude_bridge.go`) — WebSocket relay to upstream MCP at `ws://localhost:9876/ws`. Currently disabled. + +### Frontend +Angular 20+ app embedded via `//go:embed`. Two routes: `/tray` (system tray panel, 380x480 frameless) and `/ide` (full IDE layout). Wails bindings auto-generated in `frontend/bindings/`. + +## Environment Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `CORE_REPOS` | `host-uk/core,host-uk/core-php,host-uk/core-tenant,host-uk/core-admin` | Repos to poll in headless mode | +| `CORE_API_URL` | `http://localhost:8000` | OpenBrain API endpoint | +| `CORE_API_TOKEN` | (required) | OpenBrain auth token | + +## Workspace Dependencies + +This module uses a Go workspace (`~/Code/go.work`) with `replace` directives for sibling modules. Ensure these exist alongside `ide/`: +- `../go` → `forge.lthn.ai/core/go` +- `../go-process` → `forge.lthn.ai/core/go-process` +- `../gui` → `forge.lthn.ai/core/gui` + +## Conventions + +- **UK English** in documentation and user-facing strings (colour, organisation, centre). +- **Conventional commits**: `type(scope): description` with co-author line `Co-Authored-By: Virgil `. +- **Licence**: EUPL-1.2. +- All Go code is in `package main` (single-package application). +- New Wails services: create struct in a Go file, register in `main.go`'s `Services` slice, then `wails3 dev` to regenerate TS bindings. +- New MCP tools: add metadata to `handleMCPTools` and a case to `executeWebviewTool` in `mcp_bridge.go`. diff --git a/brain_mcp.go b/brain_mcp.go new file mode 100644 index 0000000..0bee4c6 --- /dev/null +++ b/brain_mcp.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "os" + + "forge.lthn.ai/core/agent/pkg/lifecycle" +) + +// BrainService wraps the lifecycle.Client for OpenBrain vector knowledge store access. +// Used by both headless and GUI MCP servers. +type BrainService struct { + client *lifecycle.Client +} + +// NewBrainService creates a BrainService from environment variables. +// CORE_API_URL defaults to http://localhost:8000 +// CORE_API_TOKEN must be set for authentication. +func NewBrainService() *BrainService { + baseURL := os.Getenv("CORE_API_URL") + if baseURL == "" { + baseURL = "http://localhost:8000" + } + token := os.Getenv("CORE_API_TOKEN") + return &BrainService{ + client: lifecycle.NewClient(baseURL, token), + } +} + +// Remember stores a memory in OpenBrain. +func (b *BrainService) Remember(ctx context.Context, content, memType, project, agentID string, tags []string) (*lifecycle.RememberResponse, error) { + return b.client.Remember(ctx, lifecycle.RememberRequest{ + Content: content, + Type: memType, + Project: project, + AgentID: agentID, + Tags: tags, + }) +} + +// Recall performs semantic search in OpenBrain. +func (b *BrainService) Recall(ctx context.Context, query string, topK int, project, memType, agentID string) (*lifecycle.RecallResponse, error) { + return b.client.Recall(ctx, lifecycle.RecallRequest{ + Query: query, + TopK: topK, + Project: project, + Type: memType, + AgentID: agentID, + }) +} + +// Forget removes a memory by ID. +func (b *BrainService) Forget(ctx context.Context, id string) error { + return b.client.Forget(ctx, id) +} + +// EnsureCollection ensures the Qdrant collection exists. +func (b *BrainService) EnsureCollection(ctx context.Context) error { + return b.client.EnsureCollection(ctx) +} + +// executeBrainTool handles brain MCP tool calls. Shared by headless and GUI servers. +func executeBrainTool(brain *BrainService, tool string, params map[string]any) map[string]any { + if brain == nil { + return map[string]any{"error": "brain service not configured"} + } + + ctx := context.Background() + + switch tool { + case "brain_remember": + content := getStringParam(params, "content") + memType := getStringParam(params, "type") + if memType == "" { + memType = "fact" + } + project := getStringParam(params, "project") + agentID := getStringParam(params, "agent_id") + var tags []string + if rawTags, ok := params["tags"].([]any); ok { + for _, t := range rawTags { + if s, ok := t.(string); ok { + tags = append(tags, s) + } + } + } + resp, err := brain.Remember(ctx, content, memType, project, agentID, tags) + if err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"id": resp.ID, "type": resp.Type, "project": resp.Project, "created_at": resp.CreatedAt} + + case "brain_recall": + query := getStringParam(params, "query") + topK := getIntParam(params, "top_k") + if topK == 0 { + topK = 5 + } + project := getStringParam(params, "project") + memType := getStringParam(params, "type") + agentID := getStringParam(params, "agent_id") + resp, err := brain.Recall(ctx, query, topK, project, memType, agentID) + if err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"memories": resp.Memories, "scores": resp.Scores} + + case "brain_forget": + id := getStringParam(params, "id") + err := brain.Forget(ctx, id) + if err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"success": true} + + case "brain_ensure_collection": + err := brain.EnsureCollection(ctx) + if err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"success": true} + + default: + return map[string]any{"error": "unknown brain tool", "tool": tool} + } +} + +// brainToolsList returns the tool definitions for brain MCP tools. +func brainToolsList() []map[string]string { + return []map[string]string{ + {"name": "brain_remember", "description": "Store a memory in OpenBrain (content, type, project, agent_id, tags)"}, + {"name": "brain_recall", "description": "Semantic search in OpenBrain (query, top_k, project, type, agent_id)"}, + {"name": "brain_forget", "description": "Remove a memory by ID"}, + {"name": "brain_ensure_collection", "description": "Ensure the Qdrant vector collection exists"}, + } +} diff --git a/go.mod b/go.mod index 6b1c993..4e5a900 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,36 @@ module forge.lthn.ai/core/ide go 1.26.0 require ( - forge.lthn.ai/core/go v0.0.0 - forge.lthn.ai/core/go-process v0.0.0 - forge.lthn.ai/core/gui v0.0.0 + forge.lthn.ai/core/agent v0.1.3 + forge.lthn.ai/core/go-config v0.1.2 + forge.lthn.ai/core/go-process v0.1.2 + forge.lthn.ai/core/go-scm v0.1.7 + forge.lthn.ai/core/go-ws v0.1.3 github.com/gorilla/websocket v1.5.3 - github.com/wailsapp/wails/v3 v3.0.0-alpha.64 + github.com/wailsapp/wails/v3 v3.0.0-alpha.74 ) require ( + codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect dario.cat/mergo v1.0.2 // indirect + forge.lthn.ai/core/go v0.2.2 // indirect + forge.lthn.ai/core/go-io v0.0.5 // indirect + forge.lthn.ai/core/go-log v0.0.1 // indirect + github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.7.0 // indirect github.com/go-git/go-git/v5 v5.16.4 // indirect @@ -30,6 +41,7 @@ require ( github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect @@ -42,6 +54,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/samber/lo v1.52.0 // indirect @@ -54,6 +67,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect @@ -62,9 +76,3 @@ require ( gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace ( - forge.lthn.ai/core/go => ../go - forge.lthn.ai/core/go-process => ../go-process - forge.lthn.ai/core/gui => ../gui -) diff --git a/go.sum b/go.sum index 7af3340..da5105c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,25 @@ +codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI= +codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +forge.lthn.ai/core/agent v0.1.3 h1:Swng0OHXPU+gOPyyjIbWxD3u/mFovQpp/UmeXCGQhmc= +forge.lthn.ai/core/agent v0.1.3/go.mod h1:FVJdDnpxqQnyJn1sAYFG76UZISw1uM8sOnti9zlpAb4= +forge.lthn.ai/core/go v0.2.2 h1:JCWaFfiG+agb0f7b5DO1g+h40x6nb4UydxJ7D+oZk5k= +forge.lthn.ai/core/go v0.2.2/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc= +forge.lthn.ai/core/go-config v0.1.2 h1:W2Rf7PhVeugTjHZ9XbEr1yMCYY6r1r5fyUmQcM2C9xY= +forge.lthn.ai/core/go-config v0.1.2/go.mod h1:6jZZ0XQaSSiWiZjiNfibMEfSPehsBGwfToh//QGhN7E= +forge.lthn.ai/core/go-io v0.0.5 h1:oSyngKTkB1gR5fEWYKXftTg9FxwnpddSiCq2dlwfImE= +forge.lthn.ai/core/go-io v0.0.5/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= +forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= +forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +forge.lthn.ai/core/go-process v0.1.2 h1:0fdLJq/DPssilN9E5yude/xHNfZRKHghIjo++b5aXgc= +forge.lthn.ai/core/go-process v0.1.2/go.mod h1:9oxVALrZaZCqFe8YDdheIS5bRUV1SBz4tVW/MflAtxM= +forge.lthn.ai/core/go-scm v0.1.7 h1:UuIqOLal4MUNS4UoWlBBOz1b/13QlcxnMnx9e5fIHWM= +forge.lthn.ai/core/go-scm v0.1.7/go.mod h1:3GSEnNWyak3ETGehOW2DEcjWscBlNF5JHu31z2hHTVM= +forge.lthn.ai/core/go-ws v0.1.3 h1:TzqFpEcDYcZUFFmrTznfEuVcVdnp2jsRNwAGHeTyXN0= +forge.lthn.ai/core/go-ws v0.1.3/go.mod h1:iDbJuR1NT27czjtNIluxnEdLrnfsYQdEBIrsoZnpkCk= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -13,6 +33,12 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -23,6 +49,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -35,6 +65,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= @@ -59,6 +91,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= @@ -100,6 +134,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -131,20 +167,31 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= -github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs= -github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw= +github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw= +github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -159,6 +206,8 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= diff --git a/headless.go b/headless.go index f622489..e4e770a 100644 --- a/headless.go +++ b/headless.go @@ -11,13 +11,13 @@ import ( "syscall" "time" - "forge.lthn.ai/core/go-process" - "forge.lthn.ai/core/go/pkg/agentci" - "forge.lthn.ai/core/go/pkg/config" - "forge.lthn.ai/core/go/pkg/forge" - "forge.lthn.ai/core/go/pkg/jobrunner" - forgejosource "forge.lthn.ai/core/go/pkg/jobrunner/forgejo" - "forge.lthn.ai/core/go/pkg/jobrunner/handlers" + process "forge.lthn.ai/core/go-process" + orchestrator "forge.lthn.ai/core/agent/pkg/orchestrator" + config "forge.lthn.ai/core/go-config" + "forge.lthn.ai/core/go-scm/forge" + "forge.lthn.ai/core/agent/pkg/jobrunner" + forgejosource "forge.lthn.ai/core/agent/pkg/jobrunner/forgejo" + "forge.lthn.ai/core/agent/pkg/jobrunner/handlers" ) // hasDisplay returns true if a graphical display is available. @@ -69,18 +69,18 @@ func startHeadless() { // Agent dispatch — Clotho integration cfg, cfgErr := config.New() - var agentTargets map[string]agentci.AgentConfig - var clothoCfg agentci.ClothoConfig + var agentTargets map[string]orchestrator.AgentConfig + var clothoCfg orchestrator.ClothoConfig if cfgErr == nil { - agentTargets, _ = agentci.LoadActiveAgents(cfg) - clothoCfg, _ = agentci.LoadClothoConfig(cfg) + agentTargets, _ = orchestrator.LoadActiveAgents(cfg) + clothoCfg, _ = orchestrator.LoadClothoConfig(cfg) } if agentTargets == nil { - agentTargets = map[string]agentci.AgentConfig{} + agentTargets = map[string]orchestrator.AgentConfig{} } - spinner := agentci.NewSpinner(clothoCfg, agentTargets) + spinner := orchestrator.NewSpinner(clothoCfg, agentTargets) log.Printf("Loaded %d agent targets. Strategy: %s", len(agentTargets), clothoCfg.Strategy) dispatch := handlers.NewDispatchHandler(forgeClient, forgeURL, forgeToken, spinner) diff --git a/headless_mcp.go b/headless_mcp.go index f2df85e..2525a41 100644 --- a/headless_mcp.go +++ b/headless_mcp.go @@ -6,13 +6,15 @@ import ( "fmt" "log" "net/http" + "strings" - "forge.lthn.ai/core/go/pkg/jobrunner" + "forge.lthn.ai/core/agent/pkg/jobrunner" ) // startHeadlessMCP starts a minimal MCP HTTP server for headless mode. -// It exposes job handler tools and health endpoints. +// It exposes job handler tools, brain tools, and health endpoints. func startHeadlessMCP(poller *jobrunner.Poller) { + brain := NewBrainService() mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { @@ -40,6 +42,7 @@ func startHeadlessMCP(poller *jobrunner.Poller) { {"name": "job_set_dry_run", "description": "Enable/disable dry-run mode"}, {"name": "job_run_once", "description": "Trigger a single poll-dispatch cycle"}, } + tools = append(tools, brainToolsList()...) json.NewEncoder(w).Encode(map[string]any{"tools": tools}) }) @@ -78,6 +81,12 @@ func startHeadlessMCP(poller *jobrunner.Poller) { "cycle": poller.Cycle(), }) default: + // Try brain tools + if strings.HasPrefix(req.Tool, "brain_") { + result := executeBrainTool(brain, req.Tool, req.Params) + json.NewEncoder(w).Encode(result) + return + } json.NewEncoder(w).Encode(map[string]any{"error": "unknown tool"}) } }) diff --git a/mcp_bridge.go b/mcp_bridge.go index 184f3a1..a69c9df 100644 --- a/mcp_bridge.go +++ b/mcp_bridge.go @@ -7,18 +7,19 @@ import ( "log" "net/http" "net/url" + "strings" "sync" "time" - "forge.lthn.ai/core/gui/pkg/webview" - "forge.lthn.ai/core/gui/pkg/ws" + ws "forge.lthn.ai/core/go-ws" "github.com/wailsapp/wails/v3/pkg/application" ) -// MCPBridge wires together WebView and WebSocket services +// MCPBridge wires together WebView, WebSocket, and Brain services // and starts the MCP HTTP server after Wails initializes. type MCPBridge struct { - webview *webview.Service + webview *WebviewService + brain *BrainService wsHub *ws.Hub claudeBridge *ClaudeBridge app *application.App @@ -29,7 +30,7 @@ type MCPBridge struct { // NewMCPBridge creates a new MCP bridge with all services wired up. func NewMCPBridge(port int) *MCPBridge { - wv := webview.New() + wv := NewWebviewService() hub := ws.NewHub() // Create Claude bridge to forward messages to MCP core on port 9876 @@ -37,6 +38,7 @@ func NewMCPBridge(port int) *MCPBridge { return &MCPBridge{ webview: wv, + brain: NewBrainService(), wsHub: hub, claudeBridge: claudeBridge, port: port, @@ -75,7 +77,7 @@ func (b *MCPBridge) ServiceStartup(ctx context.Context, options application.Serv // injectConsoleCapture injects the console capture script into windows. func (b *MCPBridge) injectConsoleCapture() { // Wait for windows to be created (poll with timeout) - var windows []webview.WindowInfo + var windows []WindowInfo for i := 0; i < 10; i++ { time.Sleep(500 * time.Millisecond) windows = b.webview.ListWindows() @@ -190,6 +192,7 @@ func (b *MCPBridge) handleMCPTools(w http.ResponseWriter, r *http.Request) { {"name": "webview_pdf", "description": "Export page as PDF (base64 data URI)"}, {"name": "webview_print", "description": "Open print dialog for window"}, } + tools = append(tools, brainToolsList()...) json.NewEncoder(w).Encode(map[string]any{"tools": tools}) } @@ -500,6 +503,10 @@ func (b *MCPBridge) executeWebviewTool(tool string, params map[string]any) map[s return map[string]any{"success": true} default: + // Try brain tools + if strings.HasPrefix(tool, "brain_") { + return executeBrainTool(b.brain, tool, params) + } return map[string]any{"error": "unknown tool", "tool": tool} } } diff --git a/webview_svc.go b/webview_svc.go new file mode 100644 index 0000000..2714424 --- /dev/null +++ b/webview_svc.go @@ -0,0 +1,417 @@ +package main + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WindowInfo describes a Wails webview window. +type WindowInfo struct { + Name string `json:"name"` + Title string `json:"title"` + Width int `json:"width"` + Height int `json:"height"` +} + +// ConsoleEntry is a captured browser console message. +type ConsoleEntry struct { + Level string `json:"level"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// WebviewService wraps Wails v3 window management and JS execution +// for MCP tool access. This replaces the deleted gui/pkg/webview package. +type WebviewService struct { + app *application.App + console []ConsoleEntry + mu sync.Mutex +} + +// NewWebviewService creates a new service (no app wired yet). +func NewWebviewService() *WebviewService { + return &WebviewService{} +} + +// SetApp wires the Wails application reference. +func (s *WebviewService) SetApp(app *application.App) { + s.mu.Lock() + defer s.mu.Unlock() + s.app = app +} + +// SetupConsoleListener is a no-op stub — console capture requires +// JS injection (see InjectConsoleCapture). +func (s *WebviewService) SetupConsoleListener() {} + +// ListWindows returns info for all open Wails windows. +func (s *WebviewService) ListWindows() []WindowInfo { + s.mu.Lock() + app := s.app + s.mu.Unlock() + + if app == nil { + return nil + } + + var result []WindowInfo + for _, w := range app.Window.GetAll() { + result = append(result, WindowInfo{Name: w.Name()}) + } + return result +} + +// getWindow looks up a Wails window by name. +func (s *WebviewService) getWindow(name string) (application.Window, error) { + s.mu.Lock() + app := s.app + s.mu.Unlock() + + if app == nil { + return nil, fmt.Errorf("app not initialised") + } + if name == "" { + name = "tray-panel" + } + w, ok := app.Window.Get(name) + if !ok { + return nil, fmt.Errorf("window %q not found", name) + } + return w, nil +} + +// ExecJS runs JavaScript in the named window and returns the result as a string. +// Wails v3 ExecJS is fire-and-forget, so we use a callback pattern via events. +func (s *WebviewService) ExecJS(windowName, code string) (string, error) { + w, err := s.getWindow(windowName) + if err != nil { + return "", err + } + w.ExecJS(code) + return "", nil // Wails v3 ExecJS doesn't return values +} + +// InjectConsoleCapture injects a JS script that captures console messages. +func (s *WebviewService) InjectConsoleCapture(windowName string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + + // Inject console capture script + js := `(function(){ + if(window.__consoleCaptured) return; + window.__consoleCaptured = true; + window.__consoleLog = []; + ['log','warn','error','info','debug'].forEach(function(level){ + var orig = console[level]; + console[level] = function(){ + window.__consoleLog.push({level:level, message:Array.from(arguments).join(' '), ts:Date.now()}); + if(window.__consoleLog.length > 1000) window.__consoleLog.shift(); + orig.apply(console, arguments); + }; + }); + })()` + w.ExecJS(js) + return nil +} + +// GetConsoleMessages returns captured console messages (requires InjectConsoleCapture). +func (s *WebviewService) GetConsoleMessages(level string, limit int) []ConsoleEntry { + s.mu.Lock() + defer s.mu.Unlock() + + if limit <= 0 { + limit = 100 + } + var result []ConsoleEntry + for _, e := range s.console { + if level != "" && e.Level != level { + continue + } + result = append(result, e) + if len(result) >= limit { + break + } + } + return result +} + +// ClearConsole clears the captured console buffer. +func (s *WebviewService) ClearConsole() { + s.mu.Lock() + defer s.mu.Unlock() + s.console = nil +} + +// Click clicks a DOM element by CSS selector. +func (s *WebviewService) Click(windowName, selector string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + w.ExecJS(fmt.Sprintf(`document.querySelector(%s)?.click()`, jsQuote(selector))) + return nil +} + +// Type types text into a DOM element by CSS selector. +func (s *WebviewService) Type(windowName, selector, text string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + js := fmt.Sprintf(`(function(){var el=document.querySelector(%s);if(el){el.focus();el.value=%s;el.dispatchEvent(new Event('input',{bubbles:true}));}})()`, + jsQuote(selector), jsQuote(text)) + w.ExecJS(js) + return nil +} + +// QuerySelector queries DOM elements by CSS selector. +func (s *WebviewService) QuerySelector(windowName, selector string) (any, error) { + _, err := s.getWindow(windowName) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("QuerySelector requires CDP — not yet implemented") +} + +// Navigate navigates the window to a URL. +func (s *WebviewService) Navigate(windowName, url string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + w.SetURL(url) + return nil +} + +// GetPageSource returns the page HTML source. +func (s *WebviewService) GetPageSource(windowName string) (string, error) { + _, err := s.getWindow(windowName) + if err != nil { + return "", err + } + return "", fmt.Errorf("GetPageSource requires CDP — not yet implemented") +} + +// GetURL returns the current page URL. +func (s *WebviewService) GetURL(windowName string) (string, error) { + _, err := s.getWindow(windowName) + if err != nil { + return "", err + } + return "", fmt.Errorf("GetURL requires CDP — not yet implemented") +} + +// GetTitle returns the current page title. +func (s *WebviewService) GetTitle(windowName string) (string, error) { + _, err := s.getWindow(windowName) + if err != nil { + return "", err + } + return "", fmt.Errorf("GetTitle requires CDP — not yet implemented") +} + +// Screenshot captures the page as base64 PNG. +func (s *WebviewService) Screenshot(windowName string) (string, error) { + _, err := s.getWindow(windowName) + if err != nil { + return "", err + } + return "", fmt.Errorf("Screenshot requires CDP — not yet implemented") +} + +// ScreenshotElement captures a specific element as base64 PNG. +func (s *WebviewService) ScreenshotElement(windowName, selector string) (string, error) { + _, err := s.getWindow(windowName) + if err != nil { + return "", err + } + return "", fmt.Errorf("ScreenshotElement requires CDP — not yet implemented") +} + +// Scroll scrolls the window or element. +func (s *WebviewService) Scroll(windowName, selector string, x, y int) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + if selector != "" { + w.ExecJS(fmt.Sprintf(`document.querySelector(%s)?.scrollTo(%d,%d)`, jsQuote(selector), x, y)) + } else { + w.ExecJS(fmt.Sprintf(`window.scrollTo(%d,%d)`, x, y)) + } + return nil +} + +// Hover dispatches a mouseover event on an element. +func (s *WebviewService) Hover(windowName, selector string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + w.ExecJS(fmt.Sprintf(`document.querySelector(%s)?.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}))`, jsQuote(selector))) + return nil +} + +// Select selects an option in a dropdown. +func (s *WebviewService) Select(windowName, selector, value string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + js := fmt.Sprintf(`(function(){var el=document.querySelector(%s);if(el){el.value=%s;el.dispatchEvent(new Event('change',{bubbles:true}));}})()`, + jsQuote(selector), jsQuote(value)) + w.ExecJS(js) + return nil +} + +// Check checks or unchecks a checkbox/radio. +func (s *WebviewService) Check(windowName, selector string, checked bool) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + js := fmt.Sprintf(`(function(){var el=document.querySelector(%s);if(el){el.checked=%t;el.dispatchEvent(new Event('change',{bubbles:true}));}})()`, + jsQuote(selector), checked) + w.ExecJS(js) + return nil +} + +// GetElementInfo returns info about a DOM element. +func (s *WebviewService) GetElementInfo(windowName, selector string) (any, error) { + _, err := s.getWindow(windowName) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("GetElementInfo requires CDP — not yet implemented") +} + +// GetComputedStyle returns computed CSS styles. +func (s *WebviewService) GetComputedStyle(windowName, selector string, properties []string) (any, error) { + _, err := s.getWindow(windowName) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("GetComputedStyle requires CDP — not yet implemented") +} + +// Highlight visually highlights an element. +func (s *WebviewService) Highlight(windowName, selector string, duration int) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + if duration <= 0 { + duration = 2000 + } + js := fmt.Sprintf(`(function(){var el=document.querySelector(%s);if(el){var old=el.style.outline;el.style.outline='3px solid red';setTimeout(function(){el.style.outline=old;},%d);}})()`, + jsQuote(selector), duration) + w.ExecJS(js) + return nil +} + +// GetDOMTree returns DOM tree structure. +func (s *WebviewService) GetDOMTree(windowName string, maxDepth int) (any, error) { + _, err := s.getWindow(windowName) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("GetDOMTree requires CDP — not yet implemented") +} + +// GetErrors returns captured error messages. +func (s *WebviewService) GetErrors(limit int) []ConsoleEntry { + return s.GetConsoleMessages("error", limit) +} + +// GetPerformance returns performance metrics. +func (s *WebviewService) GetPerformance(windowName string) (any, error) { + _, err := s.getWindow(windowName) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("GetPerformance requires CDP — not yet implemented") +} + +// GetResources returns loaded resources. +func (s *WebviewService) GetResources(windowName string) (any, error) { + _, err := s.getWindow(windowName) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("GetResources requires CDP — not yet implemented") +} + +// GetNetworkRequests returns network request log. +func (s *WebviewService) GetNetworkRequests(windowName string, limit int) (any, error) { + _, err := s.getWindow(windowName) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("GetNetworkRequests requires CDP — not yet implemented") +} + +// ClearNetworkRequests clears the network request log. +func (s *WebviewService) ClearNetworkRequests(windowName string) error { + _, err := s.getWindow(windowName) + if err != nil { + return err + } + return fmt.Errorf("ClearNetworkRequests requires CDP — not yet implemented") +} + +// InjectNetworkInterceptor injects network monitoring JS. +func (s *WebviewService) InjectNetworkInterceptor(windowName string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + js := `(function(){ + if(window.__networkCaptured) return; + window.__networkCaptured = true; + window.__networkLog = []; + var origFetch = window.fetch; + window.fetch = function(){ + var start = Date.now(); + var url = arguments[0]; + if(typeof url === 'object') url = url.url; + return origFetch.apply(this, arguments).then(function(resp){ + window.__networkLog.push({url:url, status:resp.status, duration:Date.now()-start, ts:start}); + if(window.__networkLog.length > 500) window.__networkLog.shift(); + return resp; + }); + }; + })()` + w.ExecJS(js) + return nil +} + +// ExportToPDF exports page as PDF via CDP. +func (s *WebviewService) ExportToPDF(windowName string, options map[string]any) (string, error) { + _, err := s.getWindow(windowName) + if err != nil { + return "", err + } + return "", fmt.Errorf("ExportToPDF requires CDP — not yet implemented") +} + +// PrintToPDF opens print dialog. +func (s *WebviewService) PrintToPDF(windowName string) error { + w, err := s.getWindow(windowName) + if err != nil { + return err + } + w.ExecJS(`window.print()`) + return nil +} + +// jsQuote JSON-encodes a string for safe JS interpolation. +func jsQuote(s string) string { + b, _ := json.Marshal(s) + return string(b) +}