diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..37e94eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +permissions: + contents: read + +jobs: + backend: + name: Backend + runs-on: ubuntu-latest + defaults: + run: + working-directory: dash/backend + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: dash/backend/go.mod + cache: true + + - name: Run Go tests + run: go test ./... + + - name: Check Go formatting + run: test -z "$(gofmt -l .)" + + frontend: + name: Frontend + runs-on: ubuntu-latest + defaults: + run: + working-directory: dash/frontend + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: dash/frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f25650f --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Local configuration and secrets +.env +.env.* +!.env.example +*.key +*.pem +*.p12 + +# Runtime state and generated data +*.db +*.db-* +*.sqlite +*.sqlite3 +dash/backend/data/workspaces/ + +# Build output and dependencies +node_modules/ +dist/ +coverage/ +dash/backend/apollo-dash* + +# Local tooling and OS files +.claude/ +.playwright-mcp/ +.DS_Store diff --git a/README.md b/README.md index 1a515de..8d91834 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,88 @@ -# apollo -Self-hosted AI workforce platform for building and operating autonomous agent companies with organization management, tasks, memory, MCP tools, schedules, governance, and audit logs. +# AgentHQ + +AgentHQ is a self-hosted dashboard for building and operating AI companies. It combines a Go backend with a React frontend for agent organization, tasks, memory, MCP tools, schedules, governance, and audit history. + +## Project layout + +```text +dash/ +├── backend/ Go API, SQLite persistence, workers, and static file server +└── frontend/ React + Vite dashboard +``` + +## Requirements + +- Go 1.25.6 or newer +- Node.js 20 or newer +- npm + +## Local development + +1. Create local backend configuration: + + ```bash + cp dash/backend/.env.example dash/backend/.env + ``` + + Set `DASHBOARD_PASSWORD` and add an `OPENROUTER_API_KEY` if you want to use hosted models. Keep `.env` local; it is ignored by Git. + +2. Install frontend dependencies and build the dashboard: + + ```bash + cd dash/frontend + npm ci + npm run build + ``` + +3. Start the backend: + + ```bash + cd dash/backend + go run . + ``` + + The dashboard is available at `http://localhost:4000`. + +For frontend hot reload, run `npm run dev` in `dash/frontend` and keep the backend running on port 4000. The Vite proxy forwards `/api` and `/ws` requests to the backend. + +## Verification + +Run the same checks used by CI: + +```bash +cd dash/backend +go test ./... + +cd ../frontend +npm run lint +npm run build +``` + +## Production build + +`dash/backend/build.sh` cross-compiles Linux AMD64 and ARM64 backend binaries with Zig, then builds the frontend. Install Zig first: + +```bash +brew install zig +cd dash/backend +./build.sh +``` + +The generated binaries, frontend bundle, database, runtime workspace, and credentials are intentionally excluded from version control. + +## Configuration + +The main backend settings are documented in `dash/backend/.env.example`: + +- `PORT` — HTTP port, default `4000` +- `DASHBOARD_PASSWORD` — admin password for protected routes +- `OPENROUTER_API_KEY` — hosted model and embedding access +- `OPENAI_API_KEY` — optional OpenAI-compatible fallback +- `RESEND_API_KEY` and `APP_URL` — optional email notifications +- `VULTA_API_KEY` and `VULTA_WEBHOOK_SECRET` — optional billing integration +- `AGENTHQ_WORKSPACE_ROOT` — optional override for workspace storage +- `OLLAMA_API_URL` — optional local Ollama-compatible endpoint +- `APOLLO_DEBUG_SYSTEM_LOG` — optional verbose model prompt logging +- `CONTEXTPLUS_EMBED_TRACKER` — optional embedding tracking toggle + +Never commit real credentials, private keys, SQLite databases, or built binaries. diff --git a/dash/backend/.env.example b/dash/backend/.env.example new file mode 100644 index 0000000..394b371 --- /dev/null +++ b/dash/backend/.env.example @@ -0,0 +1,70 @@ +# AgentHQ backend environment template +# +# Copy this file to .env for local development: +# cp .env.example .env +# +# Never commit .env or paste real credentials into this file. Use your +# deployment platform's secret manager in production. + +# ----------------------------------------------------------------------------- +# Server and authentication +# ----------------------------------------------------------------------------- + +# HTTP listen port. Defaults to 4000. +PORT=4000 + +# Required for admin access and API Basic Auth fallback. +# Generate a strong value with: openssl rand -base64 32 +DASHBOARD_PASSWORD=replace-with-a-strong-random-password + +# Public application URL used in email links. Leave commented for the built-in +# default, or set your local/deployed URL explicitly. +# APP_URL=http://localhost:4000 + +# ----------------------------------------------------------------------------- +# AI providers +# ----------------------------------------------------------------------------- + +# Recommended hosted provider. Required for OpenRouter-backed chat, agents, and +# embeddings unless a user-level key or local Ollama provider is configured. +OPENROUTER_API_KEY=replace-with-your-openrouter-api-key + +# Optional OpenAI-compatible fallback for embeddings and AI features. +# OPENAI_API_KEY=replace-with-your-openai-api-key + +# Optional local Ollama-compatible endpoint. The application defaults to +# http://localhost:11434 when this is not set. +# OLLAMA_API_URL=http://localhost:11434 + +# ----------------------------------------------------------------------------- +# Workspace and local storage +# ----------------------------------------------------------------------------- + +# Optional persistent workspace location. Defaults to ./data/workspaces. +# Use an absolute path for production deployments. +# AGENTHQ_WORKSPACE_ROOT=/var/lib/agenthq/workspaces + +# Legacy alias accepted by workspace tools. Prefer AGENTHQ_WORKSPACE_ROOT. +# APOLLO_WORKSPACE_ROOT=/var/lib/agenthq/workspaces + +# ----------------------------------------------------------------------------- +# Optional integrations +# ----------------------------------------------------------------------------- + +# Resend email delivery. Leave unset to disable email notifications. +# RESEND_API_KEY=replace-with-your-resend-api-key + +# Vulta billing API and webhook signing secret. Leave unset to disable billing. +# VULTA_API_KEY=replace-with-your-vulta-api-key +# VULTA_WEBHOOK_SECRET=replace-with-your-vulta-webhook-secret + +# ----------------------------------------------------------------------------- +# Diagnostics and indexing +# ----------------------------------------------------------------------------- + +# Include verbose model prompts in the system log panel. Keep disabled in +# production because prompts can contain sensitive workspace data. +APOLLO_DEBUG_SYSTEM_LOG=false + +# ContextPlus embedding tracking is enabled by default. Set false to disable it. +# CONTEXTPLUS_EMBED_TRACKER=false diff --git a/dash/backend/agentos/consensus.go b/dash/backend/agentos/consensus.go new file mode 100644 index 0000000..ff52a3b --- /dev/null +++ b/dash/backend/agentos/consensus.go @@ -0,0 +1,200 @@ +package agentos + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +func (s *Service) CreateConsensusRound(companyID, departmentID, topic, createdBy string) (ConsensusRound, error) { + if strings.TrimSpace(companyID) == "" || strings.TrimSpace(topic) == "" { + return ConsensusRound{}, fmt.Errorf("company_id and topic are required") + } + if strings.TrimSpace(createdBy) == "" { + createdBy = "owner" + } + id := uuid.NewString() + _, err := s.db.Exec(` + INSERT INTO agent_consensus_rounds (id, company_id, department_id, topic, status, created_by, created_at, closed_at) + VALUES (?, ?, ?, ?, 'open', ?, CURRENT_TIMESTAMP, '') + `, id, companyID, departmentID, topic, createdBy) + if err != nil { + return ConsensusRound{}, err + } + s.appendAudit("consensus_round_created", "consensus_round", id, createdBy, map[string]interface{}{"topic": topic}) + s.emitEvent(companyID, departmentID, "", "", "", "consensus_round_created", "info", map[string]interface{}{"round_id": id, "topic": topic}) + return s.GetConsensusRound(id) +} + +func (s *Service) GetConsensusRound(id string) (ConsensusRound, error) { + var r ConsensusRound + var createdAt string + err := s.db.QueryRow(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(topic,''), IFNULL(status,'open'), IFNULL(created_by,''), IFNULL(created_at,''), IFNULL(closed_at,'') + FROM agent_consensus_rounds WHERE id = ? + `, id).Scan(&r.ID, &r.CompanyID, &r.DepartmentID, &r.Topic, &r.Status, &r.CreatedBy, &createdAt, &r.ClosedAt) + if err != nil { + return ConsensusRound{}, err + } + r.CreatedAt = parseDBTime(createdAt) + return r, nil +} + +func (s *Service) ListConsensusRounds(companyID, departmentID string, limit int) ([]ConsensusRound, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + query := ` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(topic,''), IFNULL(status,'open'), IFNULL(created_by,''), IFNULL(created_at,''), IFNULL(closed_at,'') + FROM agent_consensus_rounds WHERE 1=1` + args := []interface{}{} + if strings.TrimSpace(companyID) != "" { + query += " AND company_id = ?" + args = append(args, companyID) + } + if strings.TrimSpace(departmentID) != "" { + query += " AND department_id = ?" + args = append(args, departmentID) + } + query += " ORDER BY created_at DESC LIMIT ?" + args = append(args, limit) + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ConsensusRound{} + for rows.Next() { + var r ConsensusRound + var createdAt string + if err := rows.Scan(&r.ID, &r.CompanyID, &r.DepartmentID, &r.Topic, &r.Status, &r.CreatedBy, &createdAt, &r.ClosedAt); err != nil { + continue + } + r.CreatedAt = parseDBTime(createdAt) + out = append(out, r) + } + return out, nil +} + +func (s *Service) VoteConsensus(roundID, agentID, option string, confidence float64, rationale string) (ConsensusVote, error) { + if strings.TrimSpace(roundID) == "" || strings.TrimSpace(agentID) == "" || strings.TrimSpace(option) == "" { + return ConsensusVote{}, fmt.Errorf("round_id, agent_id and option are required") + } + round, err := s.GetConsensusRound(roundID) + if err != nil { + return ConsensusVote{}, err + } + if strings.ToLower(strings.TrimSpace(round.Status)) != "open" { + return ConsensusVote{}, fmt.Errorf("consensus round is closed") + } + if confidence <= 0 { + confidence = 0.5 + } + if confidence > 1 { + confidence = 1 + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO agent_consensus_votes (id, round_id, agent_id, option, confidence, rationale, created_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(round_id, agent_id) DO UPDATE SET + option=excluded.option, + confidence=excluded.confidence, + rationale=excluded.rationale, + created_at=CURRENT_TIMESTAMP + `, id, roundID, agentID, option, confidence, rationale) + if err != nil { + return ConsensusVote{}, err + } + s.appendAudit("consensus_vote_cast", "consensus_round", roundID, agentID, map[string]interface{}{"option": option, "confidence": confidence}) + s.emitEvent(round.CompanyID, round.DepartmentID, agentID, "", "", "consensus_vote_cast", "info", map[string]interface{}{"round_id": roundID, "option": option}) + + votes, _ := s.ListConsensusVotes(roundID) + for _, v := range votes { + if v.AgentID == agentID { + return v, nil + } + } + return ConsensusVote{}, fmt.Errorf("vote not found after write") +} + +func (s *Service) ListConsensusVotes(roundID string) ([]ConsensusVote, error) { + rows, err := s.db.Query(` + SELECT id, IFNULL(round_id,''), IFNULL(agent_id,''), IFNULL(option,''), IFNULL(confidence,0.5), IFNULL(rationale,''), IFNULL(created_at,'') + FROM agent_consensus_votes + WHERE round_id = ? + ORDER BY created_at ASC + `, roundID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ConsensusVote{} + for rows.Next() { + var v ConsensusVote + var createdAt string + if err := rows.Scan(&v.ID, &v.RoundID, &v.AgentID, &v.Option, &v.Confidence, &v.Rationale, &createdAt); err != nil { + continue + } + v.CreatedAt = parseDBTime(createdAt) + out = append(out, v) + } + return out, nil +} + +func (s *Service) CloseConsensusRound(roundID string) (ConsensusRound, error) { + round, err := s.GetConsensusRound(roundID) + if err != nil { + return ConsensusRound{}, err + } + if strings.ToLower(strings.TrimSpace(round.Status)) == "closed" { + return round, nil + } + _, err = s.db.Exec("UPDATE agent_consensus_rounds SET status='closed', closed_at=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), roundID) + if err != nil { + return ConsensusRound{}, err + } + s.appendAudit("consensus_round_closed", "consensus_round", roundID, "owner", nil) + s.emitEvent(round.CompanyID, round.DepartmentID, "", "", "", "consensus_round_closed", "info", map[string]interface{}{"round_id": roundID}) + return s.GetConsensusRound(roundID) +} + +func (s *Service) ConsensusDecision(roundID string) (map[string]interface{}, error) { + round, err := s.GetConsensusRound(roundID) + if err != nil { + return nil, err + } + votes, err := s.ListConsensusVotes(roundID) + if err != nil { + return nil, err + } + byOption := map[string]int{} + for _, v := range votes { + opt := strings.TrimSpace(v.Option) + if opt == "" { + continue + } + byOption[opt]++ + } + winner := "" + winnerCount := 0 + for option, count := range byOption { + if count > winnerCount { + winner = option + winnerCount = count + } + } + return map[string]interface{}{ + "round": round, + "votes": votes, + "counts": byOption, + "winner_option": winner, + "winner_votes": winnerCount, + }, nil +} diff --git a/dash/backend/agentos/governance.go b/dash/backend/agentos/governance.go new file mode 100644 index 0000000..a6947ae --- /dev/null +++ b/dash/backend/agentos/governance.go @@ -0,0 +1,468 @@ +package agentos + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +type PolicyDecision struct { + Allowed bool `json:"allowed"` + MatchedRule string `json:"matched_rule"` + ApprovalTier string `json:"approval_tier"` + Reason string `json:"reason"` +} + +func normalizeTier(t string) string { + t = strings.ToLower(strings.TrimSpace(t)) + switch t { + case "none", "tier1", "tier2", "tier3": + return t + default: + return "none" + } +} + +func maxTier(a, b string) string { + order := map[string]int{"none": 0, "tier1": 1, "tier2": 2, "tier3": 3} + na := normalizeTier(a) + nb := normalizeTier(b) + if order[na] >= order[nb] { + return na + } + return nb +} + +func wildcardMatch(pattern, value string) bool { + pattern = strings.TrimSpace(pattern) + value = strings.TrimSpace(value) + if pattern == "" || pattern == "*" { + return true + } + if ok, err := filepath.Match(pattern, value); err == nil && ok { + return true + } + if strings.HasSuffix(pattern, "/") { + return strings.HasPrefix(value, pattern) + } + needle := strings.Trim(pattern, "*") + if needle == "" { + return true + } + return strings.Contains(value, needle) +} + +func (s *Service) UpsertPolicy(rule PolicyRule) (PolicyRule, error) { + if strings.TrimSpace(rule.ID) == "" { + rule.ID = uuid.NewString() + } + if strings.TrimSpace(rule.Action) == "" { + return PolicyRule{}, fmt.Errorf("action is required") + } + rule.Effect = strings.ToLower(strings.TrimSpace(rule.Effect)) + if rule.Effect == "" { + rule.Effect = "allow" + } + if rule.Effect != "allow" && rule.Effect != "deny" { + return PolicyRule{}, fmt.Errorf("effect must be allow or deny") + } + if strings.TrimSpace(rule.ScopePattern) == "" { + rule.ScopePattern = "*" + } + rule.ApprovalTier = normalizeTier(rule.ApprovalTier) + + _, err := s.db.Exec(` + INSERT INTO capability_policies (id, company_id, department_id, agent_id, action, effect, scope_pattern, approval_tier, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + company_id=excluded.company_id, + department_id=excluded.department_id, + agent_id=excluded.agent_id, + action=excluded.action, + effect=excluded.effect, + scope_pattern=excluded.scope_pattern, + approval_tier=excluded.approval_tier, + updated_at=CURRENT_TIMESTAMP + `, rule.ID, rule.CompanyID, rule.DepartmentID, rule.AgentID, rule.Action, rule.Effect, rule.ScopePattern, rule.ApprovalTier) + if err != nil { + return PolicyRule{}, err + } + s.appendAudit("policy_upsert", "policy", rule.ID, "owner", rule) + return s.GetPolicy(rule.ID) +} + +func (s *Service) GetPolicy(id string) (PolicyRule, error) { + var p PolicyRule + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(action,''), IFNULL(effect,''), IFNULL(scope_pattern,'*'), IFNULL(approval_tier,'none'), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM capability_policies WHERE id = ? + `, id).Scan(&p.ID, &p.CompanyID, &p.DepartmentID, &p.AgentID, &p.Action, &p.Effect, &p.ScopePattern, &p.ApprovalTier, &createdAt, &updatedAt) + if err != nil { + return PolicyRule{}, err + } + p.CreatedAt = parseDBTime(createdAt) + p.UpdatedAt = parseDBTime(updatedAt) + return p, nil +} + +func (s *Service) ListPolicies(companyID, departmentID, agentID string) ([]PolicyRule, error) { + query := ` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(action,''), IFNULL(effect,''), IFNULL(scope_pattern,'*'), IFNULL(approval_tier,'none'), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM capability_policies WHERE 1=1` + args := []interface{}{} + if strings.TrimSpace(companyID) != "" { + query += " AND company_id = ?" + args = append(args, companyID) + } + if strings.TrimSpace(departmentID) != "" { + query += " AND department_id = ?" + args = append(args, departmentID) + } + if strings.TrimSpace(agentID) != "" { + query += " AND agent_id = ?" + args = append(args, agentID) + } + query += " ORDER BY updated_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []PolicyRule{} + for rows.Next() { + var p PolicyRule + var createdAt, updatedAt string + if err := rows.Scan(&p.ID, &p.CompanyID, &p.DepartmentID, &p.AgentID, &p.Action, &p.Effect, &p.ScopePattern, &p.ApprovalTier, &createdAt, &updatedAt); err != nil { + continue + } + p.CreatedAt = parseDBTime(createdAt) + p.UpdatedAt = parseDBTime(updatedAt) + out = append(out, p) + } + return out, nil +} + +func (s *Service) EvaluatePolicy(companyID, departmentID, agentID, action, scope string) PolicyDecision { + action = strings.ToLower(strings.TrimSpace(action)) + rules, err := s.ListPolicies(companyID, "", "") + if err != nil { + return PolicyDecision{Allowed: false, ApprovalTier: "tier3", Reason: "policy query failed"} + } + + matched := []PolicyRule{} + for _, r := range rules { + if strings.TrimSpace(r.CompanyID) != "" && r.CompanyID != companyID { + continue + } + if strings.TrimSpace(r.DepartmentID) != "" && r.DepartmentID != departmentID { + continue + } + if strings.TrimSpace(r.AgentID) != "" && r.AgentID != agentID { + continue + } + ra := strings.ToLower(strings.TrimSpace(r.Action)) + if ra != "*" && ra != action { + continue + } + if !wildcardMatch(r.ScopePattern, scope) { + continue + } + matched = append(matched, r) + } + + for _, r := range matched { + if strings.EqualFold(r.Effect, "deny") { + return PolicyDecision{Allowed: false, MatchedRule: r.ID, ApprovalTier: maxTier(r.ApprovalTier, "tier3"), Reason: "explicit deny"} + } + } + for _, r := range matched { + if strings.EqualFold(r.Effect, "allow") { + return PolicyDecision{Allowed: true, MatchedRule: r.ID, ApprovalTier: normalizeTier(r.ApprovalTier), Reason: "explicit allow"} + } + } + + enforcement := strings.ToLower(strings.TrimSpace(s.getSettingString("agentos_policy_enforcement", "deny_default"))) + if enforcement == "allow_default" { + return PolicyDecision{Allowed: true, ApprovalTier: "none", Reason: "allow-default"} + } + return PolicyDecision{Allowed: false, ApprovalTier: "tier2", Reason: "deny-by-default"} +} + +func (s *Service) TestPolicy(companyID, departmentID, agentID, action, scope string) map[string]interface{} { + dec := s.EvaluatePolicy(companyID, departmentID, agentID, action, scope) + return map[string]interface{}{ + "input": map[string]string{ + "company_id": companyID, + "department_id": departmentID, + "agent_id": agentID, + "action": action, + "scope": scope, + }, + "decision": dec, + } +} + +func (s *Service) RequestApproval(companyID, taskID, action, tier, reason string, payload interface{}) error { + if strings.TrimSpace(tier) == "" { + tier = "tier2" + } + payloadJSON := "{}" + if payload != nil { + if b, err := json.Marshal(payload); err == nil { + payloadJSON = string(b) + } + } + id := uuid.NewString() + _, err := s.db.Exec(` + INSERT INTO approval_requests (id, company_id, task_id, action, tier, reason, payload_json, status, requested_at, resolved_at, resolved_by) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, NULL, '') + `, id, companyID, taskID, action, normalizeTier(tier), reason, payloadJSON) + if err != nil { + return err + } + s.appendAudit("approval_requested", "approval", id, "system", map[string]interface{}{"task_id": taskID, "action": action, "tier": tier}) + var departmentID, agentID, threadID string + _ = s.db.QueryRow("SELECT IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(thread_id,'') FROM agent_tasks WHERE id = ?", taskID).Scan(&departmentID, &agentID, &threadID) + s.emitEvent(companyID, departmentID, agentID, threadID, taskID, "approval_requested", "warn", map[string]interface{}{"approval_id": id, "tier": tier, "reason": reason}) + return nil +} + +func (s *Service) ListApprovals(companyID, status string) ([]ApprovalRequest, error) { + query := `SELECT id, IFNULL(company_id,''), IFNULL(task_id,''), IFNULL(action,''), IFNULL(tier,''), IFNULL(reason,''), IFNULL(payload_json,'{}'), IFNULL(status,''), IFNULL(requested_at,''), IFNULL(resolved_at,''), IFNULL(resolved_by,'') FROM approval_requests WHERE 1=1` + args := []interface{}{} + if strings.TrimSpace(companyID) != "" { + query += " AND company_id = ?" + args = append(args, companyID) + } + if strings.TrimSpace(status) != "" { + query += " AND status = ?" + args = append(args, status) + } + query += " ORDER BY requested_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ApprovalRequest{} + for rows.Next() { + var a ApprovalRequest + if err := rows.Scan(&a.ID, &a.CompanyID, &a.TaskID, &a.Action, &a.Tier, &a.Reason, &a.PayloadJSON, &a.Status, &a.RequestedAt, &a.ResolvedAt, &a.ResolvedBy); err != nil { + continue + } + out = append(out, a) + } + return out, nil +} + +func (s *Service) GetApproval(approvalID string) (ApprovalRequest, error) { + var a ApprovalRequest + err := s.db.QueryRow(`SELECT id, IFNULL(company_id,''), IFNULL(task_id,''), IFNULL(action,''), IFNULL(tier,''), IFNULL(reason,''), IFNULL(payload_json,'{}'), IFNULL(status,''), IFNULL(requested_at,''), IFNULL(resolved_at,''), IFNULL(resolved_by,'') FROM approval_requests WHERE id = ?`, approvalID). + Scan(&a.ID, &a.CompanyID, &a.TaskID, &a.Action, &a.Tier, &a.Reason, &a.PayloadJSON, &a.Status, &a.RequestedAt, &a.ResolvedAt, &a.ResolvedBy) + if err != nil { + return ApprovalRequest{}, err + } + return a, nil +} + +func (s *Service) ResolveApproval(approvalID string, approve bool, actor string) error { + if strings.TrimSpace(approvalID) == "" { + return fmt.Errorf("approval_id is required") + } + status := "rejected" + if approve { + status = "approved" + } + if strings.TrimSpace(actor) == "" { + actor = "owner" + } + + var companyID, taskID, action string + err := s.db.QueryRow(`SELECT IFNULL(company_id,''), IFNULL(task_id,''), IFNULL(action,'') FROM approval_requests WHERE id = ?`, approvalID).Scan(&companyID, &taskID, &action) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("approval not found") + } + return err + } + + _, err = s.db.Exec("UPDATE approval_requests SET status = ?, resolved_at = CURRENT_TIMESTAMP, resolved_by = ? WHERE id = ?", status, actor, approvalID) + if err != nil { + return err + } + + if status == "approved" && strings.TrimSpace(taskID) != "" { + _, _ = s.db.Exec("UPDATE agent_tasks SET status='queued', blocked_reason='', updated_at=CURRENT_TIMESTAMP WHERE id = ? AND status='blocked'", taskID) + } + + s.appendAudit("approval_resolved", "approval", approvalID, actor, map[string]interface{}{"status": status, "task_id": taskID, "action": action}) + var departmentID, agentID, threadID string + _ = s.db.QueryRow("SELECT IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(thread_id,'') FROM agent_tasks WHERE id = ?", taskID).Scan(&departmentID, &agentID, &threadID) + s.emitEvent(companyID, departmentID, agentID, threadID, taskID, "approval_resolved", "info", map[string]interface{}{"approval_id": approvalID, "status": status}) + return nil +} + +func canonicalJSON(raw []byte) string { + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + return string(raw) + } + b, err := json.Marshal(v) + if err != nil { + return string(raw) + } + return string(b) +} + +func hashAudit(eventType, entityType, entityID, actor, prevHash, payloadJSON, ts string) string { + raw := strings.Join([]string{eventType, entityType, entityID, actor, prevHash, payloadJSON, ts}, "|") + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func (s *Service) appendAudit(eventType, entityType, entityID, actor string, payload interface{}) { + payloadJSON := "{}" + if payload != nil { + if b, err := json.Marshal(payload); err == nil { + payloadJSON = canonicalJSON(b) + } + } + prevHash := "" + _ = s.db.QueryRow("SELECT IFNULL(event_hash,'') FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prevHash) + now := time.Now().UTC().Format(time.RFC3339Nano) + eventHash := hashAudit(eventType, entityType, entityID, actor, prevHash, payloadJSON, now) + sig, pubID := s.signAudit(eventHash) + _, _ = s.db.Exec(` + INSERT INTO audit_log (event_type, entity_type, entity_id, actor_type, actor_id, payload_json, prev_hash, event_hash, signature, pubkey_id, created_at) + VALUES (?, ?, ?, 'agent', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, eventType, entityType, entityID, actor, payloadJSON, prevHash, eventHash, sig, pubID) +} + +func (s *Service) signAudit(eventHash string) (string, string) { + priv, _, pubID := s.loadOrInitAuditKeypair() + if len(priv) == 0 { + return "", pubID + } + sig := ed25519.Sign(priv, []byte(eventHash)) + return base64.StdEncoding.EncodeToString(sig), pubID +} + +func (s *Service) loadOrInitAuditKeypair() (ed25519.PrivateKey, ed25519.PublicKey, string) { + keyPath := strings.TrimSpace(s.getSettingString("agentos_audit_signing_key_path", "./agentos_ed25519.key")) + pubID := strings.TrimSpace(s.getSettingString("agentos_audit_pubkey_id", "local-ed25519")) + + if raw, err := os.ReadFile(keyPath); err == nil { + decoded, derr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw))) + if derr == nil && len(decoded) == ed25519.PrivateKeySize { + priv := ed25519.PrivateKey(decoded) + pub := priv.Public().(ed25519.PublicKey) + return priv, pub, pubID + } + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, pubID + } + _ = os.WriteFile(keyPath, []byte(base64.StdEncoding.EncodeToString(priv)), 0600) + return priv, pub, pubID +} + +func (s *Service) AuditVerify() map[string]interface{} { + rows, err := s.db.Query(` + SELECT id, IFNULL(event_hash,''), IFNULL(prev_hash,''), IFNULL(signature,''), IFNULL(created_at,'') + FROM audit_log ORDER BY id ASC + `) + if err != nil { + return map[string]interface{}{"ok": false, "error": err.Error()} + } + defer rows.Close() + + _, pub, _ := s.loadOrInitAuditKeypair() + if len(pub) == 0 { + return map[string]interface{}{"ok": false, "error": "audit public key unavailable"} + } + + lastHash := "" + count := 0 + issues := []string{} + for rows.Next() { + count++ + var id int64 + var eventHash, prevHash, sig, createdAt string + if err := rows.Scan(&id, &eventHash, &prevHash, &sig, &createdAt); err != nil { + issues = append(issues, fmt.Sprintf("row %d scan failed", id)) + continue + } + if prevHash != lastHash { + issues = append(issues, fmt.Sprintf("row %d chain mismatch", id)) + } + sigBytes, err := base64.StdEncoding.DecodeString(sig) + if err != nil || !ed25519.Verify(pub, []byte(eventHash), sigBytes) { + issues = append(issues, fmt.Sprintf("row %d bad signature", id)) + } + lastHash = eventHash + } + + return map[string]interface{}{ + "ok": len(issues) == 0, + "entries": count, + "issues": issues, + "last_hash": lastHash, + "verified_at": time.Now().UTC().Format(time.RFC3339), + } +} + +func (s *Service) ListAudit(sinceID int64, limit int) ([]map[string]interface{}, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + rows, err := s.db.Query(` + SELECT id, IFNULL(event_type,''), IFNULL(entity_type,''), IFNULL(entity_id,''), IFNULL(actor_type,''), IFNULL(actor_id,''), IFNULL(payload_json,'{}'), IFNULL(prev_hash,''), IFNULL(event_hash,''), IFNULL(signature,''), IFNULL(pubkey_id,''), IFNULL(created_at,'') + FROM audit_log WHERE id > ? ORDER BY id ASC LIMIT ? + `, sinceID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []map[string]interface{}{} + for rows.Next() { + var id int64 + var eventType, entityType, entityID, actorType, actorID, payloadJSON, prevHash, eventHash, signature, pubkeyID, createdAt string + if err := rows.Scan(&id, &eventType, &entityType, &entityID, &actorType, &actorID, &payloadJSON, &prevHash, &eventHash, &signature, &pubkeyID, &createdAt); err != nil { + continue + } + out = append(out, map[string]interface{}{ + "id": id, + "event_type": eventType, + "entity_type": entityType, + "entity_id": entityID, + "actor_type": actorType, + "actor_id": actorID, + "payload_json": payloadJSON, + "prev_hash": prevHash, + "event_hash": eventHash, + "signature": signature, + "pubkey_id": pubkeyID, + "created_at": createdAt, + }) + } + return out, nil +} diff --git a/dash/backend/agentos/memory.go b/dash/backend/agentos/memory.go new file mode 100644 index 0000000..343f2e6 --- /dev/null +++ b/dash/backend/agentos/memory.go @@ -0,0 +1,408 @@ +package agentos + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +type memoryQueryHit struct { + Entry MemoryEntry + Score float64 +} + +func (s *Service) WriteMemoryEntry(entry MemoryEntry) error { + entry.ScopeType = strings.TrimSpace(strings.ToLower(entry.ScopeType)) + entry.ScopeID = strings.TrimSpace(entry.ScopeID) + entry.Content = strings.TrimSpace(entry.Content) + entry.SourceType = strings.TrimSpace(entry.SourceType) + entry.AuthorAgentID = strings.TrimSpace(entry.AuthorAgentID) + + if entry.ScopeType == "" || entry.ScopeID == "" { + return fmt.Errorf("scope_type and scope_id are required") + } + if entry.Content == "" { + return nil + } + if len(entry.Content) < 18 { + return nil + } + if entry.ID == "" { + entry.ID = uuid.NewString() + } + if entry.SourceType == "" { + entry.SourceType = "agent_note" + } + if entry.TagsJSON == "" { + entry.TagsJSON = "{}" + } + if entry.Importance <= 0 { + entry.Importance = 0.5 + } + + allowedScopes := map[string]bool{ + "global_user": true, + "company": true, + "department": true, + "agent_short_term": true, + "agent_long_term": true, + } + if !allowedScopes[entry.ScopeType] { + return fmt.Errorf("unsupported scope_type %q", entry.ScopeType) + } + + // Duplicate suppression within same scope. + var existing string + _ = s.db.QueryRow(` + SELECT id FROM memory_entries + WHERE scope_type = ? AND scope_id = ? AND content = ? + ORDER BY created_at DESC LIMIT 1 + `, entry.ScopeType, entry.ScopeID, entry.Content).Scan(&existing) + if existing != "" { + return nil + } + + if entry.TTLAt != "" { + if _, err := time.Parse(time.RFC3339, entry.TTLAt); err != nil { + entry.TTLAt = "" + } + } + + emb, err := s.generateEmbedding(entry.Content) + if err == nil && len(emb) > 0 { + b, _ := json.Marshal(emb) + entry.Embedding = string(b) + } else { + entry.Embedding = "" + } + + _, err = s.db.Exec(` + INSERT INTO memory_entries (id, scope_type, scope_id, source_type, author_agent_id, content, embedding, tags_json, importance, ttl_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, entry.ID, entry.ScopeType, entry.ScopeID, entry.SourceType, entry.AuthorAgentID, entry.Content, entry.Embedding, entry.TagsJSON, entry.Importance, entry.TTLAt) + if err != nil { + return err + } + + companyID, departmentID := s.scopeContext(entry.ScopeType, entry.ScopeID) + s.appendAudit("memory_write", "memory_entry", entry.ID, entry.AuthorAgentID, map[string]interface{}{ + "scope_type": entry.ScopeType, + "scope_id": entry.ScopeID, + "source_type": entry.SourceType, + }) + s.emitEvent(companyID, departmentID, entry.AuthorAgentID, "", "", "memory_written", "info", map[string]interface{}{ + "memory_id": entry.ID, + "scope_type": entry.ScopeType, + "scope_id": entry.ScopeID, + "importance": entry.Importance, + "source_type": entry.SourceType, + }) + + return nil +} + +func (s *Service) scopeContext(scopeType, scopeID string) (companyID string, departmentID string) { + switch scopeType { + case "company": + return scopeID, "" + case "department": + _ = s.db.QueryRow("SELECT IFNULL(company_id,'') FROM departments WHERE id = ?", scopeID).Scan(&companyID) + return companyID, scopeID + case "agent_short_term", "agent_long_term": + _ = s.db.QueryRow("SELECT IFNULL(company_id,''), IFNULL(department_id,'') FROM agents WHERE id = ?", scopeID).Scan(&companyID, &departmentID) + return companyID, departmentID + default: + return "", "" + } +} + +func (s *Service) ListMemoryTimeline(companyID, departmentID, agentID string, limit int) ([]MemoryEntry, error) { + if limit <= 0 || limit > 400 { + limit = 120 + } + query := ` + SELECT id, IFNULL(scope_type,''), IFNULL(scope_id,''), IFNULL(source_type,''), IFNULL(author_agent_id,''), IFNULL(content,''), IFNULL(embedding,''), IFNULL(tags_json,'{}'), IFNULL(importance,0.5), IFNULL(ttl_at,''), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM memory_entries WHERE 1=1` + args := []interface{}{} + + if strings.TrimSpace(agentID) != "" { + query += " AND ((scope_type IN ('agent_short_term','agent_long_term') AND scope_id = ?) OR author_agent_id = ?)" + args = append(args, agentID, agentID) + } else if strings.TrimSpace(departmentID) != "" { + query += " AND ( (scope_type = 'department' AND scope_id = ?) OR (scope_type IN ('agent_short_term','agent_long_term') AND scope_id IN (SELECT id FROM agents WHERE department_id = ?)) )" + args = append(args, departmentID, departmentID) + } else if strings.TrimSpace(companyID) != "" { + query += " AND ( (scope_type = 'company' AND scope_id = ?) OR (scope_type = 'department' AND scope_id IN (SELECT id FROM departments WHERE company_id = ?)) OR (scope_type IN ('agent_short_term','agent_long_term') AND scope_id IN (SELECT id FROM agents WHERE company_id = ?)) )" + args = append(args, companyID, companyID, companyID) + } + + query += " ORDER BY created_at DESC LIMIT ?" + args = append(args, limit) + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []MemoryEntry{} + for rows.Next() { + var e MemoryEntry + var ttlAt, createdAt, updatedAt string + if err := rows.Scan(&e.ID, &e.ScopeType, &e.ScopeID, &e.SourceType, &e.AuthorAgentID, &e.Content, &e.Embedding, &e.TagsJSON, &e.Importance, &ttlAt, &createdAt, &updatedAt); err != nil { + continue + } + e.TTLAt = strings.TrimSpace(ttlAt) + e.CreatedAt = parseDBTime(createdAt) + e.UpdatedAt = parseDBTime(updatedAt) + out = append(out, e) + } + return out, nil +} + +func (s *Service) QueryMemory(companyID, departmentID, agentID, query string, limit int) ([]MemoryEntry, error) { + if limit <= 0 || limit > 120 { + limit = 24 + } + entries, err := s.ListMemoryTimeline(companyID, departmentID, agentID, 500) + if err != nil { + return nil, err + } + if len(entries) == 0 { + return []MemoryEntry{}, nil + } + query = strings.TrimSpace(query) + if query == "" { + if len(entries) > limit { + entries = entries[:limit] + } + return entries, nil + } + + queryEmb, embErr := s.generateEmbedding(query) + qLower := strings.ToLower(query) + + hits := []memoryQueryHit{} + for _, e := range entries { + score := 0.0 + contentLower := strings.ToLower(e.Content) + if strings.Contains(contentLower, qLower) { + score += 0.45 + } + for _, tok := range strings.Fields(qLower) { + if tok != "" && strings.Contains(contentLower, tok) { + score += 0.08 + } + } + if embErr == nil && len(queryEmb) > 0 && strings.TrimSpace(e.Embedding) != "" { + var emb []float32 + if err := json.Unmarshal([]byte(e.Embedding), &emb); err == nil { + score += 0.7 * cosineSimilarity(queryEmb, emb) + } + } + if score > 0 { + hits = append(hits, memoryQueryHit{Entry: e, Score: score}) + } + } + + sort.Slice(hits, func(i, j int) bool { + if hits[i].Score == hits[j].Score { + return hits[i].Entry.CreatedAt.After(hits[j].Entry.CreatedAt) + } + return hits[i].Score > hits[j].Score + }) + + out := make([]MemoryEntry, 0, limit) + for _, h := range hits { + out = append(out, h.Entry) + if len(out) >= limit { + break + } + } + return out, nil +} + +func (s *Service) memoryContextForAgent(companyID, departmentID, agentID string) string { + sections := []struct { + Title string + Scope string + ID string + Limit int + }{ + {Title: "Global User Memory", Scope: "global_user", ID: "owner", Limit: 3}, + {Title: "Company Memory", Scope: "company", ID: companyID, Limit: 4}, + {Title: "Department Memory", Scope: "department", ID: departmentID, Limit: 4}, + {Title: "Agent Long-Term Memory", Scope: "agent_long_term", ID: agentID, Limit: 5}, + {Title: "Agent Short-Term Memory", Scope: "agent_short_term", ID: agentID, Limit: 5}, + } + + var b strings.Builder + b.WriteString("Scoped memory context for this run. Use it as guidance, not absolute truth.\n\n") + + for _, sec := range sections { + if strings.TrimSpace(sec.ID) == "" { + continue + } + rows, err := s.db.Query(` + SELECT IFNULL(content,''), IFNULL(source_type,''), IFNULL(created_at,'') + FROM memory_entries + WHERE scope_type = ? AND scope_id = ? + ORDER BY created_at DESC LIMIT ? + `, sec.Scope, sec.ID, sec.Limit) + if err != nil { + continue + } + + items := []string{} + for rows.Next() { + var content, source, createdAt string + if err := rows.Scan(&content, &source, &createdAt); err != nil { + continue + } + content = strings.TrimSpace(content) + if content == "" { + continue + } + if len(content) > 280 { + content = content[:280] + "..." + } + items = append(items, fmt.Sprintf("- (%s) %s", source, content)) + } + rows.Close() + if len(items) == 0 { + continue + } + b.WriteString(sec.Title) + b.WriteString(":\n") + b.WriteString(strings.Join(items, "\n")) + b.WriteString("\n\n") + } + + // Add indexed knowledge chunks from contextplus-native indexing where available. + if strings.TrimSpace(companyID) != "" { + rows, err := s.db.Query(` + SELECT IFNULL(k.chunk_text,'') + FROM knowledge_index_chunks k + JOIN knowledge_assets a ON a.id = k.asset_id + WHERE a.company_id = ? + ORDER BY k.created_at DESC + LIMIT 6 + `, companyID) + if err == nil { + defer rows.Close() + chunks := []string{} + for rows.Next() { + var chunk string + if err := rows.Scan(&chunk); err != nil { + continue + } + chunk = strings.TrimSpace(chunk) + if chunk == "" { + continue + } + if len(chunk) > 220 { + chunk = chunk[:220] + "..." + } + chunks = append(chunks, "- "+chunk) + } + if len(chunks) > 0 { + b.WriteString("Company Knowledge Index Hints:\n") + b.WriteString(strings.Join(chunks, "\n")) + b.WriteString("\n") + } + } + } + + return strings.TrimSpace(b.String()) +} + +func cosineSimilarity(a, b []float32) float64 { + if len(a) == 0 || len(a) != len(b) { + return 0 + } + var dot, na, nb float64 + for i := range a { + av := float64(a[i]) + bv := float64(b[i]) + dot += av * bv + na += av * av + nb += bv * bv + } + if na == 0 || nb == 0 { + return 0 + } + return dot / (math.Sqrt(na) * math.Sqrt(nb)) +} + +func (s *Service) generateEmbedding(text string) ([]float32, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, nil + } + apiURL := strings.TrimSpace(s.getSettingString("embedding_api_url", "http://localhost:11434")) + model := strings.TrimSpace(s.getSettingString("embedding_model", "nomic-embed-text")) + if apiURL == "" || model == "" { + return nil, fmt.Errorf("embedding provider is not configured") + } + apiURL = strings.TrimRight(apiURL, "/") + + isOpenAICompatible := strings.Contains(apiURL, "/v1") || strings.Contains(apiURL, "openrouter.ai") + endpoint := apiURL + "/api/embeddings" + payload := map[string]interface{}{"model": model, "prompt": text} + if isOpenAICompatible { + endpoint = apiURL + "/embeddings" + payload = map[string]interface{}{"model": model, "input": text} + } + b, _ := json.Marshal(payload) + + req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(b)) + req.Header.Set("Content-Type", "application/json") + if isOpenAICompatible { + apiKey := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) + if apiKey == "" || apiKey == "your_openrouter_api_key_here" { + apiKey = strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + } + client := &http.Client{Timeout: 45 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("embedding endpoint status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var ollamaOut struct { + Embedding []float32 `json:"embedding"` + } + if err := json.Unmarshal(body, &ollamaOut); err == nil && len(ollamaOut.Embedding) > 0 { + return ollamaOut.Embedding, nil + } + + var openAIOut struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + } + if err := json.Unmarshal(body, &openAIOut); err != nil { + return nil, err + } + if len(openAIOut.Data) == 0 || len(openAIOut.Data[0].Embedding) == 0 { + return nil, fmt.Errorf("empty embedding vector") + } + return openAIOut.Data[0].Embedding, nil +} diff --git a/dash/backend/agentos/ops.go b/dash/backend/agentos/ops.go new file mode 100644 index 0000000..daa6d9e --- /dev/null +++ b/dash/backend/agentos/ops.go @@ -0,0 +1,142 @@ +package agentos + +import ( + "fmt" + "sort" + "strings" + + "github.com/google/uuid" +) + +func (s *Service) Topology(companyID string) map[string]interface{} { + departments, _ := s.ListDepartments(companyID) + agents, _ := s.ListAgents(companyID, "") + tasks, _ := s.ListTasks(companyID, "", "", 400) + + agentsByDepartment := map[string][]Agent{} + for _, ag := range agents { + agentsByDepartment[ag.DepartmentID] = append(agentsByDepartment[ag.DepartmentID], ag) + } + for depID := range agentsByDepartment { + sort.Slice(agentsByDepartment[depID], func(i, j int) bool { + return agentsByDepartment[depID][i].CreatedAt.Before(agentsByDepartment[depID][j].CreatedAt) + }) + } + + taskCounts := map[string]int{} + for _, t := range tasks { + taskCounts[t.Status]++ + } + + return map[string]interface{}{ + "departments": departments, + "agents": map[string]interface{}{ + "items": agents, + "by_department": agentsByDepartment, + }, + "tasks": map[string]interface{}{ + "items": tasks, + "status_counts": taskCounts, + }, + } +} + +func (s *Service) GetTaskDetails(taskID string) (map[string]interface{}, error) { + task, err := s.GetTask(taskID) + if err != nil { + return nil, err + } + runs, _ := s.ListRuns(taskID) + children, _ := s.db.Query(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(requested_by,''), IFNULL(parent_task_id,''), IFNULL(thread_id,''), IFNULL(type,''), IFNULL(status,''), IFNULL(priority,50), IFNULL(input_json,''), IFNULL(result_json,''), IFNULL(blocked_reason,''), IFNULL(created_at,''), IFNULL(updated_at,''), IFNULL(completed_at,'') + FROM agent_tasks WHERE parent_task_id = ? ORDER BY created_at ASC + `, taskID) + childTasks := []AgentTask{} + if children != nil { + defer children.Close() + for children.Next() { + t, err := scanTask(children) + if err == nil { + childTasks = append(childTasks, t) + } + } + } + + dRows, _ := s.db.Query(`SELECT id, IFNULL(parent_task_id,''), IFNULL(from_agent_id,''), IFNULL(to_agent_id,''), IFNULL(instruction,''), IFNULL(status,''), IFNULL(created_at,''), IFNULL(updated_at,'') FROM agent_delegations WHERE parent_task_id = ? ORDER BY created_at ASC`, taskID) + delegations := []AgentDelegation{} + if dRows != nil { + defer dRows.Close() + for dRows.Next() { + var d AgentDelegation + var createdAt, updatedAt string + if err := dRows.Scan(&d.ID, &d.ParentTaskID, &d.FromAgentID, &d.ToAgentID, &d.Instruction, &d.Status, &createdAt, &updatedAt); err == nil { + d.CreatedAt = parseDBTime(createdAt) + d.UpdatedAt = parseDBTime(updatedAt) + delegations = append(delegations, d) + } + } + } + + events, _ := s.ListEvents(task.CompanyID, taskID, 0, 200) + return map[string]interface{}{ + "task": task, + "runs": runs, + "children": childTasks, + "delegations": delegations, + "events": events, + }, nil +} + +func (s *Service) DelegateTask(parentTaskID, toAgentID, instruction, requestedBy string) (AgentTask, error) { + parent, err := s.GetTask(parentTaskID) + if err != nil { + return AgentTask{}, err + } + if strings.TrimSpace(toAgentID) == "" { + return AgentTask{}, fmt.Errorf("to_agent_id is required") + } + target, err := s.GetAgent(toAgentID) + if err != nil { + return AgentTask{}, err + } + if target.CompanyID != parent.CompanyID { + return AgentTask{}, fmt.Errorf("target agent belongs to another company") + } + if target.DepartmentID != parent.DepartmentID { + allowed := s.EvaluatePolicy(parent.CompanyID, parent.DepartmentID, parent.AgentID, "delegate_cross_department", fmt.Sprintf("to_agent:%s", target.ID)) + if !allowed.Allowed { + return AgentTask{}, fmt.Errorf("cross-department delegation denied") + } + } + if strings.TrimSpace(instruction) == "" { + instruction = parent.InputJSON + if strings.TrimSpace(instruction) == "" { + instruction = `{"prompt":"Delegated task"}` + } + } + if strings.TrimSpace(requestedBy) == "" { + requestedBy = parent.AgentID + } + + child := AgentTask{ + ID: uuid.NewString(), + CompanyID: parent.CompanyID, + DepartmentID: target.DepartmentID, + AgentID: target.ID, + RequestedBy: requestedBy, + ParentTaskID: parent.ID, + ThreadID: parent.ThreadID, + Type: "delegated", + Status: "queued", + Priority: parent.Priority + 5, + InputJSON: instruction, + } + created, err := s.CreateTask(child) + if err != nil { + return AgentTask{}, err + } + _, _ = s.db.Exec(`INSERT INTO agent_delegations (id, parent_task_id, from_agent_id, to_agent_id, instruction, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'queued', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, uuid.NewString(), parent.ID, parent.AgentID, target.ID, instruction) + s.appendAudit("task_delegated", "task", parent.ID, requestedBy, map[string]interface{}{"to_agent_id": target.ID, "child_task_id": created.ID}) + s.emitEvent(parent.CompanyID, parent.DepartmentID, parent.AgentID, parent.ThreadID, parent.ID, "task_delegated", "info", map[string]interface{}{"to_agent_id": target.ID, "child_task_id": created.ID}) + return created, nil +} diff --git a/dash/backend/agentos/runtime.go b/dash/backend/agentos/runtime.go new file mode 100644 index 0000000..8f8a84a --- /dev/null +++ b/dash/backend/agentos/runtime.go @@ -0,0 +1,628 @@ +package agentos + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/google/uuid" +) + +type runtimeTaskInput struct { + Prompt string `json:"prompt"` +} + +type llmMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func (s *Service) processQueue() { + row := s.db.QueryRow(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(requested_by,''), IFNULL(parent_task_id,''), IFNULL(thread_id,''), IFNULL(type,''), IFNULL(status,''), IFNULL(priority,50), IFNULL(input_json,''), IFNULL(result_json,''), IFNULL(blocked_reason,''), IFNULL(created_at,''), IFNULL(updated_at,''), IFNULL(completed_at,'') + FROM agent_tasks WHERE status = 'queued' + ORDER BY priority ASC, created_at ASC + LIMIT 1 + `) + task, err := scanTask(row) + if err != nil { + return + } + if ok, reason := s.schedulerAdmission(&task); !ok { + // Resource pressure should throttle admission without mutating task state. + if reason == "cpu_hard" || reason == "ram_hard" || reason == "max_workers" { + return + } + if reason != "" && task.Status == "queued" { + _, _ = s.db.Exec("UPDATE agent_tasks SET status='blocked', blocked_reason=?, updated_at=CURRENT_TIMESTAMP WHERE id = ?", reason, task.ID) + s.emitEvent(task.CompanyID, task.DepartmentID, task.AgentID, task.ThreadID, task.ID, "task_blocked", "warn", map[string]interface{}{"reason": reason}) + } + return + } + + s.mu.Lock() + if _, ok := s.running[task.ID]; ok { + s.mu.Unlock() + return + } + s.running[task.ID] = struct{}{} + s.mu.Unlock() + + go func(t AgentTask) { + defer func() { + s.mu.Lock() + delete(s.running, t.ID) + s.mu.Unlock() + }() + s.runTask(t) + }(task) +} + +func (s *Service) runTask(task AgentTask) { + agent, err := s.GetAgent(task.AgentID) + if err != nil { + s.failTask(task, "agent_not_found", err.Error()) + return + } + if !agent.IsActive { + s.failTask(task, "agent_inactive", "agent is inactive") + return + } + + if !s.policyAllowed(task, "task_run", fmt.Sprintf("company:%s/department:%s/agent:%s", task.CompanyID, task.DepartmentID, task.AgentID), "tier2") { + return + } + + _, _ = s.db.Exec("UPDATE agent_tasks SET status='running', blocked_reason='', updated_at=CURRENT_TIMESTAMP WHERE id = ?", task.ID) + s.emitEvent(task.CompanyID, task.DepartmentID, task.AgentID, task.ThreadID, task.ID, "task_running", "info", map[string]interface{}{"task_id": task.ID}) + + runID := uuid.NewString() + _, _ = s.db.Exec(`INSERT INTO agent_runs (id, task_id, attempt, status, provider, model, started_at, ended_at, summary, error) VALUES (?, ?, 1, 'running', '', '', CURRENT_TIMESTAMP, NULL, '', '')`, runID, task.ID) + + if strings.EqualFold(agent.RoleType, "manager") { + if done := s.processManagerTask(task, agent, runID); done { + return + } + } + + output, provider, model, runErr := s.generateAgentOutput(task, agent) + if runErr != nil { + s.failRunAndTask(runID, task, provider, model, runErr) + return + } + + s.extractAndApplySelfSchedule(output, task, agent) + s.extractAndApplyInterAgentMessages(output, task, agent) + + result := map[string]interface{}{ + "output": output, + "provider": provider, + "model": model, + } + resJSON, _ := json.Marshal(result) + + completedAt := time.Now().UTC() + _, _ = s.db.Exec("UPDATE agent_tasks SET status='done', result_json=?, updated_at=CURRENT_TIMESTAMP, completed_at=CURRENT_TIMESTAMP WHERE id = ?", string(resJSON), task.ID) + _, _ = s.db.Exec("UPDATE agent_runs SET status='done', provider=?, model=?, ended_at=CURRENT_TIMESTAMP, summary=? WHERE id = ?", provider, model, summarize(output, 500), runID) + _, _ = s.db.Exec("UPDATE agents SET status='idle', updated_at=CURRENT_TIMESTAMP WHERE id = ?", agent.ID) + + if task.ThreadID != "" { + _, _ = s.AddThreadMessage(task.ThreadID, "agent", output, "text") + } + + _ = s.WriteMemoryEntry(MemoryEntry{ + ID: uuid.NewString(), + ScopeType: "agent_long_term", + ScopeID: agent.ID, + SourceType: "task_result", + AuthorAgentID: agent.ID, + Content: summarize(output, 1600), + TagsJSON: `{"origin":"task","task_id":"` + task.ID + `"}`, + Importance: 0.7, + }) + + if task.ParentTaskID != "" { + _, _ = s.db.Exec("UPDATE agent_delegations SET status='done', updated_at=CURRENT_TIMESTAMP WHERE parent_task_id = ? AND to_agent_id = ?", task.ParentTaskID, task.AgentID) + } + + s.emitEvent(task.CompanyID, task.DepartmentID, task.AgentID, task.ThreadID, task.ID, "task_done", "info", map[string]interface{}{"completed_at": completedAt.Format(time.RFC3339)}) + s.appendAudit("task_done", "task", task.ID, agent.ID, map[string]interface{}{"provider": provider, "model": model}) +} + +func (s *Service) processManagerTask(task AgentTask, agent Agent, runID string) bool { + // If manager has active subtasks, wait for completion and synthesize later. + var totalChildren, pendingChildren int + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE parent_task_id = ?", task.ID).Scan(&totalChildren) + if totalChildren > 0 { + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE parent_task_id = ? AND status IN ('queued','running','blocked','waiting_input')", task.ID).Scan(&pendingChildren) + if pendingChildren > 0 { + _, _ = s.db.Exec("UPDATE agent_tasks SET status='waiting_input', blocked_reason='awaiting_worker_tasks', updated_at=CURRENT_TIMESTAMP WHERE id = ?", task.ID) + _, _ = s.db.Exec("UPDATE agent_runs SET status='done', provider='manager', model='manager', ended_at=CURRENT_TIMESTAMP, summary='waiting for worker tasks' WHERE id = ?", runID) + s.emitEvent(task.CompanyID, task.DepartmentID, task.AgentID, task.ThreadID, task.ID, "task_waiting_workers", "info", map[string]interface{}{"pending_children": pendingChildren}) + return true + } + + rows, err := s.db.Query("SELECT IFNULL(result_json,'') FROM agent_tasks WHERE parent_task_id = ? ORDER BY created_at ASC", task.ID) + if err != nil { + s.failRunAndTask(runID, task, "manager", "manager", err) + return true + } + defer rows.Close() + parts := []string{} + for rows.Next() { + var r string + if err := rows.Scan(&r); err == nil { + parts = append(parts, r) + } + } + summary := "Manager synthesis:\n\n" + strings.Join(parts, "\n\n") + result := map[string]interface{}{"output": summary, "provider": "manager", "model": "manager"} + resJSON, _ := json.Marshal(result) + _, _ = s.db.Exec("UPDATE agent_tasks SET status='done', result_json=?, updated_at=CURRENT_TIMESTAMP, completed_at=CURRENT_TIMESTAMP WHERE id = ?", string(resJSON), task.ID) + _, _ = s.db.Exec("UPDATE agent_runs SET status='done', provider='manager', model='manager', ended_at=CURRENT_TIMESTAMP, summary=? WHERE id = ?", summarize(summary, 500), runID) + if task.ThreadID != "" { + _, _ = s.AddThreadMessage(task.ThreadID, "agent", summary, "text") + } + s.emitEvent(task.CompanyID, task.DepartmentID, task.AgentID, task.ThreadID, task.ID, "task_done", "info", map[string]interface{}{"mode": "manager_synthesis"}) + return true + } + + workers, err := s.ListAgents(task.CompanyID, task.DepartmentID) + if err != nil { + s.failRunAndTask(runID, task, "manager", "manager", err) + return true + } + eligible := []Agent{} + for _, w := range workers { + if w.RoleType != "worker" || !w.IsActive { + continue + } + if strings.TrimSpace(w.ParentAgentID) != "" && w.ParentAgentID != agent.ID { + continue + } + eligible = append(eligible, w) + } + if len(eligible) == 0 { + // No workers; manager executes directly as regular agent. + return false + } + + instruction := task.InputJSON + if strings.TrimSpace(instruction) == "" { + instruction = `{"prompt":"Execute delegated manager task"}` + } + for _, w := range eligible { + if !s.policyAllowed(task, "delegate", fmt.Sprintf("to_agent:%s", w.ID), "tier1") { + continue + } + child := AgentTask{ + ID: uuid.NewString(), + CompanyID: task.CompanyID, + DepartmentID: task.DepartmentID, + AgentID: w.ID, + RequestedBy: agent.ID, + ParentTaskID: task.ID, + ThreadID: task.ThreadID, + Type: "delegated", + Status: "queued", + Priority: task.Priority + 5, + InputJSON: instruction, + } + if _, err := s.CreateTask(child); err == nil { + _, _ = s.db.Exec(`INSERT INTO agent_delegations (id, parent_task_id, from_agent_id, to_agent_id, instruction, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'queued', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, uuid.NewString(), task.ID, agent.ID, w.ID, summarize(instruction, 500)) + s.emitEvent(task.CompanyID, task.DepartmentID, agent.ID, task.ThreadID, task.ID, "task_delegated", "info", map[string]interface{}{"to_agent_id": w.ID, "child_task_id": child.ID}) + } + } + + _, _ = s.db.Exec("UPDATE agent_tasks SET status='waiting_input', blocked_reason='awaiting_worker_tasks', updated_at=CURRENT_TIMESTAMP WHERE id = ?", task.ID) + _, _ = s.db.Exec("UPDATE agent_runs SET status='done', provider='manager', model='manager', ended_at=CURRENT_TIMESTAMP, summary='delegated to worker agents' WHERE id = ?", runID) + return true +} + +func (s *Service) resumeBlockedManagers() { + rows, err := s.db.Query(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(requested_by,''), IFNULL(parent_task_id,''), IFNULL(thread_id,''), IFNULL(type,''), IFNULL(status,''), IFNULL(priority,50), IFNULL(input_json,''), IFNULL(result_json,''), IFNULL(blocked_reason,''), IFNULL(created_at,''), IFNULL(updated_at,''), IFNULL(completed_at,'') + FROM agent_tasks WHERE status = 'waiting_input' AND blocked_reason = 'awaiting_worker_tasks' + `) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + t, err := scanTask(rows) + if err != nil { + continue + } + var pending int + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE parent_task_id = ? AND status IN ('queued','running','blocked','waiting_input')", t.ID).Scan(&pending) + if pending == 0 { + _, _ = s.db.Exec("UPDATE agent_tasks SET status='queued', blocked_reason='', updated_at=CURRENT_TIMESTAMP WHERE id = ?", t.ID) + s.emitEvent(t.CompanyID, t.DepartmentID, t.AgentID, t.ThreadID, t.ID, "task_resumed", "info", map[string]interface{}{"reason": "workers_completed"}) + } + } +} + +func (s *Service) failRunAndTask(runID string, task AgentTask, provider string, model string, err error) { + if runID != "" { + _, _ = s.db.Exec("UPDATE agent_runs SET status='error', provider=?, model=?, ended_at=CURRENT_TIMESTAMP, error=?, summary=? WHERE id = ?", provider, model, err.Error(), summarize(err.Error(), 500), runID) + } + s.failTask(task, "runtime_error", err.Error()) +} + +func (s *Service) failTask(task AgentTask, reason string, details string) { + _, _ = s.db.Exec("UPDATE agent_tasks SET status='failed', blocked_reason=?, updated_at=CURRENT_TIMESTAMP, completed_at=CURRENT_TIMESTAMP WHERE id = ?", reason, task.ID) + _, _ = s.db.Exec("UPDATE agents SET status='error', updated_at=CURRENT_TIMESTAMP WHERE id = ?", task.AgentID) + s.emitEvent(task.CompanyID, task.DepartmentID, task.AgentID, task.ThreadID, task.ID, "task_failed", "error", map[string]interface{}{"reason": reason, "details": details}) + s.appendAudit("task_failed", "task", task.ID, task.AgentID, map[string]interface{}{"reason": reason, "details": details}) +} + +func (s *Service) generateAgentOutput(task AgentTask, agent Agent) (string, string, string, error) { + binding, profile, err := s.getBinding(agent.ID) + if err != nil { + return "", "", "", err + } + + messages := []llmMessage{} + messages = append(messages, llmMessage{Role: "system", Content: strings.TrimSpace(agent.IdentityPrompt)}) + messages = append(messages, llmMessage{Role: "system", Content: "Mode: AgentOS v1. You can reason, write memory, and use two special output blocks:\n\nTo schedule a future task for yourself, output:\n````agenthq_schedule\n{\"type\":\"cron\",\"cron_expr\":\"0 9 * * MON\",\"message\":\"What to do\"}\n````\nor for a one-time trigger:\n````agenthq_schedule\n{\"type\":\"once\",\"start_at\":\"2026-05-28T09:00:00Z\",\"message\":\"What to do\"}\n````\n\nTo send a direct message to another agent, output:\n````agenthq_message\n{\"to_agent_id\":\"\",\"content\":\"Your message here\"}\n````"}) + messages = append(messages, llmMessage{Role: "system", Content: s.memoryContextForAgent(task.CompanyID, task.DepartmentID, agent.ID)}) + + if task.ThreadID != "" { + recent, _ := s.ListThreadMessages(task.ThreadID) + if len(recent) > 24 { + recent = recent[len(recent)-24:] + } + for _, m := range recent { + role := "user" + switch m.Role { + case "agent": + role = "assistant" + case "system": + role = "system" + default: + role = "user" + } + messages = append(messages, llmMessage{Role: role, Content: m.Content}) + } + } + + if strings.TrimSpace(task.InputJSON) != "" { + var in runtimeTaskInput + if err := json.Unmarshal([]byte(task.InputJSON), &in); err == nil && strings.TrimSpace(in.Prompt) != "" { + messages = append(messages, llmMessage{Role: "user", Content: in.Prompt}) + } else { + messages = append(messages, llmMessage{Role: "user", Content: task.InputJSON}) + } + } + + // Resolve owner user ID for per-user API key lookup + ownerUserID := "admin" + _ = s.db.QueryRow(`SELECT IFNULL(owner_user_id,'admin') FROM companies WHERE id = ?`, task.CompanyID).Scan(&ownerUserID) + + output, provider, model, err := s.callProvider(ownerUserID, profile, binding, messages) + if err != nil { + return "", provider, model, err + } + return output, provider, model, nil +} + +func (s *Service) callProvider(ownerUserID string, profile AgentModelProfile, binding AgentModelBinding, messages []llmMessage) (string, string, string, error) { + provider := strings.ToLower(strings.TrimSpace(profile.Provider)) + model := strings.TrimSpace(profile.Model) + if provider == "" { + provider = "openrouter" + } + if model == "" { + model = s.getUserSettingString(ownerUserID, "default_model", "") + } + + chain := []string{provider} + var fallback []string + _ = json.Unmarshal([]byte(profile.FallbackChainJSON), &fallback) + for _, p := range fallback { + p = strings.TrimSpace(strings.ToLower(p)) + if p != "" && p != provider { + chain = append(chain, p) + } + } + + var lastErr error + for _, candidate := range unique(chain) { + out, usedModel, err := s.callProviderOnce(ownerUserID, candidate, model, binding, messages) + if err == nil { + return strings.TrimSpace(out), candidate, usedModel, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("no provider available") + } + return "", provider, model, lastErr +} + +func (s *Service) callProviderOnce(ownerUserID, provider, model string, binding AgentModelBinding, messages []llmMessage) (string, string, error) { + switch provider { + case "openrouter": + return s.callOpenRouter(ownerUserID, model, binding, messages) + case "local": + return s.callLocal(model, binding, messages) + case "cli_codex": + return s.callCLI("codex", model, messages) + case "cli_claude": + return s.callCLI("claude", model, messages) + case "cli_gemini": + return s.callCLI("gemini", model, messages) + default: + return "", model, fmt.Errorf("unsupported provider %s", provider) + } +} + +func (s *Service) callOpenRouter(ownerUserID, model string, binding AgentModelBinding, messages []llmMessage) (string, string, error) { + // User's personal key takes priority over server-wide env key + apiKey := "" + if ownerUserID != "" && ownerUserID != "admin" { + _ = s.db.QueryRow(`SELECT COALESCE(openrouter_api_key,'') FROM users WHERE id = ?`, ownerUserID).Scan(&apiKey) + apiKey = strings.TrimSpace(apiKey) + } + if apiKey == "" { + apiKey = strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) + } + if apiKey == "" || apiKey == "your_openrouter_api_key_here" { + return "", model, fmt.Errorf("OPENROUTER_API_KEY missing") + } + payload := map[string]interface{}{ + "model": model, + "messages": messages, + "stream": false, + } + if binding.MaxTokens > 0 { + payload["max_tokens"] = binding.MaxTokens + } + if binding.Temperature > 0 { + payload["temperature"] = binding.Temperature + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPost, "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Title", "Apollo AgentOS") + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", model, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", model, fmt.Errorf("openrouter status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + var parsed struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", model, err + } + if len(parsed.Choices) == 0 { + return "", model, fmt.Errorf("empty choices") + } + return parsed.Choices[0].Message.Content, model, nil +} + +func (s *Service) callLocal(model string, binding AgentModelBinding, messages []llmMessage) (string, string, error) { + baseURL := strings.TrimSpace(os.Getenv("OLLAMA_API_URL")) + if baseURL == "" { + baseURL = strings.TrimSpace(s.getSettingString("embedding_api_url", "http://localhost:11434")) + } + if strings.HasSuffix(baseURL, "/api") { + baseURL = strings.TrimSuffix(baseURL, "/api") + } + payload := map[string]interface{}{ + "model": model, + "messages": messages, + "stream": false, + } + if binding.MaxTokens > 0 { + payload["max_tokens"] = binding.MaxTokens + } + if binding.Temperature > 0 { + payload["temperature"] = binding.Temperature + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPost, strings.TrimRight(baseURL, "/")+"/v1/chat/completions", bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer local") + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", model, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", model, fmt.Errorf("local status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + var parsed struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", model, err + } + if len(parsed.Choices) == 0 { + return "", model, fmt.Errorf("empty choices") + } + return parsed.Choices[0].Message.Content, model, nil +} + +func (s *Service) callCLI(cliName, model string, messages []llmMessage) (string, string, error) { + if _, err := exec.LookPath(cliName); err != nil { + return "", model, fmt.Errorf("%s not installed", cliName) + } + prompt := "" + for _, m := range messages { + if m.Role == "system" { + prompt += "[SYSTEM]\n" + m.Content + "\n\n" + } else if m.Role == "assistant" { + prompt += "[ASSISTANT]\n" + m.Content + "\n\n" + } else { + prompt += "[USER]\n" + m.Content + "\n\n" + } + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + defer cancel() + + var cmd *exec.Cmd + switch cliName { + case "codex": + cmd = exec.CommandContext(ctx, "codex", "--model", model, "--dangerously-bypass-approvals-and-sandbox", prompt) + case "claude": + cmd = exec.CommandContext(ctx, "claude", "--model", model, "--dangerously-skip-permissions", "-p", prompt) + default: + cmd = exec.CommandContext(ctx, cliName, "-p", prompt) + } + out, err := cmd.CombinedOutput() + if err != nil { + return "", model, fmt.Errorf("%s failed: %s", cliName, strings.TrimSpace(string(out))) + } + return string(out), model, nil +} + +func summarize(text string, max int) string { + clean := strings.TrimSpace(text) + if max <= 0 || len(clean) <= max { + return clean + } + return clean[:max] + "..." +} + +func unique(items []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(items)) + for _, it := range items { + it = strings.TrimSpace(it) + if it == "" { + continue + } + if _, ok := seen[it]; ok { + continue + } + seen[it] = struct{}{} + out = append(out, it) + } + return out +} + +func (s *Service) policyAllowed(task AgentTask, action string, scope string, fallbackTier string) bool { + dec := s.EvaluatePolicy(task.CompanyID, task.DepartmentID, task.AgentID, action, scope) + if dec.Allowed && normalizeTier(dec.ApprovalTier) == "none" { + return true + } + tier := normalizeTier(dec.ApprovalTier) + if tier == "none" { + tier = normalizeTier(fallbackTier) + } + if tier == "none" { + tier = "tier2" + } + _ = s.RequestApproval(task.CompanyID, task.ID, action, tier, dec.Reason, map[string]interface{}{"scope": scope, "decision": dec}) + _, _ = s.db.Exec("UPDATE agent_tasks SET status='blocked', blocked_reason='approval_pending', updated_at=CURRENT_TIMESTAMP WHERE id = ?", task.ID) + s.emitEvent(task.CompanyID, task.DepartmentID, task.AgentID, task.ThreadID, task.ID, "task_blocked", "warn", map[string]interface{}{"reason": "approval_pending", "action": action}) + return false +} + +func (s *Service) threadForTask(taskID string) (AgentTask, AgentThread, error) { + task, err := s.GetTask(taskID) + if err != nil { + return AgentTask{}, AgentThread{}, err + } + if strings.TrimSpace(task.ThreadID) == "" { + return task, AgentThread{}, sql.ErrNoRows + } + thread, err := s.GetThread(task.ThreadID) + return task, thread, err +} + +func (s *Service) BuildTaskPromptInput(prompt string) string { + payload := runtimeTaskInput{Prompt: strings.TrimSpace(prompt)} + b, _ := json.Marshal(payload) + return string(b) +} + +// extractAndApplySelfSchedule parses ````agenthq_schedule ... ```` blocks from agent output +// and creates schedules targeting the agent itself. +func (s *Service) extractAndApplySelfSchedule(output string, task AgentTask, agent Agent) { + re := regexp.MustCompile("(?s)````agenthq_schedule\n(.*?)````") + matches := re.FindAllStringSubmatch(output, -1) + for _, m := range matches { + var cmd struct { + Type string `json:"type"` + CronExpr string `json:"cron_expr"` + StartAt string `json:"start_at"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(m[1])), &cmd); err != nil { + continue + } + schedType := cmd.Type + if schedType == "" { + if cmd.CronExpr != "" { + schedType = "cron" + } else { + schedType = "once" + } + } + payload, _ := json.Marshal(map[string]string{"prompt": cmd.Message}) + sch := Schedule{ + ID: uuid.NewString(), + CompanyID: task.CompanyID, + DepartmentID: task.DepartmentID, + TargetAgentID: agent.ID, + ScheduleType: schedType, + CronExpr: cmd.CronExpr, + StartAt: cmd.StartAt, + Timezone: "UTC", + PayloadJSON: string(payload), + IsActive: true, + } + if _, err := s.CreateSchedule(sch); err == nil { + s.emitEvent(task.CompanyID, task.DepartmentID, agent.ID, task.ThreadID, task.ID, "agent_self_scheduled", "info", map[string]interface{}{"schedule_type": schedType}) + } + } +} + +// extractAndApplyInterAgentMessages parses ````agenthq_message ... ```` blocks and delivers them. +func (s *Service) extractAndApplyInterAgentMessages(output string, task AgentTask, agent Agent) { + re := regexp.MustCompile("(?s)````agenthq_message\n(.*?)````") + matches := re.FindAllStringSubmatch(output, -1) + for _, m := range matches { + var cmd struct { + ToAgentID string `json:"to_agent_id"` + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(m[1])), &cmd); err != nil { + continue + } + if cmd.ToAgentID == "" || cmd.Content == "" { + continue + } + if err := s.PostInterAgentMessage(task.CompanyID, agent.ID, cmd.ToAgentID, cmd.Content); err == nil { + s.emitEvent(task.CompanyID, task.DepartmentID, agent.ID, task.ThreadID, task.ID, "inter_agent_message_sent", "info", map[string]interface{}{"to_agent_id": cmd.ToAgentID}) + } + } +} diff --git a/dash/backend/agentos/scheduler.go b/dash/backend/agentos/scheduler.go new file mode 100644 index 0000000..1aa87d9 --- /dev/null +++ b/dash/backend/agentos/scheduler.go @@ -0,0 +1,524 @@ +package agentos + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/mem" +) + +type SchedulerConfig struct { + Mode string `json:"mode"` + MinWorkers int `json:"min_workers"` + MaxWorkers int `json:"max_workers"` + MinReviewers int `json:"min_reviewers"` + MaxReviewers int `json:"max_reviewers"` + CPUSoft float64 `json:"cpu_soft"` + CPUHard float64 `json:"cpu_hard"` + RAMSoft float64 `json:"ram_soft"` + RAMHard float64 `json:"ram_hard"` +} + +func (s *Service) schedulerConfig() SchedulerConfig { + cfg := SchedulerConfig{ + Mode: s.getSettingString("agentos_scheduler_mode", "adaptive"), + MinWorkers: toInt(s.getSettingString("agentos_scheduler_min_workers", "1"), 1), + MaxWorkers: toInt(s.getSettingString("agentos_scheduler_max_workers", "4"), 4), + MinReviewers: toInt(s.getSettingString("agentos_scheduler_min_reviewers", "1"), 1), + MaxReviewers: toInt(s.getSettingString("agentos_scheduler_max_reviewers", "2"), 2), + CPUSoft: toFloat(s.getSettingString("agentos_scheduler_cpu_soft", "70"), 70), + CPUHard: toFloat(s.getSettingString("agentos_scheduler_cpu_hard", "90"), 90), + RAMSoft: toFloat(s.getSettingString("agentos_scheduler_ram_soft", "75"), 75), + RAMHard: toFloat(s.getSettingString("agentos_scheduler_ram_hard", "90"), 90), + } + if cfg.MinWorkers < 1 { + cfg.MinWorkers = 1 + } + if cfg.MaxWorkers < cfg.MinWorkers { + cfg.MaxWorkers = cfg.MinWorkers + } + if cfg.MaxReviewers < cfg.MinReviewers { + cfg.MaxReviewers = cfg.MinReviewers + } + return cfg +} + +func (s *Service) runningTaskCount() int { + var n int + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status = 'running'").Scan(&n) + return n +} + +func (s *Service) currentResourcePressure() (cpuPercent float64, ramPercent float64) { + cpuPercent = 25 + ramPercent = 35 + if samples, err := cpu.Percent(0, false); err == nil && len(samples) > 0 { + cpuPercent = samples[0] + } + if vm, err := mem.VirtualMemory(); err == nil { + ramPercent = vm.UsedPercent + } + return cpuPercent, ramPercent +} + +func (s *Service) schedulerAdmission(task *AgentTask) (bool, string) { + if strings.EqualFold(s.getSettingString("agentos_kill_switch", "false"), "true") { + return false, "kill_switch" + } + cfg := s.schedulerConfig() + cpuP, ramP := s.currentResourcePressure() + if cpuP >= cfg.CPUHard { + return false, "cpu_hard" + } + if ramP >= cfg.RAMHard { + return false, "ram_hard" + } + if s.runningTaskCount() >= cfg.MaxWorkers { + return false, "max_workers" + } + if task != nil { + if task.Status == "blocked" && task.BlockedReason != "" { + return false, task.BlockedReason + } + } + return true, "" +} + +func (s *Service) SchedulerState() map[string]interface{} { + cfg := s.schedulerConfig() + cpuP, ramP := s.currentResourcePressure() + queued := 0 + running := 0 + blocked := 0 + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status = 'queued'").Scan(&queued) + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status = 'running'").Scan(&running) + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status = 'blocked'").Scan(&blocked) + + return map[string]interface{}{ + "config": cfg, + "host_pressure": map[string]interface{}{ + "cpu_percent": cpuP, + "ram_percent": ramP, + "updated_at": time.Now().UTC().Format(time.RFC3339), + }, + "queue": map[string]interface{}{ + "queued": queued, + "running": running, + "blocked": blocked, + }, + } +} + +func (s *Service) UpdateSchedulerConfig(payload map[string]interface{}) error { + for _, key := range []string{ + "agentos_scheduler_mode", + "agentos_scheduler_min_workers", + "agentos_scheduler_max_workers", + "agentos_scheduler_min_reviewers", + "agentos_scheduler_max_reviewers", + "agentos_scheduler_cpu_soft", + "agentos_scheduler_cpu_hard", + "agentos_scheduler_ram_soft", + "agentos_scheduler_ram_hard", + } { + if v, ok := payload[key]; ok { + _, _ = s.db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, stringify(v)) + } + } + s.appendAudit("scheduler_updated", "scheduler", "agentos", "owner", payload) + return nil +} + +func stringify(v interface{}) string { + switch t := v.(type) { + case string: + return t + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(t), 'f', -1, 64) + case int: + return strconv.Itoa(t) + case int64: + return strconv.FormatInt(t, 10) + case bool: + if t { + return "true" + } + return "false" + default: + b, _ := json.Marshal(v) + return string(b) + } +} + +func toInt(v string, fallback int) int { + i, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return fallback + } + return i +} + +func toFloat(v string, fallback float64) float64 { + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return fallback + } + return f +} + +func parseAnyTime(value string, fallback time.Time) time.Time { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"} + for _, layout := range layouts { + if t, err := time.Parse(layout, value); err == nil { + return t + } + } + return fallback +} + +func nextFromCron(expr string, from time.Time, loc *time.Location) time.Time { + parts := strings.Fields(strings.TrimSpace(expr)) + if len(parts) != 5 { + return from.Add(1 * time.Hour) + } + minute := parts[0] + hour := parts[1] + + base := from.In(loc).Truncate(time.Minute).Add(time.Minute) + + if minute == "*" && hour == "*" { + return base + } + if strings.HasPrefix(minute, "*/") && hour == "*" { + n, err := strconv.Atoi(strings.TrimPrefix(minute, "*/")) + if err != nil || n <= 0 { + return from.Add(1 * time.Hour) + } + m := base.Minute() + nextMin := ((m / n) + 1) * n + delta := nextMin - m + if delta <= 0 { + delta += n + } + return base.Add(time.Duration(delta) * time.Minute) + } + if hour == "*" { + m, err := strconv.Atoi(minute) + if err != nil || m < 0 || m > 59 { + return from.Add(1 * time.Hour) + } + t := time.Date(base.Year(), base.Month(), base.Day(), base.Hour(), m, 0, 0, loc) + if !t.After(from.In(loc)) { + t = t.Add(1 * time.Hour) + } + return t + } + + m, errM := strconv.Atoi(minute) + h, errH := strconv.Atoi(hour) + if errM != nil || errH != nil || m < 0 || m > 59 || h < 0 || h > 23 { + return from.Add(1 * time.Hour) + } + t := time.Date(base.Year(), base.Month(), base.Day(), h, m, 0, 0, loc) + if !t.After(from.In(loc)) { + t = t.Add(24 * time.Hour) + } + return t +} + +func nextFromCalendar(rrule string, start time.Time, from time.Time) time.Time { + rule := strings.ToUpper(strings.TrimSpace(rrule)) + if rule == "" { + if start.After(from) { + return start + } + return from.Add(24 * time.Hour) + } + + freq := "DAILY" + interval := 1 + for _, chunk := range strings.Split(rule, ";") { + parts := strings.SplitN(strings.TrimSpace(chunk), "=", 2) + if len(parts) != 2 { + continue + } + switch parts[0] { + case "FREQ": + freq = strings.TrimSpace(parts[1]) + case "INTERVAL": + if iv, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && iv > 0 { + interval = iv + } + } + } + + next := start + if next.IsZero() { + next = from + } + for !next.After(from) { + switch freq { + case "HOURLY": + next = next.Add(time.Duration(interval) * time.Hour) + case "WEEKLY": + next = next.AddDate(0, 0, 7*interval) + case "MONTHLY": + next = next.AddDate(0, interval, 0) + default: + next = next.AddDate(0, 0, interval) + } + } + return next +} + +func (s *Service) CreateSchedule(input Schedule) (Schedule, error) { + if strings.TrimSpace(input.CompanyID) == "" || strings.TrimSpace(input.TargetAgentID) == "" { + return Schedule{}, fmt.Errorf("company_id and target_agent_id are required") + } + input.ScheduleType = strings.ToLower(strings.TrimSpace(input.ScheduleType)) + if input.ScheduleType == "" { + input.ScheduleType = "calendar" + } + if input.ScheduleType != "cron" && input.ScheduleType != "calendar" && input.ScheduleType != "once" { + return Schedule{}, fmt.Errorf("schedule_type must be cron, calendar, or once") + } + if strings.TrimSpace(input.Timezone) == "" { + input.Timezone = "UTC" + } + loc, err := time.LoadLocation(input.Timezone) + if err != nil { + loc = time.UTC + input.Timezone = "UTC" + } + if strings.TrimSpace(input.PayloadJSON) == "" { + input.PayloadJSON = `{"prompt":"Scheduled agent task"}` + } + if input.ID == "" { + input.ID = uuid.NewString() + } + + now := time.Now().In(loc) + start := parseAnyTime(input.StartAt, now) + nextRun := start + if input.ScheduleType == "once" { + if strings.TrimSpace(input.StartAt) == "" { + return Schedule{}, fmt.Errorf("start_at is required for schedule_type once") + } + nextRun = start + } else if input.ScheduleType == "cron" { + nextRun = nextFromCron(input.CronExpr, now, loc) + } else { + nextRun = nextFromCalendar(input.RRule, start, now) + } + + activeInt := 1 + if !input.IsActive && input.IsActive != true { + activeInt = 0 + } + + _, err = s.db.Exec(` + INSERT INTO schedules (id, company_id, department_id, target_agent_id, schedule_type, cron_expr, rrule, start_at, next_run_at, timezone, payload_json, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, input.ID, input.CompanyID, input.DepartmentID, input.TargetAgentID, input.ScheduleType, input.CronExpr, input.RRule, start.UTC().Format(time.RFC3339), nextRun.UTC().Format(time.RFC3339), input.Timezone, input.PayloadJSON, activeInt) + if err != nil { + return Schedule{}, err + } + s.appendAudit("schedule_created", "schedule", input.ID, "owner", map[string]interface{}{"company_id": input.CompanyID, "target_agent_id": input.TargetAgentID}) + s.emitEvent(input.CompanyID, input.DepartmentID, input.TargetAgentID, "", "", "schedule_created", "info", map[string]interface{}{"schedule_id": input.ID}) + return s.GetSchedule(input.ID) +} + +func (s *Service) GetSchedule(id string) (Schedule, error) { + var sch Schedule + var activeInt int + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(target_agent_id,''), IFNULL(schedule_type,'calendar'), IFNULL(cron_expr,''), IFNULL(rrule,''), IFNULL(start_at,''), IFNULL(next_run_at,''), IFNULL(timezone,'UTC'), IFNULL(payload_json,'{}'), IFNULL(is_active,1), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM schedules WHERE id = ? + `, id).Scan(&sch.ID, &sch.CompanyID, &sch.DepartmentID, &sch.TargetAgentID, &sch.ScheduleType, &sch.CronExpr, &sch.RRule, &sch.StartAt, &sch.NextRunAt, &sch.Timezone, &sch.PayloadJSON, &activeInt, &createdAt, &updatedAt) + if err != nil { + return Schedule{}, err + } + sch.IsActive = activeInt == 1 + sch.CreatedAt = parseDBTime(createdAt) + sch.UpdatedAt = parseDBTime(updatedAt) + return sch, nil +} + +func (s *Service) ListSchedules(companyID, departmentID string) ([]Schedule, error) { + query := ` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(target_agent_id,''), IFNULL(schedule_type,'calendar'), IFNULL(cron_expr,''), IFNULL(rrule,''), IFNULL(start_at,''), IFNULL(next_run_at,''), IFNULL(timezone,'UTC'), IFNULL(payload_json,'{}'), IFNULL(is_active,1), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM schedules WHERE 1=1` + args := []interface{}{} + if strings.TrimSpace(companyID) != "" { + query += " AND company_id = ?" + args = append(args, companyID) + } + if strings.TrimSpace(departmentID) != "" { + query += " AND department_id = ?" + args = append(args, departmentID) + } + query += " ORDER BY next_run_at ASC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Schedule{} + for rows.Next() { + var sch Schedule + var activeInt int + var createdAt, updatedAt string + if err := rows.Scan(&sch.ID, &sch.CompanyID, &sch.DepartmentID, &sch.TargetAgentID, &sch.ScheduleType, &sch.CronExpr, &sch.RRule, &sch.StartAt, &sch.NextRunAt, &sch.Timezone, &sch.PayloadJSON, &activeInt, &createdAt, &updatedAt); err != nil { + continue + } + sch.IsActive = activeInt == 1 + sch.CreatedAt = parseDBTime(createdAt) + sch.UpdatedAt = parseDBTime(updatedAt) + out = append(out, sch) + } + return out, nil +} + +func (s *Service) UpdateSchedule(id string, patch Schedule) (Schedule, error) { + cur, err := s.GetSchedule(id) + if err != nil { + return Schedule{}, err + } + if strings.TrimSpace(patch.ScheduleType) != "" { + cur.ScheduleType = strings.ToLower(strings.TrimSpace(patch.ScheduleType)) + } + if strings.TrimSpace(patch.CronExpr) != "" { + cur.CronExpr = patch.CronExpr + } + if strings.TrimSpace(patch.RRule) != "" { + cur.RRule = patch.RRule + } + if strings.TrimSpace(patch.StartAt) != "" { + cur.StartAt = patch.StartAt + } + if strings.TrimSpace(patch.Timezone) != "" { + cur.Timezone = patch.Timezone + } + if strings.TrimSpace(patch.PayloadJSON) != "" { + cur.PayloadJSON = patch.PayloadJSON + } + + loc, err := time.LoadLocation(cur.Timezone) + if err != nil { + loc = time.UTC + cur.Timezone = "UTC" + } + now := time.Now().In(loc) + start := parseAnyTime(cur.StartAt, now) + next := start + if cur.ScheduleType == "cron" { + next = nextFromCron(cur.CronExpr, now, loc) + } else { + next = nextFromCalendar(cur.RRule, start, now) + } + + _, err = s.db.Exec(` + UPDATE schedules + SET schedule_type = ?, cron_expr = ?, rrule = ?, start_at = ?, next_run_at = ?, timezone = ?, payload_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, cur.ScheduleType, cur.CronExpr, cur.RRule, start.UTC().Format(time.RFC3339), next.UTC().Format(time.RFC3339), cur.Timezone, cur.PayloadJSON, id) + if err != nil { + return Schedule{}, err + } + s.appendAudit("schedule_updated", "schedule", id, "owner", cur) + return s.GetSchedule(id) +} + +func (s *Service) ToggleSchedule(id string, enabled bool) error { + v := 0 + if enabled { + v = 1 + } + _, err := s.db.Exec("UPDATE schedules SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", v, id) + if err == nil { + s.appendAudit("schedule_toggled", "schedule", id, "owner", map[string]interface{}{"enabled": enabled}) + } + return err +} + +func (s *Service) runSchedules() { + now := time.Now().UTC() + rows, err := s.db.Query(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(target_agent_id,''), IFNULL(schedule_type,'calendar'), IFNULL(cron_expr,''), IFNULL(rrule,''), IFNULL(start_at,''), IFNULL(next_run_at,''), IFNULL(timezone,'UTC'), IFNULL(payload_json,'{}'), IFNULL(is_active,1), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM schedules + WHERE is_active = 1 AND next_run_at != '' AND next_run_at <= ? + ORDER BY next_run_at ASC + LIMIT 20 + `, now.Format(time.RFC3339)) + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var sch Schedule + var activeInt int + var createdAt, updatedAt string + if err := rows.Scan(&sch.ID, &sch.CompanyID, &sch.DepartmentID, &sch.TargetAgentID, &sch.ScheduleType, &sch.CronExpr, &sch.RRule, &sch.StartAt, &sch.NextRunAt, &sch.Timezone, &sch.PayloadJSON, &activeInt, &createdAt, &updatedAt); err != nil { + continue + } + sch.IsActive = activeInt == 1 + + payload := strings.TrimSpace(sch.PayloadJSON) + if payload == "" { + payload = `{"prompt":"Scheduled task"}` + } + task := AgentTask{ + ID: uuid.NewString(), + CompanyID: sch.CompanyID, + DepartmentID: sch.DepartmentID, + AgentID: sch.TargetAgentID, + RequestedBy: "scheduler", + Type: "scheduled", + Status: "queued", + Priority: 40, + InputJSON: payload, + } + createdTask, err := s.CreateTask(task) + if err != nil { + _, _ = s.db.Exec(`INSERT INTO schedule_runs (id, schedule_id, task_id, status, triggered_at, finished_at, error) VALUES (?, ?, '', 'failed', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)`, uuid.NewString(), sch.ID, err.Error()) + s.emitEvent(sch.CompanyID, sch.DepartmentID, sch.TargetAgentID, "", "", "schedule_failed", "error", map[string]interface{}{"schedule_id": sch.ID, "error": err.Error()}) + continue + } + _, _ = s.db.Exec(`INSERT INTO schedule_runs (id, schedule_id, task_id, status, triggered_at, finished_at, error) VALUES (?, ?, ?, 'triggered', CURRENT_TIMESTAMP, NULL, '')`, uuid.NewString(), sch.ID, createdTask.ID) + + if sch.ScheduleType == "once" { + _, _ = s.db.Exec("UPDATE schedules SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?", sch.ID) + s.appendAudit("schedule_triggered", "schedule", sch.ID, "scheduler", map[string]interface{}{"task_id": createdTask.ID, "once": true}) + } else { + loc, err := time.LoadLocation(sch.Timezone) + if err != nil { + loc = time.UTC + } + next := now.In(loc) + if sch.ScheduleType == "cron" { + next = nextFromCron(sch.CronExpr, now.In(loc), loc) + } else { + start := parseAnyTime(sch.StartAt, now.In(loc)) + next = nextFromCalendar(sch.RRule, start, now.In(loc)) + } + _, _ = s.db.Exec("UPDATE schedules SET next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", next.UTC().Format(time.RFC3339), sch.ID) + s.appendAudit("schedule_triggered", "schedule", sch.ID, "scheduler", map[string]interface{}{"task_id": createdTask.ID, "next_run_at": next.UTC().Format(time.RFC3339)}) + } + s.emitEvent(sch.CompanyID, sch.DepartmentID, sch.TargetAgentID, createdTask.ThreadID, createdTask.ID, "schedule_triggered", "info", map[string]interface{}{"schedule_id": sch.ID, "task_id": createdTask.ID}) + } +} diff --git a/dash/backend/agentos/service.go b/dash/backend/agentos/service.go new file mode 100644 index 0000000..71a412b --- /dev/null +++ b/dash/backend/agentos/service.go @@ -0,0 +1,1164 @@ +package agentos + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +type Service struct { + db *sql.DB + workspaceRoot string + + mu sync.Mutex + running map[string]struct{} + subscribers map[string]map[chan AgentEvent]struct{} + + queueTick time.Duration + schedulerTick time.Duration +} + +func NewService(db *sql.DB, workspaceRoot string) *Service { + return &Service{ + db: db, + workspaceRoot: workspaceRoot, + running: map[string]struct{}{}, + subscribers: map[string]map[chan AgentEvent]struct{}{}, + queueTick: 2 * time.Second, + schedulerTick: 1 * time.Minute, + } +} + +func (s *Service) Start(ctx context.Context) { + s.migrateLegacyChats() + + qTicker := time.NewTicker(s.queueTick) + sTicker := time.NewTicker(s.schedulerTick) + defer qTicker.Stop() + defer sTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-qTicker.C: + s.processQueue() + s.resumeBlockedManagers() + case <-sTicker.C: + s.runSchedules() + } + } +} + +func (s *Service) Subscribe(companyID string) (<-chan AgentEvent, func()) { + ch := make(chan AgentEvent, 128) + + s.mu.Lock() + if s.subscribers[companyID] == nil { + s.subscribers[companyID] = map[chan AgentEvent]struct{}{} + } + s.subscribers[companyID][ch] = struct{}{} + s.mu.Unlock() + + cancel := func() { + s.mu.Lock() + if subs := s.subscribers[companyID]; subs != nil { + delete(subs, ch) + if len(subs) == 0 { + delete(s.subscribers, companyID) + } + } + s.mu.Unlock() + close(ch) + } + + return ch, cancel +} + +func (s *Service) emitEvent(companyID, departmentID, agentID, threadID, taskID, eventType, severity string, payload interface{}) { + if strings.TrimSpace(severity) == "" { + severity = "info" + } + payloadJSON := "{}" + if payload != nil { + if b, err := json.Marshal(payload); err == nil { + payloadJSON = string(b) + } + } + + res, err := s.db.Exec(` + INSERT INTO agent_events (company_id, department_id, agent_id, thread_id, task_id, event_type, severity, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, companyID, departmentID, agentID, threadID, taskID, eventType, severity, payloadJSON) + if err != nil { + log.Printf("agentos event insert failed: %v", err) + return + } + id, _ := res.LastInsertId() + ev := AgentEvent{ + ID: id, + CompanyID: companyID, + DepartmentID: departmentID, + AgentID: agentID, + ThreadID: threadID, + TaskID: taskID, + EventType: eventType, + Severity: severity, + PayloadJSON: payloadJSON, + CreatedAt: time.Now(), + } + + s.mu.Lock() + for cid, subs := range s.subscribers { + if cid != "" && cid != companyID { + continue + } + for ch := range subs { + select { + case ch <- ev: + default: + } + } + } + s.mu.Unlock() +} + +func (s *Service) ListEvents(companyID, taskID string, sinceID int64, limit int) ([]AgentEvent, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + query := ` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(thread_id,''), IFNULL(task_id,''), IFNULL(event_type,''), IFNULL(severity,'info'), IFNULL(payload_json,'{}'), IFNULL(created_at,'') + FROM agent_events WHERE id > ?` + args := []interface{}{sinceID} + if strings.TrimSpace(companyID) != "" { + query += " AND company_id = ?" + args = append(args, companyID) + } + if strings.TrimSpace(taskID) != "" { + query += " AND task_id = ?" + args = append(args, taskID) + } + query += " ORDER BY id ASC LIMIT ?" + args = append(args, limit) + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []AgentEvent{} + for rows.Next() { + var ev AgentEvent + var createdAt string + if err := rows.Scan(&ev.ID, &ev.CompanyID, &ev.DepartmentID, &ev.AgentID, &ev.ThreadID, &ev.TaskID, &ev.EventType, &ev.Severity, &ev.PayloadJSON, &createdAt); err != nil { + continue + } + ev.CreatedAt = parseDBTime(createdAt) + out = append(out, ev) + } + return out, nil +} + +func (s *Service) HealthStatus() map[string]interface{} { + var queued, running, blocked, waiting int + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status='queued'").Scan(&queued) + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status='running'").Scan(&running) + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status='blocked'").Scan(&blocked) + _ = s.db.QueryRow("SELECT COUNT(1) FROM agent_tasks WHERE status='waiting_input'").Scan(&waiting) + + return map[string]interface{}{ + "queue": map[string]interface{}{ + "queued": queued, + "running": running, + "blocked": blocked, + "waiting_input": waiting, + }, + "scheduler": s.SchedulerState(), + "audit": s.AuditVerify(), + "updated_at": time.Now().UTC().Format(time.RFC3339), + } +} + +func (s *Service) cleanWorkspacePath(raw string) string { + path := strings.TrimSpace(raw) + if path == "" { + return "" + } + if !filepath.IsAbs(path) && strings.TrimSpace(s.workspaceRoot) != "" { + path = filepath.Join(s.workspaceRoot, path) + } + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + return filepath.Clean(path) +} + +func (s *Service) CreateCompany(ownerUserID, name, description, timezone, workspacePath, deployCommand string) (Company, error) { + ownerUserID = strings.TrimSpace(ownerUserID) + if ownerUserID == "" { + return Company{}, fmt.Errorf("owner_user_id is required") + } + name = strings.TrimSpace(name) + if name == "" { + return Company{}, fmt.Errorf("name is required") + } + if strings.TrimSpace(timezone) == "" { + timezone = "UTC" + } + workspacePath = s.cleanWorkspacePath(workspacePath) + deployCommand = strings.TrimSpace(deployCommand) + id := uuid.NewString() + slug := slugify(name + "-" + ownerUserID) + _, err := s.db.Exec(` + INSERT INTO companies (id, owner_user_id, name, slug, description, status, timezone, workspace_path, deploy_command, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, id, ownerUserID, name, slug, description, timezone, workspacePath, deployCommand) + if err != nil { + return Company{}, err + } + + dept, err := s.CreateDepartment(id, "General", "general", "Default department") + if err == nil { + _, _ = s.CreateAgent(id, dept.ID, "Core Agent", "manager", "", "Primary coordinator agent") + } + // Bootstrap allow rules so a newly created company is operational without manual policy seeding. + for _, action := range []string{"task_run", "delegate", "memory_write", "schedule_mutate", "hierarchy_mutate", "model_bind", "thread_write"} { + _, _ = s.UpsertPolicy(PolicyRule{ + CompanyID: id, + DepartmentID: "", + AgentID: "", + Action: action, + Effect: "allow", + ScopePattern: "*", + ApprovalTier: "none", + }) + } + + s.appendAudit("company_created", "company", id, "owner", map[string]interface{}{"name": name, "workspace_path": workspacePath}) + s.emitEvent(id, "", "", "", "", "company_created", "info", map[string]interface{}{"company_id": id, "name": name, "workspace_path": workspacePath}) + return s.GetCompany(id) +} + +func (s *Service) ListCompanies(ownerUserID string) ([]Company, error) { + ownerUserID = strings.TrimSpace(ownerUserID) + rows, err := s.db.Query(` + SELECT id, IFNULL(owner_user_id,''), IFNULL(name,''), IFNULL(slug,''), IFNULL(description,''), IFNULL(status,'active'), IFNULL(timezone,'UTC'), IFNULL(workspace_path,''), IFNULL(deploy_command,''), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM companies WHERE owner_user_id = ? ORDER BY created_at DESC`, ownerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Company{} + for rows.Next() { + var c Company + var createdAt, updatedAt string + if err := rows.Scan(&c.ID, &c.OwnerUserID, &c.Name, &c.Slug, &c.Description, &c.Status, &c.Timezone, &c.WorkspacePath, &c.DeployCommand, &createdAt, &updatedAt); err != nil { + continue + } + c.CreatedAt = parseDBTime(createdAt) + c.UpdatedAt = parseDBTime(updatedAt) + out = append(out, c) + } + return out, nil +} + +func (s *Service) GetCompany(id string) (Company, error) { + var c Company + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, IFNULL(owner_user_id,''), IFNULL(name,''), IFNULL(slug,''), IFNULL(description,''), IFNULL(status,'active'), IFNULL(timezone,'UTC'), IFNULL(workspace_path,''), IFNULL(deploy_command,''), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM companies WHERE id = ? + `, id).Scan(&c.ID, &c.OwnerUserID, &c.Name, &c.Slug, &c.Description, &c.Status, &c.Timezone, &c.WorkspacePath, &c.DeployCommand, &createdAt, &updatedAt) + if err != nil { + return Company{}, err + } + c.CreatedAt = parseDBTime(createdAt) + c.UpdatedAt = parseDBTime(updatedAt) + return c, nil +} + +func (s *Service) UserOwnsCompany(ownerUserID, companyID string) bool { + ownerUserID = strings.TrimSpace(ownerUserID) + companyID = strings.TrimSpace(companyID) + if ownerUserID == "" || companyID == "" { + return false + } + var count int + _ = s.db.QueryRow(`SELECT COUNT(1) FROM companies WHERE id = ? AND owner_user_id = ?`, companyID, ownerUserID).Scan(&count) + return count > 0 +} + +func (s *Service) UpdateCompany(id, name, description, status, timezone, workspacePath, deployCommand string) (Company, error) { + current, err := s.GetCompany(id) + if err != nil { + return Company{}, err + } + if strings.TrimSpace(name) == "" { + name = current.Name + } + if strings.TrimSpace(status) == "" { + status = current.Status + } + if strings.TrimSpace(timezone) == "" { + timezone = current.Timezone + } + if strings.TrimSpace(workspacePath) == "" { + workspacePath = current.WorkspacePath + } else { + workspacePath = s.cleanWorkspacePath(workspacePath) + } + if strings.TrimSpace(deployCommand) == "" { + deployCommand = current.DeployCommand + } else { + deployCommand = strings.TrimSpace(deployCommand) + } + _, err = s.db.Exec(`UPDATE companies SET name = ?, slug = ?, description = ?, status = ?, timezone = ?, workspace_path = ?, deploy_command = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + name, slugify(name+"-"+current.OwnerUserID), description, status, timezone, workspacePath, deployCommand, id) + if err != nil { + return Company{}, err + } + s.appendAudit("company_updated", "company", id, "owner", map[string]interface{}{"name": name, "status": status, "workspace_path": workspacePath}) + return s.GetCompany(id) +} + +func (s *Service) CreateDepartment(companyID, name, depType, description string) (Department, error) { + if strings.TrimSpace(companyID) == "" || strings.TrimSpace(name) == "" { + return Department{}, fmt.Errorf("company_id and name are required") + } + if strings.TrimSpace(depType) == "" { + depType = "general" + } + id := uuid.NewString() + _, err := s.db.Exec(` + INSERT INTO departments (id, company_id, name, type, description, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, id, companyID, name, depType, description) + if err != nil { + return Department{}, err + } + s.appendAudit("department_created", "department", id, "owner", map[string]interface{}{"company_id": companyID, "name": name}) + s.emitEvent(companyID, id, "", "", "", "department_created", "info", map[string]interface{}{"department_id": id, "name": name}) + return s.GetDepartment(id) +} + +func (s *Service) ListDepartments(companyID string) ([]Department, error) { + query := `SELECT id, IFNULL(company_id,''), IFNULL(name,''), IFNULL(type,''), IFNULL(description,''), IFNULL(status,'active'), IFNULL(created_at,''), IFNULL(updated_at,'') FROM departments` + args := []interface{}{} + if strings.TrimSpace(companyID) != "" { + query += " WHERE company_id = ?" + args = append(args, companyID) + } + query += " ORDER BY created_at ASC" + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Department{} + for rows.Next() { + var d Department + var createdAt, updatedAt string + if err := rows.Scan(&d.ID, &d.CompanyID, &d.Name, &d.Type, &d.Description, &d.Status, &createdAt, &updatedAt); err != nil { + continue + } + d.CreatedAt = parseDBTime(createdAt) + d.UpdatedAt = parseDBTime(updatedAt) + out = append(out, d) + } + return out, nil +} + +func (s *Service) GetDepartment(id string) (Department, error) { + var d Department + var createdAt, updatedAt string + err := s.db.QueryRow(`SELECT id, IFNULL(company_id,''), IFNULL(name,''), IFNULL(type,''), IFNULL(description,''), IFNULL(status,'active'), IFNULL(created_at,''), IFNULL(updated_at,'') FROM departments WHERE id = ?`, id). + Scan(&d.ID, &d.CompanyID, &d.Name, &d.Type, &d.Description, &d.Status, &createdAt, &updatedAt) + if err != nil { + return Department{}, err + } + d.CreatedAt = parseDBTime(createdAt) + d.UpdatedAt = parseDBTime(updatedAt) + return d, nil +} + +func (s *Service) UpdateDepartment(id, name, depType, description, status string) (Department, error) { + cur, err := s.GetDepartment(id) + if err != nil { + return Department{}, err + } + if strings.TrimSpace(name) == "" { + name = cur.Name + } + if strings.TrimSpace(depType) == "" { + depType = cur.Type + } + if strings.TrimSpace(description) == "" { + description = cur.Description + } + if strings.TrimSpace(status) == "" { + status = cur.Status + } + _, err = s.db.Exec(`UPDATE departments SET name = ?, type = ?, description = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, name, depType, description, status, id) + if err != nil { + return Department{}, err + } + s.appendAudit("department_updated", "department", id, "owner", map[string]interface{}{"name": name, "status": status}) + return s.GetDepartment(id) +} + +func (s *Service) CreateAgent(companyID, departmentID, name, roleType, parentAgentID, identityPrompt string) (Agent, error) { + if strings.TrimSpace(companyID) == "" || strings.TrimSpace(departmentID) == "" || strings.TrimSpace(name) == "" { + return Agent{}, fmt.Errorf("company_id, department_id and name are required") + } + roleType = strings.ToLower(strings.TrimSpace(roleType)) + if roleType == "" { + roleType = "worker" + } + if roleType != "manager" && roleType != "worker" { + return Agent{}, fmt.Errorf("role_type must be manager or worker") + } + id := uuid.NewString() + _, err := s.db.Exec(` + INSERT INTO agents (id, company_id, department_id, name, role_type, parent_agent_id, identity_prompt, status, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'idle', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, id, companyID, departmentID, name, roleType, parentAgentID, identityPrompt) + if err != nil { + return Agent{}, err + } + + profileID := uuid.NewString() + ownerUserID := "admin" + _ = s.db.QueryRow(`SELECT IFNULL(owner_user_id,'admin') FROM companies WHERE id = ?`, companyID).Scan(&ownerUserID) + defaultProvider := "openrouter" + defaultModel := s.getUserSettingString(ownerUserID, "default_model", "") + if strings.TrimSpace(defaultModel) == "" { + defaultProvider = "local" + defaultModel = "llama3.1:8b" + } + _, _ = s.db.Exec(`INSERT INTO agent_model_profiles (id, owner_user_id, provider, model, settings_json, fallback_chain_json, created_at, updated_at) + VALUES (?, ?, ?, ?, '{}', '["openrouter","local","cli_codex","cli_claude"]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, profileID, ownerUserID, defaultProvider, defaultModel) + _, _ = s.db.Exec(`INSERT OR REPLACE INTO agent_model_bindings (agent_id, primary_profile_id, temperature, max_tokens, reasoning_effort) VALUES (?, ?, ?, ?, ?)`, id, profileID, 0.2, 1200, "standard") + + s.appendAudit("agent_created", "agent", id, "owner", map[string]interface{}{"name": name, "role_type": roleType}) + s.emitEvent(companyID, departmentID, id, "", "", "agent_created", "info", map[string]interface{}{"agent_id": id, "name": name}) + return s.GetAgent(id) +} + +func (s *Service) ListAgents(companyID, departmentID string) ([]Agent, error) { + query := `SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(name,''), IFNULL(role_type,'worker'), IFNULL(parent_agent_id,''), IFNULL(identity_prompt,''), IFNULL(status,'idle'), IFNULL(is_active,1), IFNULL(created_at,''), IFNULL(updated_at,'') FROM agents WHERE 1=1` + args := []interface{}{} + if strings.TrimSpace(companyID) != "" { + query += " AND company_id = ?" + args = append(args, companyID) + } + if strings.TrimSpace(departmentID) != "" { + query += " AND department_id = ?" + args = append(args, departmentID) + } + query += " ORDER BY created_at ASC" + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Agent{} + for rows.Next() { + ag, err := scanAgent(rows) + if err != nil { + continue + } + out = append(out, ag) + } + return out, nil +} + +func (s *Service) GetAgent(id string) (Agent, error) { + row := s.db.QueryRow(`SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(name,''), IFNULL(role_type,'worker'), IFNULL(parent_agent_id,''), IFNULL(identity_prompt,''), IFNULL(status,'idle'), IFNULL(is_active,1), IFNULL(created_at,''), IFNULL(updated_at,'') FROM agents WHERE id = ?`, id) + return scanAgent(row) +} + +func (s *Service) UpdateAgent(id, name, roleType, parentAgentID, identityPrompt, status string, isActive *bool) (Agent, error) { + ag, err := s.GetAgent(id) + if err != nil { + return Agent{}, err + } + if strings.TrimSpace(name) == "" { + name = ag.Name + } + if strings.TrimSpace(roleType) == "" { + roleType = ag.RoleType + } + if strings.TrimSpace(identityPrompt) == "" { + identityPrompt = ag.IdentityPrompt + } + if strings.TrimSpace(status) == "" { + status = ag.Status + } + if parentAgentID == "" { + parentAgentID = ag.ParentAgentID + } + active := ag.IsActive + if isActive != nil { + active = *isActive + } + activeInt := 0 + if active { + activeInt = 1 + } + _, err = s.db.Exec(`UPDATE agents SET name = ?, role_type = ?, parent_agent_id = ?, identity_prompt = ?, status = ?, is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, name, roleType, parentAgentID, identityPrompt, status, activeInt, id) + if err != nil { + return Agent{}, err + } + s.appendAudit("agent_updated", "agent", id, "owner", map[string]interface{}{"name": name, "status": status}) + return s.GetAgent(id) +} + +func (s *Service) AssignManager(agentID, managerID string) error { + if strings.TrimSpace(agentID) == "" || strings.TrimSpace(managerID) == "" { + return fmt.Errorf("agent_id and manager_id are required") + } + agent, err := s.GetAgent(agentID) + if err != nil { + return err + } + manager, err := s.GetAgent(managerID) + if err != nil { + return err + } + if manager.RoleType != "manager" { + return fmt.Errorf("parent must be a manager") + } + if agent.DepartmentID != manager.DepartmentID { + return fmt.Errorf("department-scoped hierarchy: manager and agent must belong to same department") + } + if agent.ID == manager.ID { + return fmt.Errorf("agent cannot manage itself") + } + if s.pathWouldCycle(agentID, managerID) { + return fmt.Errorf("hierarchy cycle detected") + } + _, err = s.db.Exec("UPDATE agents SET parent_agent_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", managerID, agentID) + if err == nil { + s.appendAudit("hierarchy_assigned", "agent", agentID, "owner", map[string]interface{}{"manager_id": managerID}) + s.emitEvent(agent.CompanyID, agent.DepartmentID, agentID, "", "", "hierarchy_assigned", "info", map[string]interface{}{"manager_id": managerID}) + } + return err +} + +func (s *Service) pathWouldCycle(agentID, managerID string) bool { + current := managerID + for i := 0; i < 256; i++ { + if current == "" { + return false + } + if current == agentID { + return true + } + var next string + err := s.db.QueryRow("SELECT IFNULL(parent_agent_id,'') FROM agents WHERE id = ?", current).Scan(&next) + if err != nil { + return false + } + current = next + } + return true +} + +func (s *Service) CreateThread(companyID, departmentID, agentID, title string) (AgentThread, error) { + if strings.TrimSpace(companyID) == "" || strings.TrimSpace(agentID) == "" { + return AgentThread{}, fmt.Errorf("company_id and agent_id are required") + } + if strings.TrimSpace(title) == "" { + title = "New Thread" + } + id := uuid.NewString() + _, err := s.db.Exec(`INSERT INTO agent_threads (id, company_id, department_id, agent_id, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, id, companyID, departmentID, agentID, title) + if err != nil { + return AgentThread{}, err + } + s.emitEvent(companyID, departmentID, agentID, id, "", "thread_created", "info", map[string]interface{}{"thread_id": id}) + return s.GetThread(id) +} + +func (s *Service) ListThreads(agentID string) ([]AgentThread, error) { + query := `SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(title,''), IFNULL(status,'active'), IFNULL(created_at,''), IFNULL(updated_at,'') FROM agent_threads` + args := []interface{}{} + if strings.TrimSpace(agentID) != "" { + query += " WHERE agent_id = ?" + args = append(args, agentID) + } + query += " ORDER BY updated_at DESC" + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AgentThread{} + for rows.Next() { + th, err := scanThread(rows) + if err != nil { + continue + } + out = append(out, th) + } + return out, nil +} + +func (s *Service) GetThread(id string) (AgentThread, error) { + row := s.db.QueryRow(`SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(title,''), IFNULL(status,'active'), IFNULL(created_at,''), IFNULL(updated_at,'') FROM agent_threads WHERE id = ?`, id) + return scanThread(row) +} + +func (s *Service) ListThreadMessages(threadID string) ([]AgentMessage, error) { + rows, err := s.db.Query(`SELECT id, IFNULL(thread_id,''), IFNULL(role,''), IFNULL(content,''), IFNULL(content_type,'text'), IFNULL(created_at,'') FROM agent_messages WHERE thread_id = ? ORDER BY id ASC`, threadID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AgentMessage{} + for rows.Next() { + var m AgentMessage + var createdAt string + if err := rows.Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.ContentType, &createdAt); err != nil { + continue + } + m.CreatedAt = parseDBTime(createdAt) + out = append(out, m) + } + return out, nil +} + +func (s *Service) AddThreadMessage(threadID, role, content, contentType string) (AgentMessage, error) { + if strings.TrimSpace(threadID) == "" || strings.TrimSpace(role) == "" { + return AgentMessage{}, fmt.Errorf("thread_id and role are required") + } + if strings.TrimSpace(contentType) == "" { + contentType = "text" + } + res, err := s.db.Exec(`INSERT INTO agent_messages (thread_id, role, content, content_type, text_embedding, created_at) VALUES (?, ?, ?, ?, '', CURRENT_TIMESTAMP)`, threadID, role, content, contentType) + if err != nil { + return AgentMessage{}, err + } + _, _ = s.db.Exec("UPDATE agent_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", threadID) + id, _ := res.LastInsertId() + row := s.db.QueryRow(`SELECT id, IFNULL(thread_id,''), IFNULL(role,''), IFNULL(content,''), IFNULL(content_type,'text'), IFNULL(created_at,'') FROM agent_messages WHERE id = ?`, id) + var m AgentMessage + var createdAt string + if err := row.Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.ContentType, &createdAt); err != nil { + return AgentMessage{}, err + } + m.CreatedAt = parseDBTime(createdAt) + return m, nil +} + +func (s *Service) CreateTask(input AgentTask) (AgentTask, error) { + if strings.TrimSpace(input.CompanyID) == "" || strings.TrimSpace(input.AgentID) == "" { + return AgentTask{}, fmt.Errorf("company_id and agent_id are required") + } + if strings.TrimSpace(input.Type) == "" { + input.Type = "conversation" + } + if strings.TrimSpace(input.Status) == "" { + input.Status = "queued" + } + if input.Priority <= 0 { + input.Priority = 50 + } + if strings.TrimSpace(input.ID) == "" { + input.ID = uuid.NewString() + } + _, err := s.db.Exec(` + INSERT INTO agent_tasks (id, company_id, department_id, agent_id, requested_by, parent_task_id, thread_id, type, status, priority, input_json, result_json, blocked_reason, created_at, updated_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, IFNULL(?,''), '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL) + `, input.ID, input.CompanyID, input.DepartmentID, input.AgentID, input.RequestedBy, input.ParentTaskID, input.ThreadID, input.Type, input.Status, input.Priority, input.InputJSON, input.ResultJSON) + if err != nil { + return AgentTask{}, err + } + s.emitEvent(input.CompanyID, input.DepartmentID, input.AgentID, input.ThreadID, input.ID, "task_created", "info", map[string]interface{}{"task_id": input.ID, "type": input.Type}) + return s.GetTask(input.ID) +} + +func (s *Service) GetTask(taskID string) (AgentTask, error) { + row := s.db.QueryRow(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(requested_by,''), IFNULL(parent_task_id,''), IFNULL(thread_id,''), IFNULL(type,''), IFNULL(status,''), IFNULL(priority,50), IFNULL(input_json,''), IFNULL(result_json,''), IFNULL(blocked_reason,''), IFNULL(created_at,''), IFNULL(updated_at,''), IFNULL(completed_at,'') + FROM agent_tasks WHERE id = ? + `, taskID) + return scanTask(row) +} + +func (s *Service) ListTasks(companyID, agentID, status string, limit int) ([]AgentTask, error) { + if limit <= 0 || limit > 400 { + limit = 120 + } + query := `SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(requested_by,''), IFNULL(parent_task_id,''), IFNULL(thread_id,''), IFNULL(type,''), IFNULL(status,''), IFNULL(priority,50), IFNULL(input_json,''), IFNULL(result_json,''), IFNULL(blocked_reason,''), IFNULL(created_at,''), IFNULL(updated_at,''), IFNULL(completed_at,'') FROM agent_tasks WHERE 1=1` + args := []interface{}{} + if strings.TrimSpace(companyID) != "" { + query += " AND company_id = ?" + args = append(args, companyID) + } + if strings.TrimSpace(agentID) != "" { + query += " AND agent_id = ?" + args = append(args, agentID) + } + if strings.TrimSpace(status) != "" { + query += " AND status = ?" + args = append(args, status) + } + query += " ORDER BY priority ASC, created_at ASC LIMIT ?" + args = append(args, limit) + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AgentTask{} + for rows.Next() { + t, err := scanTask(rows) + if err != nil { + continue + } + out = append(out, t) + } + return out, nil +} + +func (s *Service) CancelTask(taskID string) error { + t, err := s.GetTask(taskID) + if err != nil { + return err + } + _, err = s.db.Exec("UPDATE agent_tasks SET status='canceled', completed_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id = ?", taskID) + if err == nil { + s.emitEvent(t.CompanyID, t.DepartmentID, t.AgentID, t.ThreadID, t.ID, "task_canceled", "warn", map[string]interface{}{"task_id": taskID}) + s.appendAudit("task_canceled", "task", taskID, t.RequestedBy, nil) + } + return err +} + +func (s *Service) RetryTask(taskID string) error { + t, err := s.GetTask(taskID) + if err != nil { + return err + } + _, err = s.db.Exec("UPDATE agent_tasks SET status='queued', blocked_reason='', updated_at=CURRENT_TIMESTAMP, completed_at=NULL WHERE id = ?", taskID) + if err == nil { + s.emitEvent(t.CompanyID, t.DepartmentID, t.AgentID, t.ThreadID, t.ID, "task_retried", "info", map[string]interface{}{"task_id": taskID}) + s.appendAudit("task_retried", "task", taskID, t.RequestedBy, nil) + } + return err +} + +func (s *Service) ListRuns(taskID string) ([]AgentRun, error) { + rows, err := s.db.Query(`SELECT id, IFNULL(task_id,''), IFNULL(attempt,1), IFNULL(status,''), IFNULL(provider,''), IFNULL(model,''), IFNULL(started_at,''), IFNULL(ended_at,''), IFNULL(summary,''), IFNULL(error,'') FROM agent_runs WHERE task_id = ? ORDER BY started_at DESC`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AgentRun{} + for rows.Next() { + var r AgentRun + var startedAt, endedAt string + if err := rows.Scan(&r.ID, &r.TaskID, &r.Attempt, &r.Status, &r.Provider, &r.Model, &startedAt, &endedAt, &r.Summary, &r.Error); err != nil { + continue + } + r.StartedAt = parseDBTime(startedAt) + if strings.TrimSpace(endedAt) != "" { + t := parseDBTime(endedAt) + r.EndedAt = &t + } + out = append(out, r) + } + return out, nil +} + +func (s *Service) CreateModelProfile(ownerUserID, provider, model, settingsJSON, fallbackChainJSON string) (AgentModelProfile, error) { + ownerUserID = strings.TrimSpace(ownerUserID) + if ownerUserID == "" { + return AgentModelProfile{}, fmt.Errorf("owner_user_id is required") + } + if strings.TrimSpace(provider) == "" || strings.TrimSpace(model) == "" { + return AgentModelProfile{}, fmt.Errorf("provider and model are required") + } + if strings.TrimSpace(settingsJSON) == "" { + settingsJSON = "{}" + } + if strings.TrimSpace(fallbackChainJSON) == "" { + fallbackChainJSON = "[]" + } + id := uuid.NewString() + _, err := s.db.Exec(`INSERT INTO agent_model_profiles (id, owner_user_id, provider, model, settings_json, fallback_chain_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, id, ownerUserID, provider, model, settingsJSON, fallbackChainJSON) + if err != nil { + return AgentModelProfile{}, err + } + return s.GetModelProfile(id) +} + +func (s *Service) ListModelProfiles(ownerUserID string) ([]AgentModelProfile, error) { + rows, err := s.db.Query(`SELECT id, IFNULL(owner_user_id,''), IFNULL(provider,''), IFNULL(model,''), IFNULL(settings_json,'{}'), IFNULL(fallback_chain_json,'[]'), IFNULL(created_at,''), IFNULL(updated_at,'') FROM agent_model_profiles WHERE owner_user_id = ? ORDER BY updated_at DESC`, ownerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AgentModelProfile{} + for rows.Next() { + var p AgentModelProfile + var createdAt, updatedAt string + if err := rows.Scan(&p.ID, &p.OwnerUserID, &p.Provider, &p.Model, &p.SettingsJSON, &p.FallbackChainJSON, &createdAt, &updatedAt); err != nil { + continue + } + p.CreatedAt = parseDBTime(createdAt) + p.UpdatedAt = parseDBTime(updatedAt) + out = append(out, p) + } + return out, nil +} + +func (s *Service) GetModelProfile(id string) (AgentModelProfile, error) { + var p AgentModelProfile + var createdAt, updatedAt string + err := s.db.QueryRow(`SELECT id, IFNULL(owner_user_id,''), IFNULL(provider,''), IFNULL(model,''), IFNULL(settings_json,'{}'), IFNULL(fallback_chain_json,'[]'), IFNULL(created_at,''), IFNULL(updated_at,'') FROM agent_model_profiles WHERE id = ?`, id). + Scan(&p.ID, &p.OwnerUserID, &p.Provider, &p.Model, &p.SettingsJSON, &p.FallbackChainJSON, &createdAt, &updatedAt) + if err != nil { + return AgentModelProfile{}, err + } + p.CreatedAt = parseDBTime(createdAt) + p.UpdatedAt = parseDBTime(updatedAt) + return p, nil +} + +func (s *Service) BindAgentModel(agentID, profileID string, temperature float64, maxTokens int, reasoningEffort string) error { + if strings.TrimSpace(agentID) == "" || strings.TrimSpace(profileID) == "" { + return fmt.Errorf("agent_id and primary_profile_id are required") + } + if maxTokens <= 0 { + maxTokens = 1200 + } + if strings.TrimSpace(reasoningEffort) == "" { + reasoningEffort = "standard" + } + _, err := s.db.Exec(`INSERT OR REPLACE INTO agent_model_bindings (agent_id, primary_profile_id, temperature, max_tokens, reasoning_effort) VALUES (?, ?, ?, ?, ?)`, agentID, profileID, temperature, maxTokens, reasoningEffort) + if err == nil { + s.appendAudit("agent_model_bound", "agent", agentID, "owner", map[string]interface{}{"profile_id": profileID, "temperature": temperature}) + } + return err +} + +// GetAgentBinding is the public version of getBinding for use by handlers. +func (s *Service) GetAgentBinding(agentID string) (AgentModelBinding, AgentModelProfile, error) { + return s.getBinding(agentID) +} + +func (s *Service) getBinding(agentID string) (AgentModelBinding, AgentModelProfile, error) { + var b AgentModelBinding + err := s.db.QueryRow(`SELECT IFNULL(agent_id,''), IFNULL(primary_profile_id,''), IFNULL(temperature,0.2), IFNULL(max_tokens,1200), IFNULL(reasoning_effort,'standard') FROM agent_model_bindings WHERE agent_id = ?`, agentID). + Scan(&b.AgentID, &b.PrimaryProfileID, &b.Temperature, &b.MaxTokens, &b.ReasoningEffort) + if err != nil { + return AgentModelBinding{}, AgentModelProfile{}, err + } + p, err := s.GetModelProfile(b.PrimaryProfileID) + if err != nil { + return AgentModelBinding{}, AgentModelProfile{}, err + } + return b, p, nil +} + +func (s *Service) getSettingString(key, fallback string) string { + var value string + err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if err != nil || strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +// getUserSettingString reads from user_settings for a specific user; falls back to global settings. +func (s *Service) getUserSettingString(userID, key, fallback string) string { + if userID != "" && userID != "admin" { + var value string + err := s.db.QueryRow("SELECT value FROM user_settings WHERE user_id = ? AND key = ?", userID, key).Scan(&value) + if err == nil && strings.TrimSpace(value) != "" { + return value + } + } + return s.getSettingString(key, fallback) +} + +func parseDBTime(value string) time.Time { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{} + } + formats := []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05"} + for _, f := range formats { + if t, err := time.Parse(f, value); err == nil { + return t + } + } + return time.Now() +} + +func slugify(value string) string { + v := strings.ToLower(strings.TrimSpace(value)) + if v == "" { + return "item" + } + v = strings.ReplaceAll(v, " ", "-") + out := make([]rune, 0, len(v)) + for _, r := range v { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + out = append(out, r) + } else { + out = append(out, '-') + } + } + res := strings.Trim(strings.ReplaceAll(string(out), "--", "-"), "-") + if res == "" { + return "item" + } + if len(res) > 64 { + return res[:64] + } + return res +} + +func scanAgent(scanner interface { + Scan(dest ...interface{}) error +}) (Agent, error) { + var ag Agent + var createdAt, updatedAt string + var isActive int + err := scanner.Scan(&ag.ID, &ag.CompanyID, &ag.DepartmentID, &ag.Name, &ag.RoleType, &ag.ParentAgentID, &ag.IdentityPrompt, &ag.Status, &isActive, &createdAt, &updatedAt) + if err != nil { + return Agent{}, err + } + ag.IsActive = isActive == 1 + ag.CreatedAt = parseDBTime(createdAt) + ag.UpdatedAt = parseDBTime(updatedAt) + return ag, nil +} + +func scanThread(scanner interface { + Scan(dest ...interface{}) error +}) (AgentThread, error) { + var t AgentThread + var createdAt, updatedAt string + err := scanner.Scan(&t.ID, &t.CompanyID, &t.DepartmentID, &t.AgentID, &t.Title, &t.Status, &createdAt, &updatedAt) + if err != nil { + return AgentThread{}, err + } + t.CreatedAt = parseDBTime(createdAt) + t.UpdatedAt = parseDBTime(updatedAt) + return t, nil +} + +func scanTask(scanner interface { + Scan(dest ...interface{}) error +}) (AgentTask, error) { + var t AgentTask + var createdAt, updatedAt, completedAt string + err := scanner.Scan(&t.ID, &t.CompanyID, &t.DepartmentID, &t.AgentID, &t.RequestedBy, &t.ParentTaskID, &t.ThreadID, &t.Type, &t.Status, &t.Priority, &t.InputJSON, &t.ResultJSON, &t.BlockedReason, &createdAt, &updatedAt, &completedAt) + if err != nil { + return AgentTask{}, err + } + t.CreatedAt = parseDBTime(createdAt) + t.UpdatedAt = parseDBTime(updatedAt) + if strings.TrimSpace(completedAt) != "" { + tm := parseDBTime(completedAt) + t.CompletedAt = &tm + } + return t, nil +} + +func (s *Service) migrateLegacyChats() { + var companiesCount int + _ = s.db.QueryRow("SELECT COUNT(1) FROM companies").Scan(&companiesCount) + if companiesCount > 0 { + return + } + + var legacyCount int + _ = s.db.QueryRow("SELECT COUNT(1) FROM chat_sessions").Scan(&legacyCount) + if legacyCount == 0 { + return + } + + log.Printf("agentos migration: migrating %d legacy chat sessions", legacyCount) + + settingsRaw := s.getSettingString("managed_projects", "[]") + var projects []struct { + Name string `json:"name"` + Path string `json:"path"` + } + _ = json.Unmarshal([]byte(settingsRaw), &projects) + + companyByPath := map[string]string{} + if len(projects) == 0 { + comp, err := s.CreateCompany("admin", "Migrated Workspace", "Auto-migrated from legacy chats", "UTC", "", "") + if err == nil { + companyByPath[""] = comp.ID + } + } else { + for _, p := range projects { + comp, err := s.CreateCompany("admin", p.Name, "Auto-migrated from managed project", "UTC", p.Path, "") + if err == nil { + companyByPath[strings.TrimSpace(p.Path)] = comp.ID + } + } + } + + rows, err := s.db.Query("SELECT id, IFNULL(title,''), IFNULL(project_path,'') FROM chat_sessions ORDER BY created_at ASC") + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var sid, title, projectPath string + if err := rows.Scan(&sid, &title, &projectPath); err != nil { + continue + } + companyID := companyByPath[strings.TrimSpace(projectPath)] + if companyID == "" { + for _, cid := range companyByPath { + companyID = cid + break + } + } + if companyID == "" { + continue + } + depts, _ := s.ListDepartments(companyID) + if len(depts) == 0 { + continue + } + agents, _ := s.ListAgents(companyID, depts[0].ID) + if len(agents) == 0 { + continue + } + thread, err := s.CreateThread(companyID, depts[0].ID, agents[0].ID, title) + if err != nil { + continue + } + + msgRows, err := s.db.Query("SELECT role, content FROM chat_messages WHERE session_id = ? ORDER BY id ASC", sid) + if err == nil { + for msgRows.Next() { + var role, content string + if err := msgRows.Scan(&role, &content); err != nil { + continue + } + mappedRole := role + if role == "assistant" { + mappedRole = "agent" + } + _, _ = s.AddThreadMessage(thread.ID, mappedRole, content, "text") + } + msgRows.Close() + } + + _, _ = s.db.Exec("INSERT INTO legacy_migration_map (legacy_type, legacy_id, new_type, new_id, created_at) VALUES ('chat_session', ?, 'agent_thread', ?, CURRENT_TIMESTAMP)", sid, thread.ID) + } +} + +// GetOrCreateInterAgentThread returns the shared thread between two agents (or creates one). +func (s *Service) GetOrCreateInterAgentThread(companyID, agentA, agentB string) (AgentThread, error) { + var thread AgentThread + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(title,''), IFNULL(status,''), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM agent_threads + WHERE company_id = ? AND title = ? AND status != 'archived' + LIMIT 1 + `, companyID, "inter:"+agentA+":"+agentB).Scan( + &thread.ID, &thread.CompanyID, &thread.DepartmentID, &thread.AgentID, + &thread.Title, &thread.Status, &createdAt, &updatedAt) + if err == nil { + thread.CreatedAt = parseDBTime(createdAt) + thread.UpdatedAt = parseDBTime(updatedAt) + return thread, nil + } + // Try reverse order too + err = s.db.QueryRow(` + SELECT id, IFNULL(company_id,''), IFNULL(department_id,''), IFNULL(agent_id,''), IFNULL(title,''), IFNULL(status,''), IFNULL(created_at,''), IFNULL(updated_at,'') + FROM agent_threads + WHERE company_id = ? AND title = ? AND status != 'archived' + LIMIT 1 + `, companyID, "inter:"+agentB+":"+agentA).Scan( + &thread.ID, &thread.CompanyID, &thread.DepartmentID, &thread.AgentID, + &thread.Title, &thread.Status, &createdAt, &updatedAt) + if err == nil { + thread.CreatedAt = parseDBTime(createdAt) + thread.UpdatedAt = parseDBTime(updatedAt) + return thread, nil + } + // Create new thread + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO agent_threads (id, company_id, department_id, agent_id, title, status, created_at, updated_at) + VALUES (?, ?, '', ?, ?, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, id, companyID, agentA, "inter:"+agentA+":"+agentB) + if err != nil { + return AgentThread{}, err + } + return s.GetThread(id) +} + +// PostInterAgentMessage sends a direct message from one agent to another. +func (s *Service) PostInterAgentMessage(companyID, fromAgentID, toAgentID, content string) error { + thread, err := s.GetOrCreateInterAgentThread(companyID, fromAgentID, toAgentID) + if err != nil { + return err + } + _, err = s.db.Exec(` + INSERT INTO agent_messages (thread_id, role, content, content_type, text_embedding, created_at) + VALUES (?, ?, ?, 'text', '', CURRENT_TIMESTAMP) + `, thread.ID, "agent:"+fromAgentID, content) + return err +} + +// GetAgentInbox returns all inter-agent threads for an agent (as sender or receiver). +func (s *Service) GetAgentInbox(companyID, agentID string) ([]map[string]interface{}, error) { + rows, err := s.db.Query(` + SELECT t.id, t.title, t.status, t.created_at, + m.role, m.content, m.created_at as msg_at + FROM agent_threads t + LEFT JOIN agent_messages m ON m.thread_id = t.id + WHERE t.company_id = ? AND (t.title LIKE ? OR t.title LIKE ?) + AND t.title LIKE 'inter:%' + ORDER BY m.created_at DESC + LIMIT 200 + `, companyID, "inter:"+agentID+":%", "inter:%:"+agentID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []map[string]interface{} + for rows.Next() { + var threadID, title, status, createdAt, role, content, msgAt string + if err := rows.Scan(&threadID, &title, &status, &createdAt, &role, &content, &msgAt); err != nil { + continue + } + result = append(result, map[string]interface{}{ + "thread_id": threadID, + "title": title, + "role": role, + "content": content, + "created_at": msgAt, + }) + } + if result == nil { + result = []map[string]interface{}{} + } + return result, nil +} diff --git a/dash/backend/agentos/types.go b/dash/backend/agentos/types.go new file mode 100644 index 0000000..b9a9252 --- /dev/null +++ b/dash/backend/agentos/types.go @@ -0,0 +1,217 @@ +package agentos + +import "time" + +type Company struct { + ID string `json:"id"` + OwnerUserID string `json:"owner_user_id,omitempty"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status string `json:"status"` + Timezone string `json:"timezone"` + WorkspacePath string `json:"workspace_path"` + DeployCommand string `json:"deploy_command"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Department struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Agent struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + Name string `json:"name"` + RoleType string `json:"role_type"` + ParentAgentID string `json:"parent_agent_id"` + IdentityPrompt string `json:"identity_prompt"` + Status string `json:"status"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentModelProfile struct { + ID string `json:"id"` + OwnerUserID string `json:"owner_user_id,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + SettingsJSON string `json:"settings_json"` + FallbackChainJSON string `json:"fallback_chain_json"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentModelBinding struct { + AgentID string `json:"agent_id"` + PrimaryProfileID string `json:"primary_profile_id"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + ReasoningEffort string `json:"reasoning_effort"` +} + +type AgentThread struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + AgentID string `json:"agent_id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentMessage struct { + ID int64 `json:"id"` + ThreadID string `json:"thread_id"` + Role string `json:"role"` + Content string `json:"content"` + ContentType string `json:"content_type"` + CreatedAt time.Time `json:"created_at"` +} + +type AgentTask struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + AgentID string `json:"agent_id"` + RequestedBy string `json:"requested_by"` + ParentTaskID string `json:"parent_task_id"` + ThreadID string `json:"thread_id"` + Type string `json:"type"` + Status string `json:"status"` + Priority int `json:"priority"` + InputJSON string `json:"input_json"` + ResultJSON string `json:"result_json"` + BlockedReason string `json:"blocked_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type AgentRun struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Attempt int `json:"attempt"` + Status string `json:"status"` + Provider string `json:"provider"` + Model string `json:"model"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Summary string `json:"summary"` + Error string `json:"error"` +} + +type AgentDelegation struct { + ID string `json:"id"` + ParentTaskID string `json:"parent_task_id"` + FromAgentID string `json:"from_agent_id"` + ToAgentID string `json:"to_agent_id"` + Instruction string `json:"instruction"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Schedule struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + TargetAgentID string `json:"target_agent_id"` + ScheduleType string `json:"schedule_type"` + CronExpr string `json:"cron_expr"` + RRule string `json:"rrule"` + StartAt string `json:"start_at"` + NextRunAt string `json:"next_run_at"` + Timezone string `json:"timezone"` + PayloadJSON string `json:"payload_json"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type MemoryEntry struct { + ID string `json:"id"` + ScopeType string `json:"scope_type"` + ScopeID string `json:"scope_id"` + SourceType string `json:"source_type"` + AuthorAgentID string `json:"author_agent_id"` + Content string `json:"content"` + Embedding string `json:"embedding"` + TagsJSON string `json:"tags_json"` + Importance float64 `json:"importance"` + TTLAt string `json:"ttl_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ConsensusRound struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + Topic string `json:"topic"` + Status string `json:"status"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + ClosedAt string `json:"closed_at"` +} + +type ConsensusVote struct { + ID string `json:"id"` + RoundID string `json:"round_id"` + AgentID string `json:"agent_id"` + Option string `json:"option"` + Confidence float64 `json:"confidence"` + Rationale string `json:"rationale"` + CreatedAt time.Time `json:"created_at"` +} + +type PolicyRule struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + AgentID string `json:"agent_id"` + Action string `json:"action"` + Effect string `json:"effect"` + ScopePattern string `json:"scope_pattern"` + ApprovalTier string `json:"approval_tier"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ApprovalRequest struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + TaskID string `json:"task_id"` + Action string `json:"action"` + Tier string `json:"tier"` + Reason string `json:"reason"` + PayloadJSON string `json:"payload_json"` + Status string `json:"status"` + RequestedAt string `json:"requested_at"` + ResolvedAt string `json:"resolved_at"` + ResolvedBy string `json:"resolved_by"` +} + +type AgentEvent struct { + ID int64 `json:"id"` + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + AgentID string `json:"agent_id"` + ThreadID string `json:"thread_id"` + TaskID string `json:"task_id"` + EventType string `json:"event_type"` + Severity string `json:"severity"` + PayloadJSON string `json:"payload_json"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/dash/backend/build.sh b/dash/backend/build.sh new file mode 100755 index 0000000..0111565 --- /dev/null +++ b/dash/backend/build.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Unified build script for Apollo Dashboard + +# Ensure we are in the script's directory (dash/backend) +cd "$(dirname "$0")" + +echo "----------------------------------------" +echo "🚀 Starting Full Production Build..." +echo "----------------------------------------" + +# 1. Build Backend for Linux (AMD64 + ARM64) using Zig cross-compiler +# This avoids macOS Mach-O binaries being deployed to Linux servers. +if ! command -v zig >/dev/null 2>&1; then + echo "❌ Error: 'zig' is required for Linux cross-builds with cgo." + echo "Install it once via: brew install zig" + exit 1 +fi + +echo "🔨 Building Backend (Linux AMD64)..." +CC='zig cc -target x86_64-linux-gnu' \ +CXX='zig c++ -target x86_64-linux-gnu' \ +CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ +go build -o apollo-dash-linux-amd64 . +if [ $? -ne 0 ]; then + echo "❌ Error: Backend AMD64 build failed." + exit 1 +fi +echo "✅ Success: apollo-dash-linux-amd64 created." + +echo "🔨 Building Backend (Linux ARM64)..." +CC='zig cc -target aarch64-linux-gnu' \ +CXX='zig c++ -target aarch64-linux-gnu' \ +CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \ +go build -o apollo-dash-linux-arm64 . +if [ $? -ne 0 ]; then + echo "❌ Error: Backend ARM64 build failed." + exit 1 +fi +echo "✅ Success: apollo-dash-linux-arm64 created." + +# 1b. Create runtime launcher expected by systemd at /opt/apollo-dash/backend/apollo-dash +cat > apollo-dash <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +ARCH="$(uname -m)" +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +case "$ARCH" in + x86_64|amd64) + exec "$BASE_DIR/apollo-dash-linux-amd64" "$@" + ;; + aarch64|arm64) + exec "$BASE_DIR/apollo-dash-linux-arm64" "$@" + ;; + *) + echo "Unsupported architecture: $ARCH" >&2 + exit 1 + ;; +esac +EOF +chmod +x apollo-dash +echo "✅ Success: launcher 'apollo-dash' created." + +# 2. Build Frontend Production Bundle +echo "🌐 Building Frontend..." +cd ../frontend +# We run npm run build to generate the dist/ folder +# Use npm install only if needed, but usually assume environment is ready +npm run build +if [ $? -eq 0 ]; then + echo "✅ Success: Frontend built." +else + echo "❌ Error: Frontend build failed." + exit 1 +fi + +echo "----------------------------------------" +echo "🎉 Build Complete!" +echo "Next step: Sync to your Ubuntu server using rsync (CAUTION: EXCLUDING .db TO PREVENT DATA LOSS):" +echo "rsync -avz --exclude 'frontend/node_modules' --exclude '*.db' dash/ dan@apollo:/opt/apollo-dash/" +echo "----------------------------------------" diff --git a/dash/backend/db/billing.go b/dash/backend/db/billing.go new file mode 100644 index 0000000..325c630 --- /dev/null +++ b/dash/backend/db/billing.go @@ -0,0 +1,36 @@ +package db + +import "time" + +func CreateBillingSession(id, userID, paymentRequestID string) error { + _, err := DB.Exec( + `INSERT INTO billing_sessions (id, user_id, payment_request_id, status) VALUES (?, ?, ?, 'pending')`, + id, userID, paymentRequestID, + ) + return err +} + +func GetBillingSessionByPaymentRequestID(paymentRequestID string) (userID string, err error) { + err = DB.QueryRow( + `SELECT user_id FROM billing_sessions WHERE payment_request_id = ?`, paymentRequestID, + ).Scan(&userID) + return +} + +func ConfirmBillingSession(paymentRequestID string) error { + _, err := DB.Exec( + `UPDATE billing_sessions SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP WHERE payment_request_id = ?`, + paymentRequestID, + ) + return err +} + +// ActivateProPlan upgrades a user to pro for 30 days and resets renewal warning flag. +func ActivateProPlan(userID string) error { + endsAt := time.Now().UTC().AddDate(0, 0, 30).Format("2006-01-02 15:04:05") + _, err := DB.Exec( + `UPDATE users SET plan = 'pro', subscription_ends_at = ?, renewal_warning_sent = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + endsAt, userID, + ) + return err +} diff --git a/dash/backend/db/database.go b/dash/backend/db/database.go new file mode 100644 index 0000000..09ac183 --- /dev/null +++ b/dash/backend/db/database.go @@ -0,0 +1,569 @@ +package db + +import ( + "database/sql" + "log" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +var DB *sql.DB + +func InitDB() { + // Create database locally in backend dir + dbPath := filepath.Join(".", "apollo.db") + var err error + + // Create file if it doesn't exist + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + file, err := os.Create(dbPath) + if err != nil { + log.Fatal(err) + } + file.Close() + } + + DB, err = sql.Open("sqlite", dbPath) + if err != nil { + log.Fatalf("Error opening database: %v\n", err) + } + // Single connection: serialises all reads+writes at the Go level. + // Eliminates SQLITE_BUSY entirely without WAL (which causes stale reads on a single conn). + DB.SetMaxOpenConns(1) + + createTables() +} + +func createTables() { + createVpsHostsTable := ` + CREATE TABLE IF NOT EXISTS vps_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + ip TEXT NOT NULL UNIQUE + );` + + createChatSessionsTable := ` + CREATE TABLE IF NOT EXISTS chat_sessions ( + id TEXT PRIMARY KEY, /* UUID */ + title TEXT NOT NULL, + model TEXT DEFAULT '', + project_path TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createChatMessagesTable := ` + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, /* 'user' or 'assistant' */ + content TEXT NOT NULL, + vector_embedding TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE + );` + + createSettingsTable := ` + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + );` + + createPersonalityFactsTable := ` + CREATE TABLE IF NOT EXISTS personality_facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fact TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + // Retry on write contention instead of immediately failing with SQLITE_BUSY + DB.Exec("PRAGMA busy_timeout = 5000;") + + // Enable foreign keys + _, err := DB.Exec("PRAGMA foreign_keys = ON;") + if err != nil { + log.Fatal("Could not enable foreign keys: ", err) + } + + createSubagentsTable := ` + CREATE TABLE IF NOT EXISTS subagents ( + id TEXT PRIMARY KEY, + session_id TEXT, + name TEXT NOT NULL, + task TEXT NOT NULL, + status TEXT DEFAULT 'running', + output TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createCompaniesTable := ` + CREATE TABLE IF NOT EXISTS companies ( + id TEXT PRIMARY KEY, + owner_user_id TEXT DEFAULT '', + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT DEFAULT 'active', + timezone TEXT DEFAULT 'UTC', + workspace_path TEXT DEFAULT '', + deploy_command TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createDepartmentsTable := ` + CREATE TABLE IF NOT EXISTS departments ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT DEFAULT 'general', + description TEXT DEFAULT '', + status TEXT DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE + );` + + createAgentsTable := ` + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + department_id TEXT NOT NULL, + name TEXT NOT NULL, + role_type TEXT DEFAULT 'worker', + parent_agent_id TEXT DEFAULT '', + identity_prompt TEXT DEFAULT '', + status TEXT DEFAULT 'idle', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE, + FOREIGN KEY(department_id) REFERENCES departments(id) ON DELETE CASCADE + );` + + createAgentModelProfilesTable := ` + CREATE TABLE IF NOT EXISTS agent_model_profiles ( + id TEXT PRIMARY KEY, + owner_user_id TEXT DEFAULT '', + provider TEXT NOT NULL, + model TEXT NOT NULL, + settings_json TEXT DEFAULT '{}', + fallback_chain_json TEXT DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createAgentModelBindingsTable := ` + CREATE TABLE IF NOT EXISTS agent_model_bindings ( + agent_id TEXT PRIMARY KEY, + primary_profile_id TEXT NOT NULL, + temperature REAL DEFAULT 0.2, + max_tokens INTEGER DEFAULT 1200, + reasoning_effort TEXT DEFAULT 'standard', + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY(primary_profile_id) REFERENCES agent_model_profiles(id) ON DELETE CASCADE + );` + + createAgentThreadsTable := ` + CREATE TABLE IF NOT EXISTS agent_threads ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + department_id TEXT DEFAULT '', + agent_id TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE + );` + + createAgentMessagesTable := ` + CREATE TABLE IF NOT EXISTS agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + content_type TEXT DEFAULT 'text', + text_embedding TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(thread_id) REFERENCES agent_threads(id) ON DELETE CASCADE + );` + + createAgentTasksTable := ` + CREATE TABLE IF NOT EXISTS agent_tasks ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + department_id TEXT DEFAULT '', + agent_id TEXT NOT NULL, + requested_by TEXT DEFAULT '', + parent_task_id TEXT DEFAULT '', + thread_id TEXT DEFAULT '', + type TEXT DEFAULT 'conversation', + status TEXT NOT NULL, + priority INTEGER DEFAULT 50, + input_json TEXT DEFAULT '', + result_json TEXT DEFAULT '', + blocked_reason TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME NULL, + FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE + );` + + createAgentRunsTable := ` + CREATE TABLE IF NOT EXISTS agent_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + attempt INTEGER DEFAULT 1, + status TEXT NOT NULL, + provider TEXT DEFAULT '', + model TEXT DEFAULT '', + started_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME NULL, + summary TEXT DEFAULT '', + error TEXT DEFAULT '', + FOREIGN KEY(task_id) REFERENCES agent_tasks(id) ON DELETE CASCADE + );` + + createAgentDelegationsTable := ` + CREATE TABLE IF NOT EXISTS agent_delegations ( + id TEXT PRIMARY KEY, + parent_task_id TEXT NOT NULL, + from_agent_id TEXT NOT NULL, + to_agent_id TEXT NOT NULL, + instruction TEXT DEFAULT '', + status TEXT DEFAULT 'queued', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(parent_task_id) REFERENCES agent_tasks(id) ON DELETE CASCADE + );` + + createAgentConsensusRoundsTable := ` + CREATE TABLE IF NOT EXISTS agent_consensus_rounds ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + department_id TEXT DEFAULT '', + topic TEXT NOT NULL, + status TEXT DEFAULT 'open', + created_by TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + closed_at TEXT DEFAULT '' + );` + + createAgentConsensusVotesTable := ` + CREATE TABLE IF NOT EXISTS agent_consensus_votes ( + id TEXT PRIMARY KEY, + round_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + option TEXT NOT NULL, + confidence REAL DEFAULT 0.5, + rationale TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(round_id) REFERENCES agent_consensus_rounds(id) ON DELETE CASCADE + );` + + createAgentEventsTable := ` + CREATE TABLE IF NOT EXISTS agent_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id TEXT DEFAULT '', + department_id TEXT DEFAULT '', + agent_id TEXT DEFAULT '', + thread_id TEXT DEFAULT '', + task_id TEXT DEFAULT '', + event_type TEXT NOT NULL, + severity TEXT DEFAULT 'info', + payload_json TEXT DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createMemoryEntriesTable := ` + CREATE TABLE IF NOT EXISTS memory_entries ( + id TEXT PRIMARY KEY, + scope_type TEXT NOT NULL, + scope_id TEXT NOT NULL, + source_type TEXT DEFAULT '', + author_agent_id TEXT DEFAULT '', + content TEXT NOT NULL, + embedding TEXT DEFAULT '', + tags_json TEXT DEFAULT '{}', + importance REAL DEFAULT 0.5, + ttl_at TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createMemoryLinksTable := ` + CREATE TABLE IF NOT EXISTS memory_links ( + id TEXT PRIMARY KEY, + from_entry_id TEXT NOT NULL, + to_entry_id TEXT NOT NULL, + relation_type TEXT DEFAULT '', + score REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createKnowledgeAssetsTable := ` + CREATE TABLE IF NOT EXISTS knowledge_assets ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + department_id TEXT DEFAULT '', + path_or_uri TEXT NOT NULL, + asset_type TEXT DEFAULT '', + status TEXT DEFAULT 'indexed', + hash TEXT DEFAULT '', + metadata_json TEXT DEFAULT '{}', + indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createKnowledgeIndexChunksTable := ` + CREATE TABLE IF NOT EXISTS knowledge_index_chunks ( + id TEXT PRIMARY KEY, + asset_id TEXT NOT NULL, + chunk_text TEXT NOT NULL, + embedding TEXT DEFAULT '', + symbols_json TEXT DEFAULT '{}', + cluster_id TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(asset_id) REFERENCES knowledge_assets(id) ON DELETE CASCADE + );` + + createSchedulesTable := ` + CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + department_id TEXT DEFAULT '', + target_agent_id TEXT NOT NULL, + schedule_type TEXT NOT NULL, + cron_expr TEXT DEFAULT '', + rrule TEXT DEFAULT '', + start_at TEXT DEFAULT '', + next_run_at TEXT DEFAULT '', + timezone TEXT DEFAULT 'UTC', + payload_json TEXT DEFAULT '{}', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createScheduleRunsTable := ` + CREATE TABLE IF NOT EXISTS schedule_runs ( + id TEXT PRIMARY KEY, + schedule_id TEXT NOT NULL, + task_id TEXT DEFAULT '', + status TEXT NOT NULL, + triggered_at DATETIME DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME NULL, + error TEXT DEFAULT '', + FOREIGN KEY(schedule_id) REFERENCES schedules(id) ON DELETE CASCADE + );` + + createCapabilityPoliciesTable := ` + CREATE TABLE IF NOT EXISTS capability_policies ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + department_id TEXT DEFAULT '', + agent_id TEXT DEFAULT '', + action TEXT NOT NULL, + effect TEXT NOT NULL, + scope_pattern TEXT DEFAULT '*', + approval_tier TEXT DEFAULT 'none', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createApprovalRequestsTable := ` + CREATE TABLE IF NOT EXISTS approval_requests ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + task_id TEXT DEFAULT '', + action TEXT NOT NULL, + tier TEXT NOT NULL, + reason TEXT DEFAULT '', + payload_json TEXT DEFAULT '{}', + status TEXT DEFAULT 'pending', + requested_at DATETIME DEFAULT CURRENT_TIMESTAMP, + resolved_at DATETIME NULL, + resolved_by TEXT DEFAULT '' + );` + + createAuditLogTable := ` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + actor_type TEXT DEFAULT '', + actor_id TEXT DEFAULT '', + payload_json TEXT NOT NULL, + prev_hash TEXT DEFAULT '', + event_hash TEXT NOT NULL, + signature TEXT DEFAULT '', + pubkey_id TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createLegacyMigrationMapTable := ` + CREATE TABLE IF NOT EXISTS legacy_migration_map ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + legacy_type TEXT NOT NULL, + legacy_id TEXT NOT NULL, + new_type TEXT NOT NULL, + new_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createUsersTable := ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + createUserTokensTable := ` + CREATE TABLE IF NOT EXISTS user_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );` + + for _, stmt := range []string{ + createVpsHostsTable, + createChatSessionsTable, + createChatMessagesTable, + createSettingsTable, + createPersonalityFactsTable, + createSubagentsTable, + createCompaniesTable, + createDepartmentsTable, + createAgentsTable, + createAgentModelProfilesTable, + createAgentModelBindingsTable, + createAgentThreadsTable, + createAgentMessagesTable, + createAgentTasksTable, + createAgentRunsTable, + createAgentDelegationsTable, + createAgentConsensusRoundsTable, + createAgentConsensusVotesTable, + createAgentEventsTable, + createMemoryEntriesTable, + createMemoryLinksTable, + createKnowledgeAssetsTable, + createKnowledgeIndexChunksTable, + createSchedulesTable, + createScheduleRunsTable, + createCapabilityPoliciesTable, + createApprovalRequestsTable, + createAuditLogTable, + createLegacyMigrationMapTable, + createUsersTable, + createUserTokensTable, + } { + if _, err := DB.Exec(stmt); err != nil { + log.Fatalf("Error creating table: %v\n", err) + } + } + + // AgentOS indexes + DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_companies_slug ON companies(slug)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_companies_owner_user_id ON companies(owner_user_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_departments_company_id ON departments(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agents_company_id ON agents(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agents_department_id ON agents(department_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agents_parent_agent_id ON agents(parent_agent_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_model_profiles_owner_user_id ON agent_model_profiles(owner_user_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_threads_agent_id ON agent_threads(agent_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_threads_company_id ON agent_threads(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_messages_thread_id ON agent_messages(thread_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(status)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_tasks_company_id ON agent_tasks(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_tasks_agent_id ON agent_tasks(agent_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_tasks_parent_task_id ON agent_tasks(parent_task_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_runs_task_id ON agent_runs(task_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_delegations_parent_task_id ON agent_delegations(parent_task_id)") + DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_consensus_votes_round_agent ON agent_consensus_votes(round_id, agent_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_events_company_id_id ON agent_events(company_id, id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_agent_events_task_id_id ON agent_events(task_id, id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_memory_entries_scope ON memory_entries(scope_type, scope_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_memory_entries_created_at ON memory_entries(created_at)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_knowledge_assets_company_id ON knowledge_assets(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_asset_id ON knowledge_index_chunks(asset_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_schedules_company_id ON schedules(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_schedules_next_run_at ON schedules(next_run_at)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_schedule_runs_schedule_id ON schedule_runs(schedule_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_capability_policies_company_id ON capability_policies(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_capability_policies_action ON capability_policies(action)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_approval_requests_company_id ON approval_requests(company_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_approval_requests_task_id ON approval_requests(task_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_audit_log_entity_id ON audit_log(entity_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_legacy_migration_map_legacy ON legacy_migration_map(legacy_type, legacy_id)") + + // Schema migrations for existing databases + DB.Exec("ALTER TABLE chat_sessions ADD COLUMN model TEXT DEFAULT ''") + DB.Exec("ALTER TABLE chat_sessions ADD COLUMN project_path TEXT DEFAULT ''") + DB.Exec("ALTER TABLE chat_sessions ADD COLUMN reasoning_effort TEXT DEFAULT 'None'") + DB.Exec("ALTER TABLE chat_sessions ADD COLUMN execution_mode TEXT DEFAULT 'Plan'") + DB.Exec("ALTER TABLE chat_messages ADD COLUMN vector_embedding TEXT DEFAULT ''") + DB.Exec("ALTER TABLE companies ADD COLUMN workspace_path TEXT DEFAULT ''") + DB.Exec("ALTER TABLE companies ADD COLUMN deploy_command TEXT DEFAULT ''") + DB.Exec("ALTER TABLE companies ADD COLUMN owner_user_id TEXT DEFAULT ''") + DB.Exec("UPDATE companies SET owner_user_id = 'admin' WHERE TRIM(IFNULL(owner_user_id,'')) = ''") + DB.Exec("ALTER TABLE agent_model_profiles ADD COLUMN owner_user_id TEXT DEFAULT ''") + DB.Exec("UPDATE agent_model_profiles SET owner_user_id = 'admin' WHERE TRIM(IFNULL(owner_user_id,'')) = ''") + + // AgentOS defaults + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_enabled', 'true')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_policy_enforcement', 'deny_default')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_mode', 'adaptive')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_min_workers', '1')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_max_workers', '4')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_min_reviewers', '1')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_max_reviewers', '2')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_cpu_soft', '70')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_cpu_hard', '90')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_ram_soft', '75')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_scheduler_ram_hard', '90')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_audit_signing_key_path', './agentos_ed25519.key')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_audit_pubkey_id', 'local-ed25519')") + DB.Exec("INSERT OR IGNORE INTO settings (key, value) VALUES ('agentos_kill_switch', 'false')") + + // User feature migrations + DB.Exec("ALTER TABLE users ADD COLUMN openrouter_api_key TEXT DEFAULT ''") + DB.Exec("ALTER TABLE users ADD COLUMN trial_ends_at TEXT DEFAULT ''") + DB.Exec("UPDATE users SET trial_ends_at = datetime(created_at, '+3 days') WHERE TRIM(IFNULL(trial_ends_at,'')) = ''") + DB.Exec("ALTER TABLE users ADD COLUMN is_blocked INTEGER DEFAULT 0") + DB.Exec("ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'trial'") + DB.Exec("ALTER TABLE users ADD COLUMN subscription_ends_at TEXT DEFAULT ''") + DB.Exec("ALTER TABLE users ADD COLUMN trial_warning_sent INTEGER DEFAULT 0") + DB.Exec("ALTER TABLE users ADD COLUMN renewal_warning_sent INTEGER DEFAULT 0") + + // Billing sessions + DB.Exec(`CREATE TABLE IF NOT EXISTS billing_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + payment_request_id TEXT NOT NULL UNIQUE, + status TEXT DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`) + DB.Exec("CREATE INDEX IF NOT EXISTS idx_billing_sessions_user_id ON billing_sessions(user_id)") + DB.Exec("CREATE INDEX IF NOT EXISTS idx_billing_sessions_payment_request_id ON billing_sessions(payment_request_id)") + + // Per-user settings (overrides global settings per user) + DB.Exec(`CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (user_id, key) + )`) +} diff --git a/dash/backend/db/users.go b/dash/backend/db/users.go new file mode 100644 index 0000000..51dcd5d --- /dev/null +++ b/dash/backend/db/users.go @@ -0,0 +1,311 @@ +package db + +import ( + "database/sql" + "time" +) + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + OpenrouterAPIKey string `json:"openrouter_api_key,omitempty"` + TrialEndsAt string `json:"trial_ends_at,omitempty"` + IsBlocked int `json:"is_blocked"` + Plan string `json:"plan"` + SubscriptionEndsAt string `json:"subscription_ends_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type UserAdminView struct { + User + CompanyCount int `json:"company_count"` + AgentCount int `json:"agent_count"` + TaskCount int `json:"task_count"` +} + +func scanUser(row *sql.Row) (*User, error) { + u := &User{} + var createdAtStr string + err := row.Scan( + &u.ID, &u.Email, &u.Name, + &u.OpenrouterAPIKey, &u.TrialEndsAt, + &u.IsBlocked, &u.Plan, &u.SubscriptionEndsAt, + &createdAtStr, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAtStr) + return u, nil +} + +// NOTE: prefix with table alias (e.g. "u.") when used in a JOIN to avoid ambiguous column errors. +// The "created_at" at the end must be qualified at the call site when there are other tables in the query. +const userSelectCols = `id, email, name, + COALESCE(openrouter_api_key,''), COALESCE(trial_ends_at,''), + COALESCE(is_blocked,0), COALESCE(plan,'trial'), COALESCE(subscription_ends_at,''), + created_at` + +// userSelectColsQualified is the same as userSelectCols but with all columns qualified with alias "u". +const userSelectColsQualified = `u.id, u.email, u.name, + COALESCE(u.openrouter_api_key,''), COALESCE(u.trial_ends_at,''), + COALESCE(u.is_blocked,0), COALESCE(u.plan,'trial'), COALESCE(u.subscription_ends_at,''), + u.created_at` + +func CreateUser(id, email, passwordHash, name string) (*User, error) { + _, err := DB.Exec( + `INSERT INTO users (id, email, password_hash, name, trial_ends_at, plan) + VALUES (?, ?, ?, ?, datetime('now', '+3 days'), 'trial')`, + id, email, passwordHash, name, + ) + if err != nil { + return nil, err + } + return GetUserByID(id) +} + +func GetUserByEmail(email string) (id, emailOut, passwordHash, name string, isBlocked int, err error) { + row := DB.QueryRow( + `SELECT id, email, password_hash, name, COALESCE(is_blocked,0) FROM users WHERE email = ?`, email, + ) + err = row.Scan(&id, &emailOut, &passwordHash, &name, &isBlocked) + return +} + +func GetUserByID(id string) (*User, error) { + row := DB.QueryRow(`SELECT `+userSelectCols+` FROM users WHERE id = ?`, id) + return scanUser(row) +} + +func CreateUserToken(token, userID string) error { + _, err := DB.Exec( + `INSERT INTO user_tokens (token, user_id) VALUES (?, ?)`, + token, userID, + ) + return err +} + +func GetUserByToken(token string) (*User, error) { + row := DB.QueryRow( + `SELECT `+userSelectColsQualified+` + FROM user_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token = ?`, token, + ) + return scanUser(row) +} + +func DeleteUserToken(token string) error { + _, err := DB.Exec(`DELETE FROM user_tokens WHERE token = ?`, token) + return err +} + +func UpdateUserName(id, name string) error { + _, err := DB.Exec( + `UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + name, id, + ) + return err +} + +func UpdateUserOpenrouterKey(id, key string) error { + _, err := DB.Exec( + `UPDATE users SET openrouter_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + key, id, + ) + return err +} + +func UpdateUserPassword(id, hash string) error { + _, err := DB.Exec( + `UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + hash, id, + ) + return err +} + +func GetUserOpenrouterKey(id string) string { + var key string + DB.QueryRow(`SELECT COALESCE(openrouter_api_key,'') FROM users WHERE id = ?`, id).Scan(&key) + return key +} + +func GetUserPasswordHash(id string) (string, error) { + var hash string + err := DB.QueryRow(`SELECT password_hash FROM users WHERE id = ?`, id).Scan(&hash) + return hash, err +} + +func EmailExists(email string) bool { + var count int + DB.QueryRow(`SELECT COUNT(1) FROM users WHERE email = ?`, email).Scan(&count) + return count > 0 +} + +func DeleteUser(id string) error { + _, err := DB.Exec(`DELETE FROM users WHERE id = ?`, id) + return err +} + +func SetUserBlocked(id string, blocked bool) error { + val := 0 + if blocked { + val = 1 + } + _, err := DB.Exec( + `UPDATE users SET is_blocked = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + val, id, + ) + return err +} + +func SetUserPlan(id, plan, subscriptionEndsAt string) error { + _, err := DB.Exec( + `UPDATE users SET plan = ?, subscription_ends_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + plan, subscriptionEndsAt, id, + ) + return err +} + +func ListUsersAdmin() ([]UserAdminView, error) { + rows, err := DB.Query(` + SELECT ` + userSelectColsQualified + `, + (SELECT COUNT(*) FROM companies c WHERE c.owner_user_id = u.id) AS company_count, + (SELECT COUNT(*) FROM agents a JOIN companies c ON a.company_id = c.id WHERE c.owner_user_id = u.id) AS agent_count, + (SELECT COUNT(*) FROM agent_tasks t JOIN companies c ON t.company_id = c.id WHERE c.owner_user_id = u.id) AS task_count + FROM users u + ORDER BY u.created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []UserAdminView + for rows.Next() { + var u UserAdminView + var createdAtStr string + if err := rows.Scan( + &u.ID, &u.Email, &u.Name, + &u.OpenrouterAPIKey, &u.TrialEndsAt, + &u.IsBlocked, &u.Plan, &u.SubscriptionEndsAt, + &createdAtStr, + &u.CompanyCount, &u.AgentCount, &u.TaskCount, + ); err != nil { + continue + } + u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAtStr) + u.OpenrouterAPIKey = "" // never expose in admin list + users = append(users, u) + } + if users == nil { + users = []UserAdminView{} + } + return users, nil +} + +// UserNotifyRow is used by the email notifier background job. +type UserNotifyRow struct { + ID string + Email string + Name string + Plan string + TrialEndsAt string + SubscriptionEndsAt string + TrialWarningSent int + RenewalWarningSent int +} + +// UsersNeedingTrialWarning returns trial users whose trial ends within 28 hours +// and haven't been sent a warning yet. +func UsersNeedingTrialWarning() ([]UserNotifyRow, error) { + rows, err := DB.Query(` + SELECT id, email, COALESCE(name,''), COALESCE(plan,'trial'), + COALESCE(trial_ends_at,''), COALESCE(subscription_ends_at,''), + COALESCE(trial_warning_sent,0), COALESCE(renewal_warning_sent,0) + FROM users + WHERE plan = 'trial' + AND is_blocked = 0 + AND TRIM(IFNULL(trial_ends_at,'')) != '' + AND datetime(trial_ends_at) > datetime('now') + AND datetime(trial_ends_at) <= datetime('now', '+28 hours') + AND COALESCE(trial_warning_sent,0) = 0 + `) + return scanNotifyRows(rows, err) +} + +// UsersNeedingRenewalWarning returns pro users whose subscription ends within 4 days +// and haven't been sent a renewal warning yet. +func UsersNeedingRenewalWarning() ([]UserNotifyRow, error) { + rows, err := DB.Query(` + SELECT id, email, COALESCE(name,''), COALESCE(plan,'trial'), + COALESCE(trial_ends_at,''), COALESCE(subscription_ends_at,''), + COALESCE(trial_warning_sent,0), COALESCE(renewal_warning_sent,0) + FROM users + WHERE plan = 'pro' + AND is_blocked = 0 + AND TRIM(IFNULL(subscription_ends_at,'')) != '' + AND datetime(subscription_ends_at) > datetime('now') + AND datetime(subscription_ends_at) <= datetime('now', '+4 days') + AND COALESCE(renewal_warning_sent,0) = 0 + `) + return scanNotifyRows(rows, err) +} + +func scanNotifyRows(rows interface { + Scan(...interface{}) error + Next() bool + Close() error +}, err error) ([]UserNotifyRow, error) { + if err != nil { + return nil, err + } + defer rows.Close() + var out []UserNotifyRow + for rows.Next() { + var r UserNotifyRow + if e := rows.Scan(&r.ID, &r.Email, &r.Name, &r.Plan, &r.TrialEndsAt, &r.SubscriptionEndsAt, &r.TrialWarningSent, &r.RenewalWarningSent); e != nil { + continue + } + out = append(out, r) + } + return out, nil +} + +func MarkTrialWarningSent(id string) { + DB.Exec(`UPDATE users SET trial_warning_sent = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, id) +} + +func MarkRenewalWarningSent(id string) { + DB.Exec(`UPDATE users SET renewal_warning_sent = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, id) +} + +type PlatformStats struct { + TotalUsers int `json:"total_users"` + ActiveTrials int `json:"active_trials"` + PaidUsers int `json:"paid_users"` + BlockedUsers int `json:"blocked_users"` + TotalCompanies int `json:"total_companies"` + TotalDepartments int `json:"total_departments"` + TotalAgents int `json:"total_agents"` + TotalTasks int `json:"total_tasks"` + TotalMemories int `json:"total_memories"` +} + +func GetPlatformStats() PlatformStats { + var s PlatformStats + DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&s.TotalUsers) + DB.QueryRow(`SELECT COUNT(*) FROM users WHERE plan = 'trial' AND is_blocked = 0`).Scan(&s.ActiveTrials) + DB.QueryRow(`SELECT COUNT(*) FROM users WHERE plan = 'pro' AND is_blocked = 0`).Scan(&s.PaidUsers) + DB.QueryRow(`SELECT COUNT(*) FROM users WHERE is_blocked = 1`).Scan(&s.BlockedUsers) + DB.QueryRow(`SELECT COUNT(*) FROM companies`).Scan(&s.TotalCompanies) + DB.QueryRow(`SELECT COUNT(*) FROM departments`).Scan(&s.TotalDepartments) + DB.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&s.TotalAgents) + DB.QueryRow(`SELECT COUNT(*) FROM agent_tasks`).Scan(&s.TotalTasks) + DB.QueryRow(`SELECT COUNT(*) FROM memory_entries`).Scan(&s.TotalMemories) + return s +} diff --git a/dash/backend/go.mod b/dash/backend/go.mod new file mode 100644 index 0000000..f6098cd --- /dev/null +++ b/dash/backend/go.mod @@ -0,0 +1,40 @@ +module github.com/danilrybalkin/apollo-dash + +go 1.25.6 + +require ( + github.com/creack/pty v1.1.24 + github.com/go-ping/ping v1.2.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/shirou/gopsutil/v3 v3.24.5 + github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 + gonum.org/v1/gonum v0.17.0 + modernc.org/sqlite v1.46.1 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.45.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/dash/backend/go.sum b/dash/backend/go.sum new file mode 100644 index 0000000..f032a58 --- /dev/null +++ b/dash/backend/go.sum @@ -0,0 +1,121 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ping/ping v1.2.0 h1:vsJ8slZBZAXNCK4dPcI2PEE9eM9n9RbXbGouVQ/Y4yQ= +github.com/go-ping/ping v1.2.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +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/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/dash/backend/handlers/admin.go b/dash/backend/handlers/admin.go new file mode 100644 index 0000000..a6575e3 --- /dev/null +++ b/dash/backend/handlers/admin.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/danilrybalkin/apollo-dash/db" +) + +func requireAdmin(w http.ResponseWriter, r *http.Request) bool { + uid := CurrentUserID(r) + if uid != "admin" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"error": "admin only"}) + return false + } + return true +} + +// GET /api/admin/stats +func AdminStatsHandler(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(db.GetPlatformStats()) +} + +// GET /api/admin/users +func AdminUsersHandler(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + users, err := db.ListUsersAdmin() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) +} + +// PATCH /api/admin/users/{id} — update plan, block status +// DELETE /api/admin/users/{id} — delete user +func AdminUserByIDHandler(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } + // Extract user ID from path: /api/admin/users/ + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/users/"), "/") + userID := parts[0] + if userID == "" { + http.Error(w, "user id required", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + + switch r.Method { + case http.MethodPatch: + var body struct { + Plan string `json:"plan"` + SubscriptionEndsAt string `json:"subscription_ends_at"` + IsBlocked *bool `json:"is_blocked"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if body.Plan != "" { + if err := db.SetUserPlan(userID, body.Plan, body.SubscriptionEndsAt); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + if body.IsBlocked != nil { + if err := db.SetUserBlocked(userID, *body.IsBlocked); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + + case http.MethodDelete: + if err := db.DeleteUser(userID); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/dash/backend/handlers/agentos.go b/dash/backend/handlers/agentos.go new file mode 100644 index 0000000..c4438bb --- /dev/null +++ b/dash/backend/handlers/agentos.go @@ -0,0 +1,1520 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/danilrybalkin/apollo-dash/agentos" +) + +var agentOSService *agentos.Service + +func SetAgentOSService(svc *agentos.Service) { + agentOSService = svc +} + +func ensureAgentOSAvailable(w http.ResponseWriter) bool { + if agentOSService == nil { + http.Error(w, "agentos is not initialized", http.StatusServiceUnavailable) + return false + } + if !getSettingBool("agentos_enabled", true) { + http.Error(w, "agentos is disabled in settings", http.StatusServiceUnavailable) + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func requireTenant(w http.ResponseWriter, r *http.Request) (string, bool) { + userID := strings.TrimSpace(CurrentUserID(r)) + if userID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return "", false + } + return userID, true +} + +func requireCompanyAccess(w http.ResponseWriter, ownerUserID, companyID string) bool { + if strings.TrimSpace(companyID) == "" { + http.Error(w, "company_id is required", http.StatusBadRequest) + return false + } + if !agentOSService.UserOwnsCompany(ownerUserID, companyID) { + http.Error(w, "not found", http.StatusNotFound) + return false + } + return true +} + +func requireDepartmentAccess(w http.ResponseWriter, ownerUserID, departmentID string) (agentos.Department, bool) { + item, err := agentOSService.GetDepartment(departmentID) + if err != nil || !agentOSService.UserOwnsCompany(ownerUserID, item.CompanyID) { + http.Error(w, "not found", http.StatusNotFound) + return agentos.Department{}, false + } + return item, true +} + +func requireAgentAccess(w http.ResponseWriter, ownerUserID, agentID string) (agentos.Agent, bool) { + item, err := agentOSService.GetAgent(agentID) + if err != nil || !agentOSService.UserOwnsCompany(ownerUserID, item.CompanyID) { + http.Error(w, "not found", http.StatusNotFound) + return agentos.Agent{}, false + } + return item, true +} + +func requireThreadAccess(w http.ResponseWriter, ownerUserID, threadID string) (agentos.AgentThread, bool) { + item, err := agentOSService.GetThread(threadID) + if err != nil || !agentOSService.UserOwnsCompany(ownerUserID, item.CompanyID) { + http.Error(w, "not found", http.StatusNotFound) + return agentos.AgentThread{}, false + } + return item, true +} + +func requireTaskAccess(w http.ResponseWriter, ownerUserID, taskID string) (agentos.AgentTask, bool) { + item, err := agentOSService.GetTask(taskID) + if err != nil || !agentOSService.UserOwnsCompany(ownerUserID, item.CompanyID) { + http.Error(w, "not found", http.StatusNotFound) + return agentos.AgentTask{}, false + } + return item, true +} + +func requireScheduleAccess(w http.ResponseWriter, ownerUserID, scheduleID string) (agentos.Schedule, bool) { + item, err := agentOSService.GetSchedule(scheduleID) + if err != nil || !agentOSService.UserOwnsCompany(ownerUserID, item.CompanyID) { + http.Error(w, "not found", http.StatusNotFound) + return agentos.Schedule{}, false + } + return item, true +} + +func AgentOSCompaniesHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + items, err := agentOSService.ListCompanies(ownerUserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + Name string `json:"name"` + Description string `json:"description"` + Timezone string `json:"timezone"` + WorkspacePath string `json:"workspace_path"` + DeployCommand string `json:"deploy_command"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + item, err := agentOSService.CreateCompany(ownerUserID, req.Name, req.Description, req.Timezone, req.WorkspacePath, req.DeployCommand) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSCompanyByIDHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/companies/") + id = strings.TrimSpace(strings.Trim(id, "/")) + if id == "" { + http.Error(w, "company id is required", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, id) { + return + } + switch r.Method { + case http.MethodGet: + item, err := agentOSService.GetCompany(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, item) + case http.MethodPatch: + var req struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Timezone string `json:"timezone"` + WorkspacePath string `json:"workspace_path"` + DeployCommand string `json:"deploy_command"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + item, err := agentOSService.UpdateCompany(id, req.Name, req.Description, req.Status, req.Timezone, req.WorkspacePath, req.DeployCommand) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSDepartmentsHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if companyID != "" && !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + if companyID == "" { + companies, err := agentOSService.ListCompanies(ownerUserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var all []agentos.Department + for _, company := range companies { + items, err := agentOSService.ListDepartments(company.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + all = append(all, items...) + } + writeJSON(w, http.StatusOK, all) + return + } + items, err := agentOSService.ListDepartments(companyID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + CompanyID string `json:"company_id"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + item, err := agentOSService.CreateDepartment(req.CompanyID, req.Name, req.Type, req.Description) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSDepartmentByIDHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/departments/") + id = strings.TrimSpace(strings.Trim(id, "/")) + if id == "" { + http.Error(w, "department id is required", http.StatusBadRequest) + return + } + if _, ok := requireDepartmentAccess(w, ownerUserID, id); !ok { + return + } + switch r.Method { + case http.MethodGet: + item, err := agentOSService.GetDepartment(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, item) + case http.MethodPatch: + var req struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + item, err := agentOSService.UpdateDepartment(id, req.Name, req.Type, req.Description, req.Status) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSAgentsHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + departmentID := strings.TrimSpace(r.URL.Query().Get("department_id")) + if companyID != "" && !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + if departmentID != "" { + dept, ok := requireDepartmentAccess(w, ownerUserID, departmentID) + if !ok { + return + } + if companyID == "" { + companyID = dept.CompanyID + } + } + if companyID == "" { + companies, err := agentOSService.ListCompanies(ownerUserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var all []agentos.Agent + for _, company := range companies { + items, err := agentOSService.ListAgents(company.ID, departmentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + all = append(all, items...) + } + writeJSON(w, http.StatusOK, all) + return + } + items, err := agentOSService.ListAgents(companyID, departmentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + Name string `json:"name"` + RoleType string `json:"role_type"` + ParentAgentID string `json:"parent_agent_id"` + IdentityPrompt string `json:"identity_prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + dept, ok := requireDepartmentAccess(w, ownerUserID, req.DepartmentID) + if !ok { + return + } + if dept.CompanyID != req.CompanyID { + http.Error(w, "department does not belong to company", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.ParentAgentID) != "" { + parent, ok := requireAgentAccess(w, ownerUserID, req.ParentAgentID) + if !ok { + return + } + if parent.CompanyID != req.CompanyID { + http.Error(w, "parent agent does not belong to company", http.StatusBadRequest) + return + } + } + item, err := agentOSService.CreateAgent(req.CompanyID, req.DepartmentID, req.Name, req.RoleType, req.ParentAgentID, req.IdentityPrompt) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSAgentByIDHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/agents/") + path = strings.Trim(path, "/") + if path == "" { + http.Error(w, "agent id is required", http.StatusBadRequest) + return + } + parts := strings.Split(path, "/") + id := parts[0] + if _, ok := requireAgentAccess(w, ownerUserID, id); !ok { + return + } + + if len(parts) == 3 && parts[1] == "hierarchy" && parts[2] == "assign" { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + ManagerID string `json:"manager_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ManagerID == "" { + http.Error(w, "manager_id is required", http.StatusBadRequest) + return + } + if _, ok := requireAgentAccess(w, ownerUserID, req.ManagerID); !ok { + return + } + if err := agentOSService.AssignManager(id, req.ManagerID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } + if len(parts) == 2 && parts[1] == "model-bind" { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + PrimaryProfileID string `json:"primary_profile_id"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + ReasoningEffort string `json:"reasoning_effort"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.PrimaryProfileID) == "" { + http.Error(w, "primary_profile_id is required", http.StatusBadRequest) + return + } + profile, err := agentOSService.GetModelProfile(req.PrimaryProfileID) + if err != nil || profile.OwnerUserID != ownerUserID { + http.Error(w, "model profile not found", http.StatusNotFound) + return + } + if err := agentOSService.BindAgentModel(id, req.PrimaryProfileID, req.Temperature, req.MaxTokens, req.ReasoningEffort); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } + + switch r.Method { + case http.MethodGet: + item, err := agentOSService.GetAgent(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, item) + case http.MethodPatch: + var req struct { + Name string `json:"name"` + RoleType string `json:"role_type"` + ParentAgentID string `json:"parent_agent_id"` + IdentityPrompt string `json:"identity_prompt"` + Status string `json:"status"` + IsActive *bool `json:"is_active"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.ParentAgentID) != "" { + if _, ok := requireAgentAccess(w, ownerUserID, req.ParentAgentID); !ok { + return + } + } + item, err := agentOSService.UpdateAgent(id, req.Name, req.RoleType, req.ParentAgentID, req.IdentityPrompt, req.Status, req.IsActive) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSModelProfilesHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + items, err := agentOSService.ListModelProfiles(ownerUserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + SettingsJSON string `json:"settings_json"` + FallbackChainJSON string `json:"fallback_chain_json"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + item, err := agentOSService.CreateModelProfile(ownerUserID, req.Provider, req.Model, req.SettingsJSON, req.FallbackChainJSON) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSThreadsHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + if agentID == "" { + writeJSON(w, http.StatusOK, []agentos.AgentThread{}) + return + } + if _, ok := requireAgentAccess(w, ownerUserID, agentID); !ok { + return + } + items, err := agentOSService.ListThreads(agentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + AgentID string `json:"agent_id"` + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + if strings.TrimSpace(req.DepartmentID) != "" { + dept, ok := requireDepartmentAccess(w, ownerUserID, req.DepartmentID) + if !ok { + return + } + if dept.CompanyID != req.CompanyID { + http.Error(w, "department does not belong to company", http.StatusBadRequest) + return + } + } + agent, ok := requireAgentAccess(w, ownerUserID, req.AgentID) + if !ok { + return + } + if agent.CompanyID != req.CompanyID { + http.Error(w, "agent does not belong to company", http.StatusBadRequest) + return + } + item, err := agentOSService.CreateThread(req.CompanyID, req.DepartmentID, req.AgentID, req.Title) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSThreadMessagesHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/threads/") + path = strings.Trim(path, "/") + parts := strings.Split(path, "/") + if len(parts) != 2 || parts[1] != "messages" { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + threadID := parts[0] + thread, ok := requireThreadAccess(w, ownerUserID, threadID) + if !ok { + return + } + + switch r.Method { + case http.MethodGet: + items, err := agentOSService.ListThreadMessages(threadID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + Content string `json:"content"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Content) == "" { + http.Error(w, "content is required", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Role) == "" { + req.Role = "user" + } + msg, err := agentOSService.AddThreadMessage(threadID, req.Role, req.Content, "text") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if req.Role == "user" { + task, err := agentOSService.CreateTask(agentos.AgentTask{ + CompanyID: thread.CompanyID, + DepartmentID: thread.DepartmentID, + AgentID: thread.AgentID, + RequestedBy: "user", + ThreadID: thread.ID, + Type: "conversation", + Status: "queued", + Priority: 50, + InputJSON: agentOSService.BuildTaskPromptInput(req.Content), + }) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"message": msg, "task_error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"message": msg, "task": task}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"message": msg}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSTasksHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + if companyID != "" && !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + if agentID != "" { + agent, ok := requireAgentAccess(w, ownerUserID, agentID) + if !ok { + return + } + if companyID == "" { + companyID = agent.CompanyID + } + } + if companyID == "" { + companies, err := agentOSService.ListCompanies(ownerUserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var all []agentos.AgentTask + for _, company := range companies { + items, err := agentOSService.ListTasks(company.ID, agentID, r.URL.Query().Get("status"), limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + all = append(all, items...) + } + writeJSON(w, http.StatusOK, all) + return + } + items, err := agentOSService.ListTasks(companyID, agentID, r.URL.Query().Get("status"), limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + AgentID string `json:"agent_id"` + RequestedBy string `json:"requested_by"` + ThreadID string `json:"thread_id"` + Type string `json:"type"` + Priority int `json:"priority"` + Prompt string `json:"prompt"` + InputJSON string `json:"input_json"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + agent, ok := requireAgentAccess(w, ownerUserID, req.AgentID) + if !ok { + return + } + if agent.CompanyID != req.CompanyID { + http.Error(w, "agent does not belong to company", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.ThreadID) != "" { + thread, ok := requireThreadAccess(w, ownerUserID, req.ThreadID) + if !ok { + return + } + if thread.CompanyID != req.CompanyID { + http.Error(w, "thread does not belong to company", http.StatusBadRequest) + return + } + } + inputJSON := req.InputJSON + if strings.TrimSpace(inputJSON) == "" { + inputJSON = agentOSService.BuildTaskPromptInput(req.Prompt) + } + created, err := agentOSService.CreateTask(agentos.AgentTask{ + CompanyID: req.CompanyID, + DepartmentID: req.DepartmentID, + AgentID: req.AgentID, + RequestedBy: req.RequestedBy, + ThreadID: req.ThreadID, + Type: req.Type, + Status: "queued", + Priority: req.Priority, + InputJSON: inputJSON, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, created) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSTaskByIDHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/tasks/") + path = strings.Trim(path, "/") + if path == "" { + http.Error(w, "task id is required", http.StatusBadRequest) + return + } + parts := strings.Split(path, "/") + taskID := parts[0] + if _, ok := requireTaskAccess(w, ownerUserID, taskID); !ok { + return + } + + if len(parts) == 1 { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + details, err := agentOSService.GetTaskDetails(taskID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, details) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + action := parts[1] + switch action { + case "cancel": + if err := agentOSService.CancelTask(taskID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "canceled"}) + case "retry": + if err := agentOSService.RetryTask(taskID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "queued"}) + case "delegate": + var req struct { + ToAgentID string `json:"to_agent_id"` + Instruction string `json:"instruction"` + RequestedBy string `json:"requested_by"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.ToAgentID) == "" { + http.Error(w, "to_agent_id is required", http.StatusBadRequest) + return + } + if _, ok := requireAgentAccess(w, ownerUserID, req.ToAgentID); !ok { + return + } + child, err := agentOSService.DelegateTask(taskID, req.ToAgentID, req.Instruction, req.RequestedBy) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, child) + default: + http.Error(w, "unknown action", http.StatusBadRequest) + } +} + +func AgentOSConsensusRoundsHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + switch r.Method { + case http.MethodGet: + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := agentOSService.ListConsensusRounds(r.URL.Query().Get("company_id"), r.URL.Query().Get("department_id"), limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + Topic string `json:"topic"` + CreatedBy string `json:"created_by"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + item, err := agentOSService.CreateConsensusRound(req.CompanyID, req.DepartmentID, req.Topic, req.CreatedBy) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSConsensusRoundByIDHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/consensus/rounds/") + path = strings.Trim(path, "/") + parts := strings.Split(path, "/") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + http.Error(w, "round id is required", http.StatusBadRequest) + return + } + roundID := parts[0] + + if len(parts) == 1 { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + decision, err := agentOSService.ConsensusDecision(roundID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, decision) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + switch parts[1] { + case "vote": + var req struct { + AgentID string `json:"agent_id"` + Option string `json:"option"` + Confidence float64 `json:"confidence"` + Rationale string `json:"rationale"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + vote, err := agentOSService.VoteConsensus(roundID, req.AgentID, req.Option, req.Confidence, req.Rationale) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, vote) + case "close": + round, err := agentOSService.CloseConsensusRound(roundID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, round) + default: + http.Error(w, "unknown action", http.StatusBadRequest) + } +} + +func AgentOSMemoryQueryHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + if departmentID := strings.TrimSpace(r.URL.Query().Get("department_id")); departmentID != "" { + if _, ok := requireDepartmentAccess(w, ownerUserID, departmentID); !ok { + return + } + } + if agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")); agentID != "" { + if _, ok := requireAgentAccess(w, ownerUserID, agentID); !ok { + return + } + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := agentOSService.QueryMemory( + companyID, + r.URL.Query().Get("department_id"), + r.URL.Query().Get("agent_id"), + r.URL.Query().Get("query"), + limit, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) +} + +func AgentOSMemoryWriteHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var entry agentos.MemoryEntry + if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if entry.ScopeType == "company" && !requireCompanyAccess(w, ownerUserID, entry.ScopeID) { + return + } + if entry.ScopeType == "department" { + if _, ok := requireDepartmentAccess(w, ownerUserID, entry.ScopeID); !ok { + return + } + } + if strings.HasPrefix(entry.ScopeType, "agent_") { + if _, ok := requireAgentAccess(w, ownerUserID, entry.ScopeID); !ok { + return + } + } + if err := agentOSService.WriteMemoryEntry(entry); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func AgentOSMemoryTimelineHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := agentOSService.ListMemoryTimeline(companyID, r.URL.Query().Get("department_id"), r.URL.Query().Get("agent_id"), limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) +} + +func AgentOSSchedulesHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + items, err := agentOSService.ListSchedules(companyID, r.URL.Query().Get("department_id")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req agentos.Schedule + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + agent, ok := requireAgentAccess(w, ownerUserID, req.TargetAgentID) + if !ok { + return + } + if agent.CompanyID != req.CompanyID { + http.Error(w, "agent does not belong to company", http.StatusBadRequest) + return + } + item, err := agentOSService.CreateSchedule(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSScheduleByIDHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/schedules/") + path = strings.Trim(path, "/") + parts := strings.Split(path, "/") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + http.Error(w, "schedule id is required", http.StatusBadRequest) + return + } + id := parts[0] + if _, ok := requireScheduleAccess(w, ownerUserID, id); !ok { + return + } + + if len(parts) == 1 { + if r.Method != http.MethodPatch { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req agentos.Schedule + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + item, err := agentOSService.UpdateSchedule(id, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + return + } + + if len(parts) == 2 && parts[1] == "toggle" { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if err := agentOSService.ToggleSchedule(id, req.Enabled); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } + + http.Error(w, "invalid path", http.StatusBadRequest) +} + +func AgentOSEventsHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := agentOSService.ListEvents(companyID, r.URL.Query().Get("task_id"), sinceID, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) +} + +func AgentOSEventsStreamHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming is unsupported", http.StatusInternalServerError) + return + } + + companyID := r.URL.Query().Get("company_id") + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + ch, cancel := agentOSService.Subscribe(companyID) + defer cancel() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + writeSSE := func(event string, data interface{}) { + b, _ := json.Marshal(data) + _, _ = fmt.Fprintf(w, "event: %s\n", event) + _, _ = fmt.Fprintf(w, "data: %s\n\n", string(b)) + flusher.Flush() + } + + writeSSE("ready", map[string]interface{}{"company_id": companyID, "ts": time.Now().UTC().Format(time.RFC3339)}) + keepAlive := time.NewTicker(20 * time.Second) + defer keepAlive.Stop() + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case ev := <-ch: + writeSSE("event", ev) + case <-keepAlive.C: + writeSSE("ping", map[string]interface{}{"ts": time.Now().UTC().Format(time.RFC3339)}) + } + } +} + +func AgentOSPoliciesHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + items, err := agentOSService.ListPolicies(companyID, r.URL.Query().Get("department_id"), r.URL.Query().Get("agent_id")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req agentos.PolicyRule + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + item, err := agentOSService.UpsertPolicy(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func AgentOSPolicyTestHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + CompanyID string `json:"company_id"` + DepartmentID string `json:"department_id"` + AgentID string `json:"agent_id"` + Action string `json:"action"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + writeJSON(w, http.StatusOK, agentOSService.TestPolicy(req.CompanyID, req.DepartmentID, req.AgentID, req.Action, req.Scope)) +} + +func AgentOSApprovalsHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + items, err := agentOSService.ListApprovals(companyID, r.URL.Query().Get("status")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) +} + +func AgentOSApprovalsResolveHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + ApprovalID string `json:"approval_id"` + Decision string `json:"decision"` + Actor string `json:"actor"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.ApprovalID) == "" { + http.Error(w, "approval_id is required", http.StatusBadRequest) + return + } + approval, err := agentOSService.GetApproval(req.ApprovalID) + if err != nil || !agentOSService.UserOwnsCompany(ownerUserID, approval.CompanyID) { + http.Error(w, "not found", http.StatusNotFound) + return + } + approve := strings.EqualFold(strings.TrimSpace(req.Decision), "approve") + if err := agentOSService.ResolveApproval(req.ApprovalID, approve, req.Actor); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func AgentOSAuditHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := agentOSService.ListAudit(sinceID, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) +} + +func AgentOSAuditVerifyHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, agentOSService.AuditVerify()) +} + +func AgentOSHealthHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, agentOSService.HealthStatus()) +} + +func AgentOSTopologyHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) + if companyID == "" { + items, _ := agentOSService.ListCompanies(ownerUserID) + if len(items) > 0 { + companyID = items[0].ID + } + } + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + writeJSON(w, http.StatusOK, agentOSService.Topology(companyID)) +} + +// AgentInboxHandler handles GET/POST for inter-agent messages. +func AgentInboxHandler(w http.ResponseWriter, r *http.Request) { + if !ensureAgentOSAvailable(w) { + return + } + ownerUserID, ok := requireTenant(w, r) + if !ok { + return + } + agentID := r.URL.Query().Get("agent_id") + companyID := r.URL.Query().Get("company_id") + if r.Method == http.MethodGet && agentID == "" { + http.Error(w, "agent_id required", http.StatusBadRequest) + return + } + switch r.Method { + case http.MethodGet: + if !requireCompanyAccess(w, ownerUserID, companyID) { + return + } + agent, ok := requireAgentAccess(w, ownerUserID, agentID) + if !ok { + return + } + if agent.CompanyID != companyID { + http.Error(w, "agent does not belong to company", http.StatusBadRequest) + return + } + msgs, err := agentOSService.GetAgentInbox(companyID, agentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, msgs) + case http.MethodPost: + var req struct { + FromAgentID string `json:"from_agent_id"` + ToAgentID string `json:"to_agent_id"` + Content string `json:"content"` + CompanyID string `json:"company_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if req.Content == "" || req.FromAgentID == "" || req.ToAgentID == "" { + http.Error(w, "from_agent_id, to_agent_id, content required", http.StatusBadRequest) + return + } + if !requireCompanyAccess(w, ownerUserID, req.CompanyID) { + return + } + fromAgent, ok := requireAgentAccess(w, ownerUserID, req.FromAgentID) + if !ok { + return + } + toAgent, ok := requireAgentAccess(w, ownerUserID, req.ToAgentID) + if !ok { + return + } + if fromAgent.CompanyID != req.CompanyID || toAgent.CompanyID != req.CompanyID { + http.Error(w, "agents do not belong to company", http.StatusBadRequest) + return + } + if err := agentOSService.PostInterAgentMessage(req.CompanyID, req.FromAgentID, req.ToAgentID, req.Content); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/dash/backend/handlers/ai.go b/dash/backend/handlers/ai.go new file mode 100644 index 0000000..c8e475d --- /dev/null +++ b/dash/backend/handlers/ai.go @@ -0,0 +1,2101 @@ +package handlers + +import ( + "bufio" + "bytes" + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/danilrybalkin/apollo-dash/db" + "github.com/danilrybalkin/apollo-dash/tools" + "github.com/google/uuid" +) + +type ChatSession struct { + ID string `json:"id"` + Title string `json:"title"` + Model string `json:"model"` + ProjectPath string `json:"project_path"` + ReasoningEffort string `json:"reasoning_effort"` + ExecutionMode string `json:"execution_mode"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type Attachment struct { + Name string `json:"name"` + Type string `json:"type"` + Data string `json:"data,omitempty"` + Content string `json:"content,omitempty"` +} + +type AiChatRequest struct { + SessionID string `json:"sessionId"` + Model string `json:"model"` + Message string `json:"message"` + Mode string `json:"mode"` + Attachments []Attachment `json:"attachments"` +} + +const ( + reasoningTierNone = "none" + reasoningTierStandard = "standard" + reasoningTierHigh = "high" +) + +func normalizeReasoningEffort(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case reasoningTierHigh: + return reasoningTierHigh + case reasoningTierStandard: + return reasoningTierStandard + case "", reasoningTierNone: + return reasoningTierNone + default: + return reasoningTierNone + } +} + +func displayReasoningEffort(value string) string { + switch normalizeReasoningEffort(value) { + case reasoningTierHigh: + return "High" + case reasoningTierStandard: + return "Standard" + default: + return "None" + } +} + +func isHighReasoningEffort(value string) bool { + return normalizeReasoningEffort(value) == reasoningTierHigh +} + +func usesReasoningDirective(value string) bool { + tier := normalizeReasoningEffort(value) + return tier == reasoningTierStandard || tier == reasoningTierHigh +} + +// Handler for fetching existing sessions +func AiSessionsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method == http.MethodGet { + projectPath := r.URL.Query().Get("project_path") + query := "SELECT id, title, IFNULL(model, ''), IFNULL(project_path, ''), IFNULL(reasoning_effort, 'None'), IFNULL(execution_mode, 'Plan'), created_at, updated_at FROM chat_sessions ORDER BY updated_at DESC" + var rows *sql.Rows + var err error + + if projectPath != "" { + query = "SELECT id, title, IFNULL(model, ''), IFNULL(project_path, ''), IFNULL(reasoning_effort, 'None'), IFNULL(execution_mode, 'Plan'), created_at, updated_at FROM chat_sessions WHERE project_path = ? ORDER BY updated_at DESC" + rows, err = db.DB.Query(query, projectPath) + } else { + query = "SELECT id, title, IFNULL(model, ''), IFNULL(project_path, ''), IFNULL(reasoning_effort, 'None'), IFNULL(execution_mode, 'Plan'), created_at, updated_at FROM chat_sessions WHERE project_path = '' OR project_path IS NULL ORDER BY updated_at DESC" + rows, err = db.DB.Query(query) + } + + if err != nil { + http.Error(w, "Failed to fetch sessions", http.StatusInternalServerError) + return + } + defer rows.Close() + + var sessions []ChatSession + for rows.Next() { + var s ChatSession + if err := rows.Scan(&s.ID, &s.Title, &s.Model, &s.ProjectPath, &s.ReasoningEffort, &s.ExecutionMode, &s.CreatedAt, &s.UpdatedAt); err != nil { + continue + } + s.ReasoningEffort = displayReasoningEffort(s.ReasoningEffort) + sessions = append(sessions, s) + } + if sessions == nil { + sessions = []ChatSession{} + } + json.NewEncoder(w).Encode(sessions) + return + } + + if r.Method == http.MethodPost { + var reqBody struct { + ProjectPath string `json:"project_path"` + ReasoningEffort string `json:"reasoning_effort"` + ExecutionMode string `json:"execution_mode"` + } + json.NewDecoder(r.Body).Decode(&reqBody) + + reqBody.ReasoningEffort = displayReasoningEffort(reqBody.ReasoningEffort) + if reqBody.ExecutionMode == "" { + reqBody.ExecutionMode = "Plan" + } + + id := uuid.New().String() + title := "New Chat" + _, err := db.DB.Exec("INSERT INTO chat_sessions (id, title, project_path, reasoning_effort, execution_mode) VALUES (?, ?, ?, ?, ?)", id, title, reqBody.ProjectPath, reqBody.ReasoningEffort, reqBody.ExecutionMode) + if err != nil { + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(ChatSession{ + ID: id, + Title: title, + ProjectPath: reqBody.ProjectPath, + ReasoningEffort: reqBody.ReasoningEffort, + ExecutionMode: reqBody.ExecutionMode, + }) + return + } + + if r.Method == http.MethodPut { + var reqBody struct { + ID string `json:"id"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort"` + ExecutionMode string `json:"execution_mode"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + reqBody.ReasoningEffort = displayReasoningEffort(reqBody.ReasoningEffort) + + // Update only the provided fields. + // For simplicity, we assume frontend sends the full new state. + _, err := db.DB.Exec("UPDATE chat_sessions SET model = ?, reasoning_effort = ?, execution_mode = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + reqBody.Model, reqBody.ReasoningEffort, reqBody.ExecutionMode, reqBody.ID) + if err != nil { + http.Error(w, "Failed to update session", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]string{"status": "updated"}) + return + } + + if r.Method == http.MethodDelete { + sid := r.URL.Query().Get("id") + if sid == "" { + http.Error(w, "Session ID required", http.StatusBadRequest) + return + } + _, err := db.DB.Exec("DELETE FROM chat_sessions WHERE id = ?", sid) // cascades to messages + if err != nil { + http.Error(w, "Failed to delete session", http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +// Fetch messages for a specific session +func AiMessagesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + sid := r.URL.Query().Get("sessionId") + if sid == "" { + http.Error(w, "Session ID required", http.StatusBadRequest) + return + } + + rows, err := db.DB.Query("SELECT role, content FROM chat_messages WHERE session_id = ? ORDER BY id ASC", sid) + if err != nil { + http.Error(w, "Failed to fetch messages", http.StatusInternalServerError) + return + } + defer rows.Close() + + var msgs []ChatMessage + for rows.Next() { + var m ChatMessage + if err := rows.Scan(&m.Role, &m.Content); err != nil { + continue + } + if m.Role == "assistant" { + systemLogRe := regexp.MustCompile("(?is)````system_log[\\s\\S]*?````") + m.Content = strings.TrimSpace(systemLogRe.ReplaceAllString(m.Content, "")) + if strings.Contains(m.Content, "[Asked User:") && !strings.Contains(m.Content, "````question") { + askedRe := regexp.MustCompile(`(?is)\[Asked User:\s*(.*?)\]`) + qtxt := "" + if match := askedRe.FindStringSubmatch(m.Content); len(match) > 1 { + qtxt = strings.TrimSpace(match[1]) + } else { + lowered := strings.ToLower(m.Content) + if idx := strings.Index(lowered, "[asked user:"); idx >= 0 { + qtxt = strings.TrimSpace(m.Content[idx+len("[Asked User:"):]) + qtxt = strings.TrimSpace(strings.TrimSuffix(qtxt, "]")) + } + } + if qtxt != "" { + qPayload := map[string]interface{}{ + "question": qtxt, + "options": []string{"Answer in chat"}, + } + qJSON, _ := json.Marshal(qPayload) + if askedRe.MatchString(m.Content) { + m.Content = askedRe.ReplaceAllString(m.Content, fmt.Sprintf("````question\n%s\n````", string(qJSON))) + } else { + m.Content = fmt.Sprintf("````question\n%s\n````", string(qJSON)) + } + } + } + } + msgs = append(msgs, m) + } + + if msgs == nil { + msgs = []ChatMessage{} + } + json.NewEncoder(w).Encode(msgs) +} + +// Fetches available OpenRouter Models and Local Ollama Models +func AiModelsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + apiKey := ResolveOpenRouterKey(CurrentUserID(r)) + + var orResp map[string]interface{} + + if apiKey != "" && apiKey != "your_openrouter_api_key_here" { + req, _ := http.NewRequest("GET", "https://openrouter.ai/api/v1/models", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + client := &http.Client{Timeout: 5 * time.Second} + if resp, err := client.Do(req); err == nil && resp.StatusCode == 200 { + defer resp.Body.Close() + json.NewDecoder(resp.Body).Decode(&orResp) + } + } + + if orResp == nil { + orResp = map[string]interface{}{"data": []interface{}{}} + } + + dataList, ok := orResp["data"].([]interface{}) + if !ok { + dataList = []interface{}{} + } + + ollamaUrl := os.Getenv("OLLAMA_API_URL") + if ollamaUrl != "" { + req, _ := http.NewRequest("GET", ollamaUrl+"/api/tags", nil) + client := &http.Client{Timeout: 2 * time.Second} + if resp, err := client.Do(req); err == nil && resp.StatusCode == 200 { + defer resp.Body.Close() + var ollamaResp struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err == nil { + for _, m := range ollamaResp.Models { + localModel := map[string]interface{}{ + "id": "local/" + m.Name, + "name": "(Local) " + m.Name, + "architecture": map[string]interface{}{ + // We can assume text, maybe add image if the ID matches llava + "input_modalities": []string{"text"}, + }, + } + // If the user's running a vision model like llava + if strings.Contains(strings.ToLower(m.Name), "llava") { + localModel["architecture"].(map[string]interface{})["input_modalities"] = []string{"text", "image"} + } + dataList = append([]interface{}{localModel}, dataList...) + } + } + } + } + + // Filter by Featured Models unless ?all=1 is passed + settings := GetCurrentSettings(CurrentUserID(r)) + if r.URL.Query().Get("all") != "1" && len(settings.FeaturedModels) > 0 { + featuredMap := make(map[string]bool) + for _, fm := range settings.FeaturedModels { + featuredMap[fm] = true + } + + var filteredList []interface{} + for _, m := range dataList { + mMap, ok := m.(map[string]interface{}) + if !ok { + continue + } + idStr, _ := mMap["id"].(string) + if featuredMap[idStr] { + filteredList = append(filteredList, m) + } + } + dataList = filteredList + } + + orResp["data"] = dataList + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(orResp) +} + +// Constructs the master personality block from Settings and SQLite Facts +func ConstructPersonalityPrompt(userID string, mode string, reasoningEffort string, executionMode string, projectPath string) string { + settings := GetCurrentSettings(userID) + var builder strings.Builder + + builder.WriteString(settings.SystemPrompt) + builder.WriteString("\n\n") + + rows, err := db.DB.Query("SELECT fact FROM personality_facts ORDER BY id ASC") + if err == nil { + var facts []string + for rows.Next() { + var fact string + if err := rows.Scan(&fact); err == nil { + facts = append(facts, fact) + } + } + rows.Close() + + if len(facts) > 0 { + builder.WriteString("\n") + builder.WriteString("You have permanently memorized the following facts across all sessions:\n") + for _, f := range facts { + builder.WriteString("- " + f + "\n") + } + builder.WriteString("\n\n") + } + } + + builder.WriteString("CORE DIRECTIVE: If the user explicitly tells you something you should remember permanently, or you establish a permanent detail about your persona, output it anywhere in your response wrapped in `fact here` tags so it can be committed to your core memory bank.\n\n") + builder.WriteString(fmt.Sprintf("SYSTEM CONTEXT: The current local server time is %s.\n\n", time.Now().Format("Monday, 02 Jan 2006 15:04:05 MST"))) + + if skillsManifest := tools.GetSkillsManifest(); skillsManifest != "" { + builder.WriteString(skillsManifest) + builder.WriteString("\n\n") + } + + if usesReasoningDirective(reasoningEffort) { + builder.WriteString(` +========================================= +MANDATORY REASONING DIRECTIVE +========================================= +Before answering any request, you MUST engage in deep, step-by-step Chain-of-Thought reasoning. +You must break down the problem, analyze constraints, and explore potential solutions. +If during your reasoning you realize a step is flawed, you must explicitly write out your Self-Correction and pivot your approach. +This ensures highly accurate, logical, and thoroughly vetted intelligent responses. + +You MUST enclose your internal thought process inside a markdown code block with the language "reasoning". +Example: +` + "````" + `reasoning +1. First I need to analyze the user's request... +2. The user wants X. Let's break this down... +Wait, if I do X, it might break Y. Let me self-correct and approach this via Z instead... +` + "````" + ` + +After closing the reasoning block, output your final, perfectly formatted direct response to the user. +`) + } + if mode == "coding" { + builder.WriteString("\n\nMODE: CODING (ACTIVE)\n") + builder.WriteString(fmt.Sprintf("\nWORKING DIRECTORY: %s\n", projectPath)) + builder.WriteString("You are currently in autonomous coding mode. Your primary objective is to execute tasks using your tools. Do not just talk—ACT!\n") + builder.WriteString(` +AGENTIC LOOP: +Work through every task in three blended phases — chain as many tool calls as needed: +1. GATHER: Understand the codebase first. Read files, list directories, grep for patterns, find files, check types. Never skip this phase on unfamiliar code. +2. ACT: Make changes. Edit files, write new files, rename/move things, run commands, run tests. One action per tool call — be surgical and precise. +3. VERIFY: After every change, confirm it worked. Run the relevant check_code, execute tests, read the modified file back, diff it. If verification fails, loop back to ACT with the corrected approach. + +SKILL-BASED LEARNING: +You have a dynamic skill library. When a skill is needed, call load_skill(name) to load its full content. Skills provide deep, specialized knowledge on demand without bloating every conversation. + +TOOL DISCIPLINE: +- After ANY file edit, immediately call check_code on the modified file to catch errors. +- After ANY execute_command that should produce output, verify the output matches expectations. +- If a test fails, re-read the relevant source files before attempting a fix — never guess. +- Chain dozens of tool calls confidently. Stop only when the task is fully verified complete. +- If you receive a user message mid-task, STOP immediately and address it before continuing. +`) + builder.WriteString(` +PLANNING DIRECTIVE: +If a user request requires a complex or multi-component build (like "create a web app"), DO NOT guess the architecture, tech stack, or design preferences. +Instead, FIRST use the 'ask_user_question' tool to prompt the user for their preferences (e.g. asking them to choose between React/Vue, or asking about a specific visual style). +You can ask as many questions as you need using the 'ask_user_question' tool. Wait for their answers. + +Once you have gathered enough context, you must draft a concrete, self-directed architecture spec. +Then, ALWAYS propose an execution plan. +Before the plan block, include a short architecture summary (3-6 bullets). +The FINAL content of your message MUST be exactly one markdown code block with language "plan" containing ONLY valid JSON. Nothing should appear after that plan block. +` + "````" + `plan +{"title":"","summary":"<1-2 sentence summary>","steps":[{"index":0,"label":"","details":"","validation":"","status":"pending"}]} +` + "````" + ` +After outputting the plan block, stop and wait for the user to respond. +If the user responds with approval (for example "yes proceed"), treat it as PLAN_APPROVED and execute each step in sequence. +After completing each step, output a plan step update: +` + "````" + `step_update +{"planId": "", "index": , "status": "done"} +` + "````" + ` +If the user suggests changes instead, revise the plan summary + JSON and output a full new plan block (same format). + +SUBAGENT DIRECTIVE: +You have access to a spawn_subagent tool. You should PROACTIVELY decide to use it — you do NOT need to be asked. +Spawn a background subagent when any of the following is true: +- The task involves deep research (e.g. "find the best X", "compare Y options", "analyze Z codebase") +- The task can be parallelized safely (e.g. "review all these files" → spawn one reviewer per major file/module) +- The task is expected to take many tool calls and would block the main conversation for a long time +- The user's main task benefits from concurrent validation, testing, or fact-checking +When spawning: use a descriptive name (e.g. "Researcher-1", "CodeReviewer", "Tester"), write a precise self-contained task description, and ALWAYS pass the current session_id so the user gets notified when it finishes. +After spawning, immediately tell the user you've spawned an agent and continue the main thread without waiting. +`) + if !isGitWorkspace(projectPath) { + builder.WriteString(` +GITLESS WORKSPACE OVERRIDE: +- This workspace is not a git repository. +- Do NOT ask the user to create branches, commits, pull requests, or run git commands. +- Do NOT rely on worktrees or PR-based orchestration. +- Execute tasks through direct file edits + validation commands only. +`) + } + } else if mode == "talking" { + builder.WriteString("\n\nMODE: TALKING (ACTIVE)\n") + builder.WriteString("You are currently in conversational talking mode. Your objective is communication, brainstorming, or clarification. Only use tools if strictly necessary for answering a question. If asked to write or edit code, provide the code snippets directly in your response chat without triggering actual file system edits.\n") + } + + return builder.String() +} + +func isGitWorkspace(projectPath string) bool { + projectPath = strings.TrimSpace(projectPath) + if projectPath == "" { + return false + } + gitPath := filepath.Join(projectPath, ".git") + info, err := os.Stat(gitPath) + if err != nil { + return false + } + // .git can be a directory or a file (worktree/submodule pointer) + return info.IsDir() || info.Mode().IsRegular() +} + +func extractQuestionFromAssistantContent(content string) string { + content = strings.TrimSpace(content) + if content == "" { + return "" + } + + questionBlockRe := regexp.MustCompile("(?is)````question\\s*([\\s\\S]*?)\\s*````") + if blocks := questionBlockRe.FindAllStringSubmatch(content, -1); len(blocks) > 0 { + for i := len(blocks) - 1; i >= 0; i-- { + raw := strings.TrimSpace(blocks[i][1]) + var payload struct { + Question string `json:"question"` + } + if json.Unmarshal([]byte(raw), &payload) == nil { + if q := strings.TrimSpace(payload.Question); q != "" { + return q + } + } + } + } + + askedRe := regexp.MustCompile(`(?is)\[Asked User:\s*(.*?)\]`) + if m := askedRe.FindStringSubmatch(content); len(m) > 1 { + return strings.TrimSpace(m[1]) + } + return "" +} + +func hasPlanBlock(content string) bool { + return regexp.MustCompile("(?is)````plan\\s*[\\s\\S]*?````").MatchString(content) +} + +func looksLikePlanApproval(msg string) bool { + m := strings.ToLower(strings.TrimSpace(msg)) + if m == "" { + return false + } + if strings.EqualFold(strings.TrimSpace(msg), "PLAN_APPROVED") { + return true + } + disqualifiers := []string{"but", "except", "instead", "change", "edit", "revise", "?"} + for _, bad := range disqualifiers { + if strings.Contains(m, bad) { + return false + } + } + approvals := []string{ + "yes", "yep", "yeah", "proceed", "go ahead", "approved", "approve", + "looks good", "ship it", "continue", "do it", "ok proceed", "you can proceed", + } + for _, token := range approvals { + if strings.Contains(m, token) { + return true + } + } + return false +} + +// Process Chat Generation and DB storage +func AiChatHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + apiKey := ResolveOpenRouterKey(CurrentUserID(r)) + ollamaUrl := os.Getenv("OLLAMA_API_URL") + + var chatReq AiChatRequest + if err := json.NewDecoder(r.Body).Decode(&chatReq); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if chatReq.SessionID == "" || chatReq.Message == "" { + http.Error(w, "SessionID and Message are required", http.StatusBadRequest) + return + } + + sendSSEChunk := func(w http.ResponseWriter, content string) { + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + chunk.Choices = append(chunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + chunk.Choices[0].Delta.Content = content + b, _ := json.Marshal(chunk) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + extractAskedUserQuestion := func(input string) (string, bool) { + re := regexp.MustCompile(`(?is)\[Asked User:\s*(.*?)\]`) + m := re.FindStringSubmatch(input) + if len(m) < 2 { + return "", false + } + return strings.TrimSpace(m[1]), true + } + + var reasoningEffort, executionMode, projectPath string + + // 1. Process documents first + for _, att := range chatReq.Attachments { + if att.Type == "file" { + chatReq.Message += "\n\n\n" + att.Content + "\n" + } + } + + // 2. Fetch past messages for this session + questionBlockRe := regexp.MustCompile("(?is)````question\\s*([\\s\\S]*?)\\s*````") + uiBlockRe := regexp.MustCompile("(?is)````(?:reasoning|tool_action|compacting|step_update|plan|checkpoint|terminal|system_log)[\\s\\S]*?````") + rows, err := db.DB.Query("SELECT role, content FROM chat_messages WHERE session_id = ? ORDER BY id ASC", chatReq.SessionID) + var historyRows []ChatMessage + var openRouterMsgs []map[string]interface{} + if err == nil { + for rows.Next() { + var role, content string + rows.Scan(&role, &content) + historyRows = append(historyRows, ChatMessage{Role: role, Content: content}) + } + rows.Close() + } + + var lastAssistantRaw string + for i := len(historyRows) - 1; i >= 0; i-- { + if historyRows[i].Role == "assistant" { + lastAssistantRaw = historyRows[i].Content + break + } + } + lastAssistantQuestion := extractQuestionFromAssistantContent(lastAssistantRaw) + lastAssistantHasPlan := hasPlanBlock(lastAssistantRaw) + + for _, hr := range historyRows { + role := hr.Role + content := hr.Content + + // Context Hygiene: Strip UI-only blocks from assistant history before sending to LLM + if role == "assistant" { + content = questionBlockRe.ReplaceAllString(content, "") + content = uiBlockRe.ReplaceAllString(content, "") + askedRe := regexp.MustCompile(`(?is)\[Asked User:\s*(.*?)\]`) + content = askedRe.ReplaceAllString(content, "") + re := regexp.MustCompile(`(?is).*?`) + content = re.ReplaceAllString(content, "") + content = strings.TrimSpace(content) + } + + msgObj := map[string]interface{}{"role": role} + if len(content) > 0 && content[0] == '[' { + var contentArr []map[string]interface{} + if err := json.Unmarshal([]byte(content), &contentArr); err == nil { + msgObj["content"] = contentArr + } else { + msgObj["content"] = content + } + } else { + msgObj["content"] = content + } + openRouterMsgs = append(openRouterMsgs, msgObj) + } + + effectiveUserMessage := chatReq.Message + var orchestrationHints []string + if lastAssistantQuestion != "" && strings.TrimSpace(chatReq.Message) != "" { + orchestrationHints = append(orchestrationHints, fmt.Sprintf("The assistant previously asked the user: %q. The user's latest message is the answer: %q. Treat it as answered and continue. Do NOT repeat the same question unless the user's answer is empty/ambiguous.", lastAssistantQuestion, chatReq.Message)) + } + if lastAssistantHasPlan { + if looksLikePlanApproval(chatReq.Message) { + effectiveUserMessage = "PLAN_APPROVED" + orchestrationHints = append(orchestrationHints, "The user approved the latest plan. Start execution now. Do not ask a new clarification question unless a hard blocker appears.") + } else if strings.TrimSpace(chatReq.Message) != "" { + orchestrationHints = append(orchestrationHints, "The user requested plan changes. Produce a fully revised plan with updated summary and detailed steps, and place the plan block at the end of the message.") + } + } + + // 3. Construct new user message content + var imageAttachments []map[string]interface{} + for _, att := range chatReq.Attachments { + if att.Type == "image" { + imageAttachments = append(imageAttachments, map[string]interface{}{ + "type": "image_url", + "image_url": map[string]string{ + "url": att.Data, + }, + }) + } + } + + var dbContentStr string + if len(imageAttachments) > 0 { + var contentArr []map[string]interface{} + contentArr = append(contentArr, map[string]interface{}{ + "type": "text", + "text": effectiveUserMessage, + }) + for _, img := range imageAttachments { + contentArr = append(contentArr, img) + } + + // For OpenRouter + openRouterMsgs = append(openRouterMsgs, map[string]interface{}{"role": "user", "content": contentArr}) + + // For SQLite + b, _ := json.Marshal(contentArr) + dbContentStr = string(b) + } else { + // Normal text + openRouterMsgs = append(openRouterMsgs, map[string]interface{}{"role": "user", "content": effectiveUserMessage}) + dbContentStr = chatReq.Message + } + + // 4. Save new user message to DB + db.DB.Exec("INSERT INTO chat_messages (session_id, role, content) VALUES (?, 'user', ?)", chatReq.SessionID, dbContentStr) + db.DB.Exec("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", chatReq.SessionID) + + // If this is the FIRST message in a session (len == 1), update the title and model automatically + if len(openRouterMsgs) == 1 { + title := chatReq.Message + if len(title) > 30 { + title = title[:27] + "..." + } + db.DB.Exec("UPDATE chat_sessions SET title = ?, model = ? WHERE id = ?", title, chatReq.Model, chatReq.SessionID) + } + + // Load session runtime mode/config once and reuse below. + err = db.DB.QueryRow("SELECT IFNULL(reasoning_effort, 'None'), IFNULL(execution_mode, 'Plan'), IFNULL(project_path, '') FROM chat_sessions WHERE id = ?", chatReq.SessionID).Scan(&reasoningEffort, &executionMode, &projectPath) + if err != nil { + reasoningEffort = "None" + executionMode = "Plan" + projectPath = "" + } + reasoningEffort = displayReasoningEffort(reasoningEffort) + gitWorkspace := isGitWorkspace(projectPath) + if !gitWorkspace && strings.EqualFold(chatReq.Mode, "coding") { + orchestrationHints = append(orchestrationHints, "Environment note: this project is gitless. Do not ask for git actions or PR flow; continue with direct code edits and local validation only.") + } + + if len(orchestrationHints) > 0 { + for i := len(orchestrationHints) - 1; i >= 0; i-- { + openRouterMsgs = append([]map[string]interface{}{ + {"role": "system", "content": orchestrationHints[i]}, + }, openRouterMsgs...) + } + } + + if (apiKey == "" || apiKey == "your_openrouter_api_key_here") && ollamaUrl == "" { + http.Error(w, `{"error": "No AI Providers configured (Missing OpenRouter API Key and OLLAMA_API_URL)"}`, http.StatusServiceUnavailable) + return + } + + // Persist assistant output in DB incrementally so reloads can recover ongoing work. + var assistantRowID int64 + if res, err := db.DB.Exec("INSERT INTO chat_messages (session_id, role, content) VALUES (?, 'assistant', '')", chatReq.SessionID); err == nil { + if id, idErr := res.LastInsertId(); idErr == nil { + assistantRowID = id + } + } + lastPersistedAssistant := "" + persistAssistant := func(content string) { + if content == lastPersistedAssistant { + return + } + lastPersistedAssistant = content + if assistantRowID > 0 { + db.DB.Exec("UPDATE chat_messages SET content = ? WHERE id = ?", content, assistantRowID) + } else { + db.DB.Exec("INSERT INTO chat_messages (session_id, role, content) VALUES (?, 'assistant', ?)", chatReq.SessionID, content) + } + db.DB.Exec("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", chatReq.SessionID) + } + + // 4. Send request to either Local Ollama or OpenRouter + model := chatReq.Model + var targetUrl string + var reqApiKey string + + if strings.HasPrefix(model, "local/") { + if ollamaUrl == "" { + http.Error(w, "OLLAMA_API_URL is not configured", http.StatusBadRequest) + return + } + model = strings.TrimPrefix(model, "local/") + targetUrl = ollamaUrl + "/v1/chat/completions" + reqApiKey = "Bearer local" + } else { + if model == "" { + settings := GetCurrentSettings(CurrentUserID(r)) + if settings.DefaultModel != "" { + model = settings.DefaultModel + } else { + model = "meta-llama/llama-3-8b-instruct:free" // fallback + } + } + targetUrl = "https://openrouter.ai/api/v1/chat/completions" + reqApiKey = "Bearer " + apiKey + } + + // 5. Inject RAG Episodes + episodes := SearchEpisodes(chatReq.Message, chatReq.SessionID) + if episodes != "" { + sysMsg := map[string]interface{}{"role": "system", "content": episodes} + openRouterMsgs = append([]map[string]interface{}{sysMsg}, openRouterMsgs...) + log.Println("Memory System: Injected chronological memory RAG block into context stream.") + } + + // 5. Inject Global Personality Prompt + personaPrompt := ConstructPersonalityPrompt(CurrentUserID(r), chatReq.Mode, reasoningEffort, executionMode, projectPath) + if personaPrompt != "" { + sysMsg := map[string]interface{}{"role": "system", "content": personaPrompt} + openRouterMsgs = append([]map[string]interface{}{sysMsg}, openRouterMsgs...) + } + + // --- SMART CONTEXT COMPRESSION --- + // Estimate total token usage. If over threshold, compress the middle of the conversation. + { + totalChars := 0 + for _, msg := range openRouterMsgs { + if c, ok := msg["content"].(string); ok { + totalChars += len(c) + } + } + estimatedTokens := totalChars / 4 + + settings := GetCurrentSettings(CurrentUserID(r)) + compressionThreshold := settings.AutoCompactTokens + if compressionThreshold <= 0 { + compressionThreshold = 80000 + } + + if estimatedTokens > compressionThreshold && len(openRouterMsgs) > 6 { + log.Printf("Context Compression: Estimated %d tokens — compressing history.", estimatedTokens) + + // Stream compacting indicator to UI + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + var compactChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + compactChunk.Choices = append(compactChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + compactChunk.Choices[0].Delta.Content = "````compacting\nCompacting context...\n````\n\n" + compactB, _ := json.Marshal(compactChunk) + w.Write([]byte("data: ")) + w.Write(compactB) + w.Write([]byte("\n\n")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Build a compression prompt from the middle of the conversation + sysMessages := []map[string]interface{}{} + var histBuilder strings.Builder + for _, msg := range openRouterMsgs { + if role, ok := msg["role"].(string); ok && role == "system" { + sysMessages = append(sysMessages, msg) + continue + } + if c, ok := msg["content"].(string); ok { + role, _ := msg["role"].(string) + histBuilder.WriteString(fmt.Sprintf("%s: %s\n\n", role, c)) + } + } + compressionMsgs := []map[string]interface{}{ + {"role": "system", "content": "You are a context compression engine. Summarize the following conversation history into a dense, factual memory block. Preserve all key decisions, code changes, files edited, commands run, and important context. Output plain text only."}, + {"role": "user", "content": histBuilder.String()}, + } + compressPayload, _ := json.Marshal(map[string]interface{}{ + "model": model, + "messages": compressionMsgs, + "stream": false, + }) + cReq, _ := http.NewRequest("POST", targetUrl, bytes.NewBuffer(compressPayload)) + cReq.Header.Set("Authorization", reqApiKey) + cReq.Header.Set("Content-Type", "application/json") + cClient := &http.Client{Timeout: 60 * time.Second} + cResp, cErr := cClient.Do(cReq) + if cErr == nil && cResp.StatusCode == 200 { + var cResult struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + json.NewDecoder(cResp.Body).Decode(&cResult) + cResp.Body.Close() + if len(cResult.Choices) > 0 { + summaryContent := cResult.Choices[0].Message.Content + summaryMsg := map[string]interface{}{ + "role": "system", + "content": "\n" + summaryContent + "\n", + } + // Rebuild: system messages + compressed summary + last 4 messages + lastN := openRouterMsgs + if len(lastN) > 4 { + lastN = lastN[len(lastN)-4:] + } + openRouterMsgs = append(sysMessages, summaryMsg) + openRouterMsgs = append(openRouterMsgs, lastN...) + log.Println("Context Compression: History compressed successfully.") + } + } + } + } + + // --- TEST-TIME COMPUTE ORCHESTRATOR (HIGH EFFORT) --- + // --- TEST-TIME COMPUTE ORCHESTRATOR (HIGH EFFORT) --- + if isHighReasoningEffort(reasoningEffort) { + log.Println("Test-Time Compute: High Effort Orchestrator Initiated.") + + orchestratorStartTime := time.Now() + + // Phase 1: Decomposition + decompMsgs := []map[string]interface{}{ + {"role": "system", "content": "You are an analytical Engine. The user has submitted a prompt. Your ONLY objective is to break their request down into a sequential array of abstract reasoning steps required to perfectly solve it. If the request requires any factual lookup, explicitly include steps to search for and double-check those facts. Output ONLY a valid JSON array of strings, nothing else. Example: [\"Analyze constraints\", \"Search for facts\", \"Critique logic\", \"Finalize\"]"}, + {"role": "user", "content": chatReq.Message}, + } + + decompPayload, _ := json.Marshal(map[string]interface{}{ + "model": model, + "messages": decompMsgs, + "stream": false, + }) + + dReq, _ := http.NewRequest("POST", targetUrl, bytes.NewBuffer(decompPayload)) + dReq.Header.Set("Authorization", reqApiKey) + dReq.Header.Set("Content-Type", "application/json") + dClient := &http.Client{Timeout: 60 * time.Second} + dResp, dErr := dClient.Do(dReq) + + var steps []string + if dErr == nil && dResp.StatusCode == 200 { + var dResult struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + json.NewDecoder(dResp.Body).Decode(&dResult) + dResp.Body.Close() + + if len(dResult.Choices) > 0 { + rawJson := strings.TrimPrefix(dResult.Choices[0].Message.Content, "```json") + rawJson = strings.TrimPrefix(rawJson, "```") + rawJson = strings.TrimSuffix(rawJson, "```") + json.Unmarshal([]byte(strings.TrimSpace(rawJson)), &steps) + } + } + + if len(steps) == 0 { + steps = []string{"Analyze the request logically", "Determine the correct response carefully"} + } + + // Phase 2: Orchestration + var fullThoughtChain strings.Builder + fullThoughtChain.WriteString("\n") + + sendSSEChunk(w, "````reasoning\n") + + orchestratorContext := append([]map[string]interface{}(nil), openRouterMsgs...) + + for i, step := range steps { + fmt.Fprintf(w, "data: %s\n\n", fmt.Sprintf("\n> **Step %d:** %s\n", i+1, step)) + w.(http.Flusher).Flush() + + stepPrompt := fmt.Sprintf("You are currently executing Step %d of your reasoning plan: '%s'.\n\nPlease output your internal thoughts processing *only* this step. You must gather facts and double-check logic. Do not execute the final answer yet. Just think aloud.", i+1, step) + orchestratorContext = append(orchestratorContext, map[string]interface{}{"role": "user", "content": stepPrompt}) + + stepPayload, _ := json.Marshal(map[string]interface{}{ + "model": model, + "messages": orchestratorContext, + "stream": false, + "tools": tools.GetAvailableTools(), // Give reasoning mid-thought access + }) + sReq, _ := http.NewRequest("POST", targetUrl, bytes.NewBuffer(stepPayload)) + sReq.Header.Set("Authorization", reqApiKey) + sReq.Header.Set("Content-Type", "application/json") + sResp, sErr := dClient.Do(sReq) + + if sErr == nil && sResp.StatusCode == 200 { + var sResult struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ToolCalls []struct { + Id string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + } + json.NewDecoder(sResp.Body).Decode(&sResult) + sResp.Body.Close() + + if len(sResult.Choices) > 0 { + msg := sResult.Choices[0].Message + + // Handle Mid-Reasoning Tool Use! + if len(msg.ToolCalls) > 0 { + for _, tc := range msg.ToolCalls { + var args map[string]interface{} + json.Unmarshal([]byte(tc.Function.Arguments), &args) + + sendSSEChunk(w, fmt.Sprintf("*(Calling tool %s to gather facts...)*\n", tc.Function.Name)) + + toolRes := tools.ExecuteTool(tc.Function.Name, tc.Function.Arguments, projectPath) + + // Inject back into step + orchestratorContext = append(orchestratorContext, map[string]interface{}{ + "role": "assistant", "content": "", "tool_calls": msg.ToolCalls, + }) + orchestratorContext = append(orchestratorContext, map[string]interface{}{ + "role": "tool", "content": toolRes, "tool_call_id": tc.Id, + }) + } + // We don't formally re-loop here to keep it simple; we just inject the tools so it has them in Phase 4 Synthesis! + } + + thought := msg.Content + if thought != "" { + orchestratorContext = append(orchestratorContext, map[string]interface{}{"role": "assistant", "content": thought}) + fullThoughtChain.WriteString(fmt.Sprintf("-- Step %d: %s --\n%s\n\n", i+1, step, thought)) + + sendSSEChunk(w, thought) + } + } + } + } + + elapsedSecs := time.Since(orchestratorStartTime).Seconds() + fullThoughtChain.WriteString("") + + sendSSEChunk(w, fmt.Sprintf("\n\n[Completed in %.1fs]\n````\n\n", elapsedSecs)) + + // Phase 4: Synthesis + // Prepend the entire massively generated thought process to the user's final message in the Tool Loop + lastUserIdx := len(openRouterMsgs) - 1 + if openRouterMsgs[lastUserIdx]["role"] == "user" { + openRouterMsgs[lastUserIdx]["content"] = fmt.Sprintf("%s\n\nUser Request: %s", fullThoughtChain.String(), chatReq.Message) + } + } + + // --- MAIN TOOL LOOP --- + // Models can call multiple tools in sequence. We loop until the model ceases calling tools. + debugSystemLogs := strings.EqualFold(strings.TrimSpace(os.Getenv("APOLLO_DEBUG_SYSTEM_LOG")), "1") || + strings.EqualFold(strings.TrimSpace(os.Getenv("APOLLO_DEBUG_SYSTEM_LOG")), "true") + timelineResponse := "" + for attempt := 0; attempt < 20; attempt++ { + // Log the prompt to the system log panel + if chatReq.Mode == "coding" && debugSystemLogs { + promptLog, _ := json.MarshalIndent(openRouterMsgs, "", " ") + sendSSEChunk(w, fmt.Sprintf("````system_log\n[LLM PROMPT - Turn %d]\n%s\n````\n", attempt+1, string(promptLog))) + } + + payload, _ := json.Marshal(map[string]interface{}{ + "model": model, + "messages": openRouterMsgs, + "stream": true, + "tools": tools.GetAvailableTools(), + }) + + req, _ := http.NewRequest("POST", targetUrl, bytes.NewBuffer(payload)) + req.Header.Set("Authorization", reqApiKey) + req.Header.Set("Content-Type", "application/json") + if !strings.HasPrefix(chatReq.Model, "local/") { + req.Header.Set("X-Title", "Apollo Dashboard") + } + + client := &http.Client{Timeout: 300 * time.Second} + resp, err := client.Do(req) + + if err != nil { + if attempt == 0 { + http.Error(w, "Upstream AI error", http.StatusBadGateway) + } + return + } + + if resp.StatusCode != 200 { + if attempt == 0 { + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + } + resp.Body.Close() + return + } + + if attempt == 0 { + // Only send headers on the very first loop before stream begins + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + } + + flusher, ok := w.(http.Flusher) + if !ok && attempt == 0 { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + resp.Body.Close() + return + } + + reader := bufio.NewReader(resp.Body) + var fullResponse string + var toolCalls []map[string]interface{} + var hiddenBuffer string + askedLeakBuffer := "" + inAskedLeak := false + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + break + } + + // Parse strictly to see if this is a tool payload or a text payload + cleanLine := bytes.TrimSpace(line) + isToolChunk := false + + if bytes.HasPrefix(cleanLine, []byte("data: ")) && string(cleanLine) != "data: [DONE]" { + data := bytes.TrimPrefix(cleanLine, []byte("data: ")) + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Thought string `json:"thought"` + ToolCalls []map[string]interface{} `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` + } + + if err := json.Unmarshal(data, &chunk); err == nil && len(chunk.Choices) > 0 { + delta := chunk.Choices[0].Delta + if len(delta.ToolCalls) > 0 { + isToolChunk = true + + // Buffer tool calls (SSE models stream JSON args in chunks) + for _, tc := range delta.ToolCalls { + idxFloat, ok := tc["index"].(float64) + if !ok { + continue + } + idx := int(idxFloat) + + for len(toolCalls) <= idx { + toolCalls = append(toolCalls, make(map[string]interface{})) + } + + if id, exists := tc["id"].(string); exists { + toolCalls[idx]["id"] = id + } + if function, exists := tc["function"].(map[string]interface{}); exists { + if _, fExists := toolCalls[idx]["function"]; !fExists { + toolCalls[idx]["function"] = make(map[string]interface{}) + } + funcPtr := toolCalls[idx]["function"].(map[string]interface{}) + + if n, nExists := function["name"].(string); nExists { + funcPtr["name"] = n + } + if argChunk, argExists := function["arguments"].(string); argExists { + if existingArgs, ex := funcPtr["arguments"].(string); ex { + funcPtr["arguments"] = existingArgs + argChunk + } else { + funcPtr["arguments"] = argChunk + } + } + } + } + } else if delta.Content != "" || delta.ReasoningContent != "" || delta.Thought != "" { + combinedText := delta.Content + if delta.ReasoningContent != "" { + combinedText = "````reasoning\n" + delta.ReasoningContent + "\n````\n" + combinedText + } else if delta.Thought != "" { + combinedText = "````reasoning\n" + delta.Thought + "\n````\n" + combinedText + } + + // Safety filters to prevent internal/debug leakage in user-visible chat. + if strings.Contains(strings.ToLower(combinedText), "[llm prompt - turn") { + combinedText = "" + } + sysLogInlineRe := regexp.MustCompile("(?is)````system_log[\\s\\S]*?````") + combinedText = sysLogInlineRe.ReplaceAllString(combinedText, "") + + // Convert legacy "[Asked User: ...]" leakage into first-class question block. + if inAskedLeak || strings.Contains(combinedText, "[Asked User:") { + inAskedLeak = true + askedLeakBuffer += combinedText + if strings.Contains(askedLeakBuffer, "]") { + if qtxt, ok := extractAskedUserQuestion(askedLeakBuffer); ok && qtxt != "" { + qPayload := map[string]interface{}{ + "question": qtxt, + "options": []string{"Answer in chat"}, + } + qJSON, _ := json.Marshal(qPayload) + combinedText = fmt.Sprintf("````question\n%s\n````\n", string(qJSON)) + } else { + combinedText = "" + } + askedLeakBuffer = "" + inAskedLeak = false + } else { + combinedText = "" + } + } + + displayChunk := combinedText + fullResponse += displayChunk + persistAssistant(timelineResponse + fullResponse) + + if !isToolChunk && len(toolCalls) == 0 { + lowerResp := strings.ToLower(fullResponse) + startIdx := strings.LastIndex(lowerResp, "") + endIdx := strings.LastIndex(lowerResp, "") + + inTag := false + if startIdx != -1 && (endIdx == -1 || endIdx < startIdx) { + inTag = true + } + + inPartial := false + if !inTag { + last10 := lowerResp + if len(last10) > 10 { + last10 = last10[len(last10)-10:] + } + for i := 1; i < len(""); i++ { + if strings.HasSuffix(last10, ""[:i]) { + inPartial = true + break + } + } + } + + if inTag || (endIdx != -1 && strings.HasSuffix(lowerResp, "")) { + // Completely drop the token chunk from the UI + } else if inPartial { + hiddenBuffer += displayChunk + } else { + // Fully render chunk + any accumulated false-positive buffer + contentToStream := hiddenBuffer + displayChunk + hiddenBuffer = "" + + chunk.Choices[0].Delta.Content = contentToStream + b, _ := json.Marshal(chunk) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + } + } + } + } + } + + if string(cleanLine) == "data: [DONE]" { + if hiddenBuffer != "" { + var finalChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + finalChunk.Choices = append(finalChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + finalChunk.Choices[0].Delta.Content = hiddenBuffer + b, _ := json.Marshal(finalChunk) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + } + w.Write([]byte("data: [DONE]\n\n")) + if ok { + flusher.Flush() + } + break + } + } + + resp.Body.Close() + + // If the AI invoked tools, we must NOT exit. We execute them, append to memory, and loop! + if len(toolCalls) > 0 { + + // 1. Append the AI's tool request to openRouterMsgs + assistantToolMsg := map[string]interface{}{ + "role": "assistant", + "content": fullResponse, // Can be null if it just straight up called a tool + "tool_calls": toolCalls, + } + for _, tc := range toolCalls { + tc["type"] = "function" // OpenRouter spec enforcement + } + openRouterMsgs = append(openRouterMsgs, assistantToolMsg) + + // 2. Execute Go Tools + for _, tc := range toolCalls { + id, _ := tc["id"].(string) + funcObj, _ := tc["function"].(map[string]interface{}) + name, _ := funcObj["name"].(string) + args, _ := funcObj["arguments"].(string) + + var parsedArgs map[string]interface{} + json.Unmarshal([]byte(args), &parsedArgs) + + // -- ASK USER QUESTION INTERCEPT -- + if name == "ask_user_question" { + var qChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + qChunk.Choices = append(qChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + + // Re-standardize for frontend + qPayload := map[string]interface{}{ + "question": parsedArgs["question"], + } + if opt, ok := parsedArgs["options"]; ok { + qPayload["options"] = opt + } else if ch, ok := parsedArgs["choices"]; ok { + qPayload["options"] = ch + } else { + qPayload["options"] = []string{"Yes", "No"} + } + qPayloadB, _ := json.Marshal(qPayload) + + qBlock := fmt.Sprintf("\n\n````question\n%s\n````\n", string(qPayloadB)) + qChunk.Choices[0].Delta.Content = qBlock + qB, _ := json.Marshal(qChunk) + w.Write([]byte("data: ")) + w.Write(qB) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + + // Persist question blocks and tool actions across reloads. + askActionJSON, _ := json.Marshal(map[string]string{"action": "Asked Question", "target": "user_input"}) + askActionBlock := fmt.Sprintf("````tool_action\n%s\n````\n", string(askActionJSON)) + fullResponse += askActionBlock + qBlock + + persistAssistant(timelineResponse + fullResponse) + resp.Body.Close() + return + } + + // -- TIMELINE: Stream a tool_action block BEFORE execution -- + type toolActionPayload struct { + Action string `json:"action"` + Target string `json:"target"` + } + tap := toolActionPayload{Action: name} + switch name { + case "execute_command": + tap.Action = "Ran Command" + if c, ok := parsedArgs["command"].(string); ok { + tap.Target = c + } + case "edit_file": + tap.Action = "Edited" + if p, ok := parsedArgs["path"].(string); ok { + tap.Target = p + } + case "write_file": + tap.Action = "Created" + if p, ok := parsedArgs["path"].(string); ok { + tap.Target = p + } + case "read_file": + tap.Action = "Read" + if p, ok := parsedArgs["path"].(string); ok { + tap.Target = p + } + case "list_files": + tap.Action = "Listed Files" + if p, ok := parsedArgs["path"].(string); ok { + tap.Target = p + } + case "grep_search": + tap.Action = "Searched Code" + if q, ok := parsedArgs["query"].(string); ok { + tap.Target = q + } + case "web_search": + tap.Action = "Searched Web" + if q, ok := parsedArgs["query"].(string); ok { + tap.Target = q + } + case "web_scrape": + tap.Action = "Scraped URL" + if u, ok := parsedArgs["url"].(string); ok { + tap.Target = u + } + case "undo_checkpoint": + tap.Action = "Reverted Checkpoint" + case "propose_commit": + tap.Action = "Proposed Commit" + if p, ok := parsedArgs["file_path"].(string); ok { + tap.Target = p + } + case "undo_change": + tap.Action = "Undid Change" + if p, ok := parsedArgs["point_id"].(string); ok { + tap.Target = p + } + case "get_context_tree": + tap.Action = "Mapped Context Tree" + case "get_file_skeleton": + tap.Action = "Generated File Skeleton" + if p, ok := parsedArgs["file_path"].(string); ok { + tap.Target = p + } + case "semantic_code_search": + tap.Action = "Semantic Search" + if q, ok := parsedArgs["query"].(string); ok { + tap.Target = q + } + case "semantic_identifier_search": + tap.Action = "Identifier Search" + if q, ok := parsedArgs["query"].(string); ok { + tap.Target = q + } + case "get_blast_radius": + tap.Action = "Computed Blast Radius" + if s, ok := parsedArgs["symbol_name"].(string); ok { + tap.Target = s + } + case "run_static_analysis": + tap.Action = "Ran Static Analysis" + case "semantic_navigate": + tap.Action = "Semantic Navigation" + case "get_feature_hub": + tap.Action = "Feature Hub Lookup" + } + tActionJSON, _ := json.Marshal(tap) + tActionBlock := fmt.Sprintf("````tool_action\n%s\n````\n", string(tActionJSON)) + var taChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + taChunk.Choices = append(taChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + taChunk.Choices[0].Delta.Content = tActionBlock + taB, _ := json.Marshal(taChunk) + w.Write([]byte("data: ")) + w.Write(taB) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + fullResponse += tActionBlock + timelineResponse += tActionBlock + persistAssistant(timelineResponse + fullResponse) + + // -- Checkpoint: create git snapshot before destructive actions -- + var hash string + if name == "execute_command" { + cmdName := "command" + if c, ok := parsedArgs["command"].(string); ok { + cmdName = c + } + hash = tools.CreateCheckpoint("Before running: " + cmdName) + } else if name == "write_file" || name == "edit_file" || name == "propose_commit" { + pathName := "file" + if p, ok := parsedArgs["path"].(string); ok { + pathName = p + } else if p, ok := parsedArgs["file_path"].(string); ok { + pathName = p + } + hash = tools.CreateCheckpoint("Before modifying: " + pathName) + } + + // --- EXECUTION MODE SAFETY GATE --- + execMode := strings.ToLower(executionMode) + if execMode == "" { + execMode = "default" + } + destructiveWrite := name == "write_file" || name == "edit_file" || name == "rename_file" || name == "propose_commit" || name == "undo_change" + destructiveExec := name == "execute_command" + if execMode == "plan" && (destructiveWrite || destructiveExec) { + openRouterMsgs = append(openRouterMsgs, map[string]interface{}{ + "role": "tool", "tool_call_id": id, "name": name, + "content": fmt.Sprintf("[PLAN MODE] Tool '%s' blocked. Read-only mode active.", name), + }) + continue + } + needsConfirm := (execMode == "default" && (destructiveWrite || destructiveExec)) || + (execMode == "auto_accept" && destructiveExec) + if needsConfirm { + confirmID := fmt.Sprintf("conf-%d-%s", time.Now().UnixNano(), name) + confirmPayload, _ := json.Marshal(map[string]interface{}{"id": confirmID, "tool": name, "args": parsedArgs}) + var cfChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + cfChunk.Choices = append(cfChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + cfChunk.Choices[0].Delta.Content = fmt.Sprintf("````confirm\n%s\n````\n", string(confirmPayload)) + cfB, _ := json.Marshal(cfChunk) + w.Write([]byte("data: ")) + w.Write(cfB) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + ch := RegisterConfirmation(confirmID) + if approved := <-ch; !approved { + openRouterMsgs = append(openRouterMsgs, map[string]interface{}{ + "role": "tool", "tool_call_id": id, "name": name, + "content": fmt.Sprintf("[Rejected] User did not approve '%s'.", name), + }) + continue + } + } + resultStr := tools.ExecuteTool(name, args, projectPath) + + // -- Post-execution visual feedback for terminal output and checkpoints -- + var visualMsg string + switch name { + case "execute_command": + cmdName := "command" + if c, ok := parsedArgs["command"].(string); ok { + cmdName = c + } + visualMsg = fmt.Sprintf("\n\n```terminal\n$ %s\n%s\n```\n", cmdName, resultStr) + case "undo_checkpoint": + visualMsg = "\n\n> ⏪ **Reverted sandbox to previous checkpoint.**\n" + } + + if hash != "" { + visualMsg = fmt.Sprintf("\n\n````checkpoint\n%s\n````\n", hash) + visualMsg + } + + if visualMsg != "" { + var streamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + streamChunk.Choices = append(streamChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + streamChunk.Choices[0].Delta.Content = visualMsg + b, _ := json.Marshal(streamChunk) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + fullResponse += visualMsg + timelineResponse += visualMsg + persistAssistant(timelineResponse + fullResponse) + } + + // 3. Append the physical tool output + toolResultMsg := map[string]interface{}{ + "role": "tool", + "tool_call_id": id, + "name": name, + "content": resultStr, + } + openRouterMsgs = append(openRouterMsgs, toolResultMsg) + + if chatReq.Mode == "coding" && debugSystemLogs { + sendSSEChunk(w, fmt.Sprintf("````system_log\n[TOOL RESULT: %s]\n%s\n````\n", name, resultStr)) + } + } + + // DO NOT BREAK. Let the `for` loop spin again and send the complete array back to OpenRouter! + continue + } + + // --- END OF TOOL LOOP --- + // If the AI has reached here, it didn't pick any more tools. + // BUT: if the fullResponse is empty or purely technical, the user sees nothing! + // We detect if there's no "human" text and force one final synthesis. + cleanText := uiBlockRe.ReplaceAllString(fullResponse, "") + cleanText = strings.TrimSpace(cleanText) + if cleanText == "" && attempt > 0 { + // Nudge the model to synthesize + openRouterMsgs = append(openRouterMsgs, map[string]interface{}{"role": "user", "content": "The tools have finished. Please provide a concise, final summary of what was accomplished for the user."}) + continue // One more spin! + } + + // --- END OF TOOL LOOP --- + + // If we reach here, the AI is done picking tools and has sent us a final text answer. + + // 5. Personality Engine Extraction + if fullResponse != "" { + re := regexp.MustCompile(`(?is)(.*?)`) + matches := re.FindAllStringSubmatch(fullResponse, -1) + for _, match := range matches { + if len(match) > 1 { + fact := strings.TrimSpace(match[1]) + if fact != "" { + db.DB.Exec("INSERT OR IGNORE INTO personality_facts (fact) VALUES (?)", fact) + log.Println("Personality Engine: Immortalized new fact:", fact) + } + } + } + } + + storedResponse := timelineResponse + fullResponse + persistAssistant(storedResponse) + return + } +} + +// Proxies raw OpenAI-format requests from external tools (VSCode, Mobile) out to the correct provider +func AiExternalChatHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + apiKey := ResolveOpenRouterKey(CurrentUserID(r)) + ollamaUrl := os.Getenv("OLLAMA_API_URL") + + if (apiKey == "" || apiKey == "your_openrouter_api_key_here") && ollamaUrl == "" { + http.Error(w, `{"error": "No AI Providers configured"}`, http.StatusServiceUnavailable) + return + } + + // Read verbatim incoming JSON payload + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error reading request body", http.StatusInternalServerError) + return + } + + var payloadMap map[string]interface{} + if err := json.Unmarshal(bodyBytes, &payloadMap); err != nil { + http.Error(w, "Invalid JSON payload", http.StatusBadRequest) + return + } + + model, _ := payloadMap["model"].(string) + + // Agent routing: check X-Agent-ID header, ?agent_id= param, or default setting + routingAgentID := r.Header.Get("X-Agent-ID") + if routingAgentID == "" { + routingAgentID = r.URL.Query().Get("agent_id") + } + if routingAgentID == "" { + routingAgentID = getSettingString("external_default_agent_id", "") + } + if routingAgentID != "" && agentOSService != nil { + if routingAgent, err := agentOSService.GetAgent(routingAgentID); err == nil { + if strings.TrimSpace(routingAgent.IdentityPrompt) != "" { + agentSysMsg := map[string]interface{}{"role": "system", "content": routingAgent.IdentityPrompt} + if messages, ok := payloadMap["messages"].([]interface{}); ok { + payloadMap["messages"] = append([]interface{}{agentSysMsg}, messages...) + } + } + // Use agent's model if set + if binding, profile, err2 := agentOSService.GetAgentBinding(routingAgent.ID); err2 == nil { + if profile.Model != "" && model == "" { + payloadMap["model"] = profile.Model + model = profile.Model + _ = binding + } + } + } + } + + // Inject RAG episodes for external tooling + if messages, ok := payloadMap["messages"].([]interface{}); ok && len(messages) > 0 { + lastMsg, _ := messages[len(messages)-1].(map[string]interface{}) + if contentStr, ok := lastMsg["content"].(string); ok { + episodes := SearchEpisodes(contentStr, "external_api") + if episodes != "" { + sysMsg := map[string]interface{}{"role": "system", "content": episodes} + payloadMap["messages"] = append([]interface{}{sysMsg}, messages...) + log.Println("Memory System: Injected chronological memory RAG block into EXTERNAL context stream.") + } + } + } + + // Inject Global Personality Prompt for external tooling + personaPrompt := ConstructPersonalityPrompt(CurrentUserID(r), "talking", "None", "Plan", "") + if personaPrompt != "" { + sysMsg := map[string]interface{}{"role": "system", "content": personaPrompt} + if messages, ok := payloadMap["messages"].([]interface{}); ok { + payloadMap["messages"] = append([]interface{}{sysMsg}, messages...) + } + } + + payloadMap["tools"] = tools.GetAvailableTools() + + var targetUrl string + var reqApiKey string + + if strings.HasPrefix(model, "local/") { + if ollamaUrl == "" { + http.Error(w, "OLLAMA_API_URL is not configured", http.StatusBadRequest) + return + } + // Rewrite model internally + payloadMap["model"] = strings.TrimPrefix(model, "local/") + targetUrl = ollamaUrl + "/v1/chat/completions" + reqApiKey = "Bearer local" + } else { + if model == "" { + settings := GetCurrentSettings(CurrentUserID(r)) + if settings.DefaultModel != "" { + payloadMap["model"] = settings.DefaultModel + } else { + payloadMap["model"] = "meta-llama/llama-3-8b-instruct:free" + } + } + targetUrl = "https://openrouter.ai/api/v1/chat/completions" + reqApiKey = "Bearer " + apiKey + } + + for attempt := 0; attempt < 5; attempt++ { + // Re-marshal to send upstream + upstreamPayload, _ := json.Marshal(payloadMap) + + req, _ := http.NewRequestWithContext(r.Context(), "POST", targetUrl, bytes.NewBuffer(upstreamPayload)) + req.Header.Set("Authorization", reqApiKey) + req.Header.Set("Content-Type", "application/json") + if !strings.HasPrefix(model, "local/") { + req.Header.Set("X-Title", "AgentHQ API Gateway") + } + + client := &http.Client{Timeout: 300 * time.Second} + resp, err := client.Do(req) + + if err != nil { + if attempt == 0 { + http.Error(w, "Upstream AI error", http.StatusBadGateway) + } + return + } + + if attempt == 0 { + // Only send headers on the very first loop before stream begins + w.WriteHeader(resp.StatusCode) + } + + if resp.StatusCode != 200 { + if attempt == 0 { + // We already wrote the header above, just pipe body + io.Copy(w, resp.Body) + } + resp.Body.Close() + return + } + + reader := bufio.NewReader(resp.Body) + var fullResponse string + var toolCalls []map[string]interface{} + var hiddenBuffer string + flusher, ok := w.(http.Flusher) + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + break + } + + cleanLine := bytes.TrimSpace(line) + isToolChunk := false + + if bytes.HasPrefix(cleanLine, []byte("data: ")) && string(cleanLine) != "data: [DONE]" { + data := bytes.TrimPrefix(cleanLine, []byte("data: ")) + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []map[string]interface{} `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` + } + if json.Unmarshal(data, &chunk) == nil && len(chunk.Choices) > 0 { + delta := chunk.Choices[0].Delta + if len(delta.ToolCalls) > 0 { + isToolChunk = true + for _, tc := range delta.ToolCalls { + idxFloat, ok := tc["index"].(float64) + if !ok { + continue + } + idx := int(idxFloat) + for len(toolCalls) <= idx { + toolCalls = append(toolCalls, make(map[string]interface{})) + } + if id, exists := tc["id"].(string); exists { + toolCalls[idx]["id"] = id + } + if function, exists := tc["function"].(map[string]interface{}); exists { + if _, fExists := toolCalls[idx]["function"]; !fExists { + toolCalls[idx]["function"] = make(map[string]interface{}) + } + funcPtr := toolCalls[idx]["function"].(map[string]interface{}) + + if n, nExists := function["name"].(string); nExists { + funcPtr["name"] = n + } + if argChunk, argExists := function["arguments"].(string); argExists { + if existingArgs, ex := funcPtr["arguments"].(string); ex { + funcPtr["arguments"] = existingArgs + argChunk + } else { + funcPtr["arguments"] = argChunk + } + } + } + } + } else if delta.Content != "" { + fullResponse += delta.Content + + if !isToolChunk && len(toolCalls) == 0 { + lowerResp := strings.ToLower(fullResponse) + startIdx := strings.LastIndex(lowerResp, "") + endIdx := strings.LastIndex(lowerResp, "") + + inTag := false + if startIdx != -1 && (endIdx == -1 || endIdx < startIdx) { + inTag = true + } + + inPartial := false + if !inTag { + last10 := lowerResp + if len(last10) > 10 { + last10 = last10[len(last10)-10:] + } + for i := 1; i < len(""); i++ { + if strings.HasSuffix(last10, ""[:i]) { + inPartial = true + break + } + } + } + + if inTag || (endIdx != -1 && strings.HasSuffix(lowerResp, "")) { + // Drop token + } else if inPartial { + hiddenBuffer += delta.Content + } else { + // Render + contentToStream := hiddenBuffer + delta.Content + hiddenBuffer = "" + + chunk.Choices[0].Delta.Content = contentToStream + b, _ := json.Marshal(chunk) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + } + } + } + } + } + + if string(cleanLine) == "data: [DONE]" { + if hiddenBuffer != "" { + var finalChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + finalChunk.Choices = append(finalChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + finalChunk.Choices[0].Delta.Content = hiddenBuffer + b, _ := json.Marshal(finalChunk) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + } + break + } + } + + resp.Body.Close() + + if len(toolCalls) > 0 { + assistantToolMsg := map[string]interface{}{ + "role": "assistant", + "content": fullResponse, + "tool_calls": toolCalls, + } + for _, tc := range toolCalls { + tc["type"] = "function" + } + + if messagesArray, ok := payloadMap["messages"].([]interface{}); ok { + messagesArray = append(messagesArray, assistantToolMsg) + + for _, tc := range toolCalls { + id, _ := tc["id"].(string) + funcObj, _ := tc["function"].(map[string]interface{}) + name, _ := funcObj["name"].(string) + args, _ := funcObj["arguments"].(string) + + var parsedArgs map[string]interface{} + json.Unmarshal([]byte(args), &parsedArgs) + + var hash string + if name == "execute_command" { + cmdName := "command" + if c, ok := parsedArgs["command"].(string); ok { + cmdName = c + } + hash = tools.CreateCheckpoint("Before running: " + cmdName) + } else if name == "write_file" || name == "edit_file" || name == "propose_commit" { + pathName := "file" + if p, ok := parsedArgs["path"].(string); ok { + pathName = p + } else if p, ok := parsedArgs["file_path"].(string); ok { + pathName = p + } + hash = tools.CreateCheckpoint("Before modifying: " + pathName) + } + + resultStr := tools.ExecuteTool(name, args, "") // External API doesn't have project root yet + + var visualMsg string + + switch name { + case "execute_command": + cmdName := "command" + if c, ok := parsedArgs["command"].(string); ok { + cmdName = c + } + visualMsg = fmt.Sprintf("\n\n> 🛠️ **Ran Command:** `%s`\n```terminal\n%s\n```\n", cmdName, resultStr) + case "undo_checkpoint": + visualMsg = "\n\n> ⏪ **Reverted sandbox to previous checkpoint.**\n" + case "edit_file": + pathName := "file" + if p, ok := parsedArgs["path"].(string); ok { + pathName = p + } + visualMsg = fmt.Sprintf("\n\n> 📝 **Edited `%s`**\n", pathName) + case "write_file": + pathName := "file" + if p, ok := parsedArgs["path"].(string); ok { + pathName = p + } + visualMsg = fmt.Sprintf("\n\n> 📝 **Created `%s`**\n", pathName) + case "propose_commit": + pathName := "file" + if p, ok := parsedArgs["file_path"].(string); ok { + pathName = p + } + visualMsg = fmt.Sprintf("\n\n> 📝 **Proposed Commit `%s`**\n", pathName) + case "undo_change": + pointID := "restore point" + if p, ok := parsedArgs["point_id"].(string); ok { + pointID = p + } + visualMsg = fmt.Sprintf("\n\n> ⏪ **Restored `%s`**\n", pointID) + case "read_file": + pathName := "file" + if p, ok := parsedArgs["path"].(string); ok { + pathName = p + } + visualMsg = fmt.Sprintf("\n\n> 📖 **Read `%s`**\n", pathName) + case "list_files": + visualMsg = "\n\n> 📂 **Listed Files**\n" + case "grep_search": + visualMsg = "\n\n> 🔍 **Searched Files**\n" + case "web_scrape": + visualMsg = "\n\n> 🌐 **Scraped Webpage**\n" + } + + if visualMsg != "" && hash != "" { + visualMsg = fmt.Sprintf("\n\n````checkpoint\n%s\n````\n", hash) + visualMsg + } + + if visualMsg != "" { + var streamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + streamChunk.Choices = append(streamChunk.Choices, struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + }{}) + streamChunk.Choices[0].Delta.Content = visualMsg + b, _ := json.Marshal(streamChunk) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + fullResponse += visualMsg + } + + toolResultMsg := map[string]interface{}{ + "role": "tool", + "tool_call_id": id, + "name": name, + "content": resultStr, + } + messagesArray = append(messagesArray, toolResultMsg) + } + payloadMap["messages"] = messagesArray + } + + continue + } + + // 5. Personality Engine Extraction against proxied stream + if fullResponse != "" { + re := regexp.MustCompile(`(?is)(.*?)`) + matches := re.FindAllStringSubmatch(fullResponse, -1) + for _, match := range matches { + if len(match) > 1 { + fact := strings.TrimSpace(match[1]) + if fact != "" { + db.DB.Exec("INSERT OR IGNORE INTO personality_facts (fact) VALUES (?)", fact) + log.Println("Personality Engine: Immortalized EXTERNAL proxied fact:", fact) + } + } + } + } + + w.Write([]byte("data: [DONE]\n\n")) + if ok { + flusher.Flush() + } + } +} diff --git a/dash/backend/handlers/auth.go b/dash/backend/handlers/auth.go new file mode 100644 index 0000000..0d64de8 --- /dev/null +++ b/dash/backend/handlers/auth.go @@ -0,0 +1,86 @@ +package handlers + +import ( + "context" + "crypto/subtle" + "net/http" + "os" + "strings" + + "github.com/danilrybalkin/apollo-dash/db" +) + +type contextKey string + +const currentUserIDKey contextKey = "agenthq_user_id" + +func CurrentUserID(r *http.Request) string { + if v, ok := r.Context().Value(currentUserIDKey).(string); ok { + return v + } + return "" +} + +func BasicAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Public endpoints — no auth check + if strings.HasPrefix(r.URL.Path, "/api/auth/") { + next.ServeHTTP(w, r) + return + } + // Vulta webhook — authenticated by HMAC signature in handler itself + if r.URL.Path == "/api/billing/webhook" { + next.ServeHTTP(w, r) + return + } + + // Let the React app and static assets load. Protected pages still require a + // user token client-side, and all API/WebSocket routes remain guarded here. + if !strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/ws/") { + next.ServeHTTP(w, r) + return + } + + // User session token check + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + // Try user token first + user, err := db.GetUserByToken(token) + if err == nil && user != nil { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), currentUserIDKey, user.ID))) + return + } + // Then try admin password + envPass := os.Getenv("DASHBOARD_PASSWORD") + if envPass != "" && subtle.ConstantTimeCompare([]byte(token), []byte(envPass)) == 1 { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), currentUserIDKey, "admin"))) + return + } + } + + // Basic Auth fallback + envPass := os.Getenv("DASHBOARD_PASSWORD") + if envPass == "" { + next.ServeHTTP(w, r) + return + } + user, pass, ok := r.BasicAuth() + if ok && subtle.ConstantTimeCompare([]byte(pass), []byte(envPass)) == 1 && user == "admin" { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), currentUserIDKey, "admin"))) + return + } + + w.Header().Set("WWW-Authenticate", `Basic realm="AgentHQ"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} diff --git a/dash/backend/handlers/billing.go b/dash/backend/handlers/billing.go new file mode 100644 index 0000000..709b093 --- /dev/null +++ b/dash/backend/handlers/billing.go @@ -0,0 +1,195 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/danilrybalkin/apollo-dash/db" + "github.com/google/uuid" +) + +const vultaAPIBase = "https://vulta.one/api" + +// ── Vulta API client ────────────────────────────────────────────────────────── + +func vultaAPIKey() string { return os.Getenv("VULTA_API_KEY") } +func vultaWebhookSecret() string { return os.Getenv("VULTA_WEBHOOK_SECRET") } + +func vultaPost(ctx context.Context, path string, body interface{}) (map[string]interface{}, int, error) { + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, vultaAPIBase+path, bytes.NewReader(b)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+vultaAPIKey()) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + return result, resp.StatusCode, nil +} + +// ── POST /api/billing/checkout ──────────────────────────────────────────────── + +func BillingCheckoutHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := extractBearerToken(r) + if token == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + user, err := db.GetUserByToken(token) + if err != nil || user == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + + if vultaAPIKey() == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{"error": "billing not configured"}) + return + } + + // Use checkout sessions API with use_all_payout_destinations — no dest ID needed + payload := map[string]interface{}{ + "amount_fiat": "39.00", + "fiat_currency": "USD", + "external_reference_id": "agenthq_user_" + user.ID, + "use_all_payout_destinations": true, + } + + result, status, err := vultaPost(r.Context(), "/checkout/sessions", payload) + if err != nil || status >= 400 { + errMsg := "failed to create checkout session" + if e, ok := result["error"].(string); ok { + errMsg = e + } + log.Printf("Billing: Vulta error for user %s: status=%d err=%v result=%v", user.ID, status, err, result) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]string{"error": errMsg}) + return + } + + // Checkout session returns { "id": "...", "checkout_url": "..." } + sessionID, _ := result["id"].(string) + checkoutURL, _ := result["checkout_url"].(string) + + if sessionID == "" || checkoutURL == "" { + log.Printf("Billing: unexpected Vulta response: %v", result) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid response from payment provider"}) + return + } + + // Store billing session so webhook can map back to user + _ = db.CreateBillingSession(uuid.New().String(), user.ID, sessionID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"checkout_url": checkoutURL}) +} + +// ── POST /api/billing/webhook ───────────────────────────────────────────────── + +type vultaWebhookPayload struct { + EventType string `json:"event_type"` + PaymentRequestID string `json:"payment_request_id"` + ExternalRefID string `json:"external_reference_id"` + AmountFiat string `json:"amount_fiat"` + FiatCurrency string `json:"fiat_currency"` + Status string `json:"status"` + MerchantID string `json:"merchant_id"` +} + +func BillingWebhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + rawBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + + // Verify HMAC-SHA256 signature if webhook secret is configured + secret := vultaWebhookSecret() + if secret != "" { + sig := r.Header.Get("X-Vulta-Signature") + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(rawBody) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(sig), []byte(expected)) { + log.Printf("Billing: invalid webhook signature") + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + } + + var payload vultaWebhookPayload + if err := json.Unmarshal(rawBody, &payload); err != nil { + http.Error(w, "bad payload", http.StatusBadRequest) + return + } + + log.Printf("Billing: webhook event_type=%s status=%s payment_request=%s ref=%s", + payload.EventType, payload.Status, payload.PaymentRequestID, payload.ExternalRefID) + + // Only act on confirmed payments (event_type from webhook, status from payload) + isConfirmed := payload.EventType == "payment.confirmed" || payload.Status == "payment.confirmed" || payload.Status == "CONFIRMED" + if !isConfirmed { + w.WriteHeader(http.StatusOK) + return + } + + // Look up user by payment request ID + userID, err := db.GetBillingSessionByPaymentRequestID(payload.PaymentRequestID) + if err != nil { + log.Printf("Billing: no billing session for payment_request %s: %v", payload.PaymentRequestID, err) + w.WriteHeader(http.StatusOK) // ack anyway to prevent retries + return + } + + // Activate pro plan + if err := db.ActivateProPlan(userID); err != nil { + log.Printf("Billing: failed to activate pro for user %s: %v", userID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + _ = db.ConfirmBillingSession(payload.PaymentRequestID) + + // Send confirmation email + user, err := db.GetUserByID(userID) + if err == nil && user != nil { + SendPaymentConfirmedEmail(context.Background(), user.Email, user.Name) + } + + log.Printf("Billing: activated pro plan for user %s", userID) + w.WriteHeader(http.StatusOK) +} diff --git a/dash/backend/handlers/confirm.go b/dash/backend/handlers/confirm.go new file mode 100644 index 0000000..bbf6bab --- /dev/null +++ b/dash/backend/handlers/confirm.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "sync" + "time" +) + +// ─── Pending Confirmation Registry ────────────────────────────────────────── +// Maps confirmation ID → response channel. The tool loop blocks on the channel. +var ( + pendingConfirms = map[string]chan bool{} + pendingConfirmsMu sync.Mutex +) + +// RegisterConfirmation creates a channel for the given ID. The tool loop reads it. +func RegisterConfirmation(id string) chan bool { + ch := make(chan bool, 1) + pendingConfirmsMu.Lock() + pendingConfirms[id] = ch + pendingConfirmsMu.Unlock() + // Auto-reject after 5 minutes + go func() { + time.Sleep(5 * time.Minute) + pendingConfirmsMu.Lock() + if ch, ok := pendingConfirms[id]; ok { + select { + case ch <- false: // auto-reject + default: + } + delete(pendingConfirms, id) + } + pendingConfirmsMu.Unlock() + }() + return ch +} + +// ConfirmHandler: POST /api/confirm?id=xxx&action=approve|reject +func ConfirmHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + id := r.URL.Query().Get("id") + action := r.URL.Query().Get("action") + if id == "" || action == "" { + http.Error(w, "id and action required", http.StatusBadRequest) + return + } + + pendingConfirmsMu.Lock() + ch, ok := pendingConfirms[id] + if ok { + delete(pendingConfirms, id) + } + pendingConfirmsMu.Unlock() + + if !ok { + http.Error(w, "confirmation not found or expired", http.StatusNotFound) + return + } + + approved := action == "approve" + ch <- approved + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"approved": approved}) +} diff --git a/dash/backend/handlers/email.go b/dash/backend/handlers/email.go new file mode 100644 index 0000000..0ad76e1 --- /dev/null +++ b/dash/backend/handlers/email.go @@ -0,0 +1,225 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/danilrybalkin/apollo-dash/db" +) + +// ── Resend client ───────────────────────────────────────────────────────────── + +type resendClient struct { + apiKey string + from string + client *http.Client +} + +var emailer *resendClient + +func InitEmailer() { + key := os.Getenv("RESEND_API_KEY") + if key == "" { + log.Println("Email: RESEND_API_KEY not set — emails disabled") + return + } + emailer = &resendClient{ + apiKey: key, + from: "AgentHQ ", + client: &http.Client{Timeout: 10 * time.Second}, + } + log.Println("Email: Resend client ready") +} + +func sendEmail(ctx context.Context, to, subject, htmlBody string) { + if emailer == nil { + return + } + go func() { + payload := map[string]interface{}{ + "from": emailer.from, + "to": []string{to}, + "subject": subject, + "html": htmlBody, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body)) + if err != nil { + log.Printf("Email: build request: %v", err) + return + } + req.Header.Set("Authorization", "Bearer "+emailer.apiKey) + req.Header.Set("Content-Type", "application/json") + resp, err := emailer.client.Do(req) + if err != nil { + log.Printf("Email: send to %s: %v", to, err) + return + } + resp.Body.Close() + if resp.StatusCode >= 400 { + log.Printf("Email: Resend returned %d for %s", resp.StatusCode, to) + } + }() +} + +// ── Templates ───────────────────────────────────────────────────────────────── + +func appURL() string { + if u := os.Getenv("APP_URL"); u != "" { + return u + } + return "https://agenthq.ai" +} + +func wrapper(content string) string { + return fmt.Sprintf(` + + +
+
+ AgentHQ +
+ %s +

+ AgentHQ · Questions? support@agenthq.ai +

+
+ +`, content) +} + +func SendWelcomeEmail(ctx context.Context, to, name string) { + first := name + if first == "" { + first = "there" + } + body := wrapper(fmt.Sprintf(` +

Welcome to AgentHQ

+

Hi %s,

+

+ Your account is ready. You have a 3-day free trial — no credit card required. + A demo company with an AI agent is already waiting for you inside. +

+ Open dashboard → +

+ Your trial gives you full access to all features. Add your OpenRouter API key in + Account → API Keys to enable AI tasks. +

`, first, appURL())) + sendEmail(ctx, to, "Welcome to AgentHQ — your trial has started", body) +} + +func SendTrialExpiryWarningEmail(ctx context.Context, to, name string, hoursLeft int) { + first := name + if first == "" { + first = "there" + } + timeStr := "tomorrow" + if hoursLeft <= 6 { + timeStr = "in a few hours" + } + body := wrapper(fmt.Sprintf(` +

Your trial ends %s

+

Hi %s,

+

+ Your AgentHQ free trial expires %s. Upgrade to keep your agents running — all your data, companies, and memory stay intact. +

+ Upgrade now → +

+ After your trial ends, your account and all data are preserved. Agents will pause until you upgrade. +

`, timeStr, first, timeStr, appURL())) + sendEmail(ctx, to, "Your AgentHQ trial expires "+timeStr, body) +} + +func SendPaymentConfirmedEmail(ctx context.Context, to, name string) { + first := name + if first == "" { + first = "there" + } + body := wrapper(fmt.Sprintf(` +

You're now on Pro

+

Hi %s,

+

+ Payment confirmed. Your AgentHQ account is now on the Pro plan for the next 30 days. + All agents are active and running. +

+ Go to dashboard → +

+ To renew, visit Account → Subscription before your plan expires. Questions? Reply to this email. +

`, first, appURL())) + sendEmail(ctx, to, "Payment confirmed — AgentHQ Pro is active", body) +} + +func SendRenewalReminderEmail(ctx context.Context, to, name string, daysLeft int) { + first := name + if first == "" { + first = "there" + } + body := wrapper(fmt.Sprintf(` +

Your Pro plan renews in %d days

+

Hi %s,

+

+ Your AgentHQ Pro subscription expires in %d days. Renew now to keep your agents running without interruption. +

+ Renew subscription →`, daysLeft, first, daysLeft, appURL())) + sendEmail(ctx, to, fmt.Sprintf("AgentHQ Pro renews in %d days", daysLeft), body) +} + +// ── Trial expiry background job ─────────────────────────────────────────────── + +func StartEmailNotifier(ctx context.Context) { + go func() { + // Run once at startup after a brief delay, then every 12 hours + time.Sleep(30 * time.Second) + runNotifierCycle(ctx) + ticker := time.NewTicker(12 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runNotifierCycle(ctx) + } + } + }() +} + +func runNotifierCycle(ctx context.Context) { + if emailer == nil { + return + } + notifyTrialExpiries(ctx) + notifyRenewalReminders(ctx) +} + +func notifyTrialExpiries(ctx context.Context) { + users, err := db.UsersNeedingTrialWarning() + if err != nil { + log.Printf("Notifier: trial warning query: %v", err) + return + } + for _, u := range users { + SendTrialExpiryWarningEmail(ctx, u.Email, u.Name, 24) + db.MarkTrialWarningSent(u.ID) + log.Printf("Notifier: sent trial warning to %s", u.Email) + } +} + +func notifyRenewalReminders(ctx context.Context) { + users, err := db.UsersNeedingRenewalWarning() + if err != nil { + log.Printf("Notifier: renewal reminder query: %v", err) + return + } + for _, u := range users { + SendRenewalReminderEmail(ctx, u.Email, u.Name, 3) + db.MarkRenewalWarningSent(u.ID) + log.Printf("Notifier: sent renewal reminder to %s", u.Email) + } +} diff --git a/dash/backend/handlers/mcp.go b/dash/backend/handlers/mcp.go new file mode 100644 index 0000000..ad87b5b --- /dev/null +++ b/dash/backend/handlers/mcp.go @@ -0,0 +1,15 @@ +package handlers + +import ( + "net/http" + + "github.com/danilrybalkin/apollo-dash/tools" +) + +func MCPHandler(w http.ResponseWriter, r *http.Request) { + tools.MCPHandler(w, r) +} + +func InitMCPServers() { + tools.InitMCPServers() +} diff --git a/dash/backend/handlers/memory.go b/dash/backend/handlers/memory.go new file mode 100644 index 0000000..d8a9902 --- /dev/null +++ b/dash/backend/handlers/memory.go @@ -0,0 +1,305 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "math" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/danilrybalkin/apollo-dash/db" +) + +type MemoryNode struct { + ID int + SessionID string + Content string + Vector []float32 +} + +var ( + MemoryBank []MemoryNode + memoryLock sync.RWMutex +) + +func buildEmbeddingRequest(baseURL, model, text string) (*http.Request, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + isOpenAICompatible := strings.Contains(baseURL, "/v1") || strings.Contains(baseURL, "openrouter.ai") + endpoint := baseURL + "/api/embeddings" + payload := map[string]interface{}{ + "model": model, + "prompt": text, + } + if isOpenAICompatible { + endpoint = baseURL + "/embeddings" + payload = map[string]interface{}{ + "model": model, + "input": text, + } + } + + payloadBytes, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if isOpenAICompatible { + apiKey := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) + if apiKey == "" || apiKey == "your_openrouter_api_key_here" { + apiKey = strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + } + return req, nil +} + +func decodeEmbeddingResponse(resp *http.Response) ([]float32, error) { + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("embedding endpoint status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var ollamaResult struct { + Embedding []float32 `json:"embedding"` + } + if err := json.Unmarshal(body, &ollamaResult); err == nil && len(ollamaResult.Embedding) > 0 { + return ollamaResult.Embedding, nil + } + + var openAIResult struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + } + if err := json.Unmarshal(body, &openAIResult); err != nil { + return nil, err + } + if len(openAIResult.Data) == 0 || len(openAIResult.Data[0].Embedding) == 0 { + return nil, fmt.Errorf("empty embedding vector") + } + return openAIResult.Data[0].Embedding, nil +} + +// Generates an embedding array for a given text using Ollama or OpenAI-compatible APIs. +func GenerateEmbedding(text string) ([]float32, error) { + settings := GetCurrentSettings("") + + req, err := buildEmbeddingRequest(settings.EmbeddingApiUrl, settings.EmbeddingModel, text) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return decodeEmbeddingResponse(resp) +} + +// Bootstraps the in-memory cache from the SQLite database +func LoadMemoryBank() { + memoryLock.Lock() + defer memoryLock.Unlock() + + MemoryBank = []MemoryNode{} + + rows, err := db.DB.Query("SELECT id, session_id, content, vector_embedding FROM chat_messages WHERE vector_embedding != '' AND vector_embedding IS NOT NULL") + if err != nil { + log.Println("Memory System: Failed to load embeddings from DB:", err) + return + } + defer rows.Close() + + count := 0 + for rows.Next() { + var node MemoryNode + var vectorStr string + + if err := rows.Scan(&node.ID, &node.SessionID, &node.Content, &vectorStr); err != nil { + continue + } + + if err := json.Unmarshal([]byte(vectorStr), &node.Vector); err == nil { + MemoryBank = append(MemoryBank, node) + count++ + } + } + + log.Printf("Memory System: Loaded %d vectorized nodes into RAM\n", count) +} + +// Background thread that hunts for unembedded messages and calculates their RAG matrices +func StartMemoryIndexer() { + for { + time.Sleep(3 * time.Minute) + + settings := GetCurrentSettings("") + if settings.EmbeddingApiUrl == "" || settings.EmbeddingModel == "" { + continue + } + + rows, err := db.DB.Query("SELECT id, session_id, content FROM chat_messages WHERE vector_embedding = '' OR vector_embedding IS NULL LIMIT 10") + if err != nil { + continue + } + + var pending []MemoryNode + for rows.Next() { + var node MemoryNode + if err := rows.Scan(&node.ID, &node.SessionID, &node.Content); err == nil { + pending = append(pending, node) + } + } + rows.Close() + + for _, node := range pending { + plainText := node.Content + if len(plainText) > 0 && plainText[0] == '[' { + var contentArr []map[string]interface{} + if err := json.Unmarshal([]byte(plainText), &contentArr); err == nil { + for _, item := range contentArr { + if t, ok := item["type"].(string); ok && t == "text" { + if textStr, ok := item["text"].(string); ok { + plainText = textStr + break + } + } + } + } + } + + // Don't embed completely empty strings + if len(plainText) < 2 { + db.DB.Exec("UPDATE chat_messages SET vector_embedding = '[]' WHERE id = ?", node.ID) + continue + } + + vector, err := GenerateEmbedding(plainText) + if err != nil || len(vector) == 0 { + log.Println("Memory System Indexer: Failed to embed message", node.ID, err) + continue + } + + vecBytes, _ := json.Marshal(vector) + + _, err = db.DB.Exec("UPDATE chat_messages SET vector_embedding = ? WHERE id = ?", string(vecBytes), node.ID) + if err == nil { + node.Vector = vector + memoryLock.Lock() + MemoryBank = append(MemoryBank, node) + memoryLock.Unlock() + log.Println("Memory System Indexer: Successfully mapped conversation block", node.ID) + } + } + } +} +func cosineSimilarity(a, b []float32) float32 { + if len(a) != len(b) { + return 0.0 + } + var dotProduct, normA, normB float32 + for i := range a { + dotProduct += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + if normA == 0 || normB == 0 { + return 0.0 + } + return dotProduct / float32(math.Sqrt(float64(normA))*math.Sqrt(float64(normB))) +} + +type MemoryHit struct { + Node MemoryNode + Score float32 +} + +// Builds the background RAG injection string dynamically by sweeping RAM matrices +func SearchEpisodes(query string, excludeSession string) string { + vector, err := GenerateEmbedding(query) + if err != nil || len(vector) == 0 { + return "" + } + + memoryLock.RLock() + var hits []MemoryHit + for _, node := range MemoryBank { + if node.SessionID == excludeSession { + continue + } + score := cosineSimilarity(vector, node.Vector) + // 0.5 is a safe semantic threshold for normalized nomic vectors + if score > 0.5 { + hits = append(hits, MemoryHit{Node: node, Score: score}) + } + } + memoryLock.RUnlock() + + // Sort hits descending by best mathematical match + sort.Slice(hits, func(i, j int) bool { + return hits[i].Score > hits[j].Score + }) + + // Take Top 3 hits + limit := 3 + if len(hits) < limit { + limit = len(hits) + } + topHits := hits[:limit] + + if len(topHits) == 0 { + return "" + } + + var builder strings.Builder + builder.WriteString("\n") + builder.WriteString("Here are distinct chronological memories from past sessions that have strong semantic similarity to the recent user prompt context. Factor them in when providing your answer:\n\n") + + for i, hit := range topHits { + builder.WriteString(fmt.Sprintf("-- Recalled Episode %d (Similarity Score: %.2f) --\n", i+1, hit.Score)) + + // Grab robust timeline: (-2 to +2 messages) around the hit inside this session linearly + rows, err := db.DB.Query("SELECT role, content FROM chat_messages WHERE session_id = ? AND id >= ? AND id <= ? ORDER BY id ASC", hit.Node.SessionID, hit.Node.ID-2, hit.Node.ID+2) + if err == nil { + for rows.Next() { + var role, content string + rows.Scan(&role, &content) + + // Extract raw textual payload safely + plainText := content + if len(plainText) > 0 && plainText[0] == '[' { + var contentArr []map[string]interface{} + if err := json.Unmarshal([]byte(plainText), &contentArr); err == nil { + for _, item := range contentArr { + if t, ok := item["type"].(string); ok && t == "text" { + if textStr, ok := item["text"].(string); ok { + plainText = textStr + break + } + } + } + } + } + + builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", strings.ToUpper(role), plainText)) + } + rows.Close() + } + } + + builder.WriteString("\n") + return builder.String() +} diff --git a/dash/backend/handlers/ping.go b/dash/backend/handlers/ping.go new file mode 100644 index 0000000..ff06e15 --- /dev/null +++ b/dash/backend/handlers/ping.go @@ -0,0 +1,142 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "sync" + "time" + + "github.com/danilrybalkin/apollo-dash/db" + "github.com/go-ping/ping" +) + +type VpsHost struct { + ID int `json:"id"` + Name string `json:"name"` + IP string `json:"ip"` +} + +type PingResult struct { + Host string `json:"host"` + Name string `json:"name"` + Online bool `json:"online"` + Latency int64 `json:"latencyMs"` +} + +func getHosts() ([]VpsHost, error) { + rows, err := db.DB.Query("SELECT id, name, ip FROM vps_hosts") + if err != nil { + return nil, err + } + defer rows.Close() + + var hosts []VpsHost + for rows.Next() { + var h VpsHost + if err := rows.Scan(&h.ID, &h.Name, &h.IP); err != nil { + return nil, err + } + hosts = append(hosts, h) + } + return hosts, nil +} + +func PingHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + hosts, err := getHosts() + if err != nil { + http.Error(w, "Failed to load hosts", http.StatusInternalServerError) + return + } + + if len(hosts) == 0 { + json.NewEncoder(w).Encode([]PingResult{}) + return + } + + results := make([]PingResult, len(hosts)) + var wg sync.WaitGroup + + for i, host := range hosts { + wg.Add(1) + go func(index int, h VpsHost) { + defer wg.Done() + pinger, err := ping.NewPinger(h.IP) + if err != nil { + results[index] = PingResult{Host: h.IP, Name: h.Name, Online: false} + return + } + pinger.Count = 1 + pinger.Timeout = 2 * time.Second + pinger.SetPrivileged(true) + + err = pinger.Run() + if err != nil || pinger.Statistics().PacketsRecv == 0 { + results[index] = PingResult{Host: h.IP, Name: h.Name, Online: false} + } else { + results[index] = PingResult{ + Host: h.IP, + Name: h.Name, + Online: true, + Latency: pinger.Statistics().AvgRtt.Milliseconds(), + } + } + }(i, host) + } + + wg.Wait() + json.NewEncoder(w).Encode(results) +} + +func VpsManagerHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + var newHost VpsHost + if err := json.Unmarshal(body, &newHost); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if newHost.IP == "" || newHost.Name == "" { + http.Error(w, "Name and IP are required", http.StatusBadRequest) + return + } + + _, err = db.DB.Exec("INSERT INTO vps_hosts (name, ip) VALUES (?, ?)", newHost.Name, newHost.IP) + if err != nil { + http.Error(w, "Failed to save host (maybe IP already exists?)", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + return + } + + if r.Method == http.MethodDelete { + ip := r.URL.Query().Get("ip") + if ip == "" { + http.Error(w, "IP query parameter required", http.StatusBadRequest) + return + } + + _, err := db.DB.Exec("DELETE FROM vps_hosts WHERE ip = ?", ip) + if err != nil { + http.Error(w, "Internal error deleting host", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} diff --git a/dash/backend/handlers/seed.go b/dash/backend/handlers/seed.go new file mode 100644 index 0000000..f4bdfd2 --- /dev/null +++ b/dash/backend/handlers/seed.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "log" + + "github.com/danilrybalkin/apollo-dash/db" + "github.com/google/uuid" +) + +// SeedDefaultModelProfiles creates the two starter model profiles if none exist yet. +// Called once at startup. Models can be changed later in Settings. +func SeedDefaultModelProfiles() { + var count int + if err := db.DB.QueryRow(`SELECT COUNT(*) FROM agent_model_profiles`).Scan(&count); err != nil { + return + } + if count > 0 { + return + } + + profiles := []struct { + label, provider, model string + }{ + // Ultra-cheap trial model (free tier on OpenRouter) + {"Trial Model (Free)", "openrouter", "meta-llama/llama-3.2-1b-instruct:free"}, + // Capable low-cost model for paying users + {"Standard Model", "openrouter", "openai/gpt-4o-mini"}, + } + + for _, p := range profiles { + id := uuid.New().String() + _, err := db.DB.Exec( + `INSERT INTO agent_model_profiles (id, provider, model, settings_json, fallback_chain_json) + VALUES (?, ?, ?, '{}', '[]')`, + id, p.provider, p.model, + ) + if err != nil { + log.Printf("Seed: failed to create model profile %q: %v", p.label, err) + } + } + log.Println("Seed: created 2 default model profiles") +} + +// SeedDemoCompany creates a sample company, department, and agent for a new user +// so they understand the org hierarchy immediately on first login. +func SeedDemoCompany(userID string) { + var count int + if err := db.DB.QueryRow(`SELECT COUNT(*) FROM companies WHERE owner_user_id = ?`, userID).Scan(&count); err != nil { + return + } + if count > 0 { + return // user already has a company + } + + companyID := uuid.New().String() + deptID := uuid.New().String() + agentID := uuid.New().String() + + _, err := db.DB.Exec( + `INSERT INTO companies (id, owner_user_id, name, slug, description, status) + VALUES (?, ?, 'My First Company', ?, 'Your first AI company. Edit or replace this example.', 'active')`, + companyID, userID, "company-"+companyID[:8], + ) + if err != nil { + log.Printf("Seed: failed to create demo company: %v", err) + return + } + + _, err = db.DB.Exec( + `INSERT INTO departments (id, company_id, name, type, description, status) + VALUES (?, ?, 'Product', 'general', 'Product and engineering work', 'active')`, + deptID, companyID, + ) + if err != nil { + log.Printf("Seed: failed to create demo department: %v", err) + return + } + + identityPrompt := `You are Alex, an AI product worker at My First Company. +You help with product research, writing specifications, drafting content, and planning tasks. +Be concise, practical, and always ask for clarification when a task is ambiguous. +You can be given tasks via the chat interface or scheduled to run automatically.` + + _, err = db.DB.Exec( + `INSERT INTO agents (id, company_id, department_id, name, role_type, identity_prompt, status, is_active) + VALUES (?, ?, ?, 'Alex', 'worker', ?, 'idle', 1)`, + agentID, companyID, deptID, identityPrompt, + ) + if err != nil { + log.Printf("Seed: failed to create demo agent: %v", err) + return + } + + log.Printf("Seed: created demo company/dept/agent for user %s", userID) +} diff --git a/dash/backend/handlers/settings.go b/dash/backend/handlers/settings.go new file mode 100644 index 0000000..2433dfa --- /dev/null +++ b/dash/backend/handlers/settings.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/danilrybalkin/apollo-dash/db" +) + +// ProjectConfig represents a managed project workspace +type ProjectConfig struct { + Name string `json:"name"` + Path string `json:"path"` + DeployCommand string `json:"deploy_command"` +} + +type SettingsPayload struct { + BearerToken string `json:"bearer_token"` + FeaturedModels []string `json:"featured_models"` + DefaultModel string `json:"default_model"` + EmbeddingApiUrl string `json:"embedding_api_url"` + EmbeddingModel string `json:"embedding_model"` + SystemPrompt string `json:"-"` + ManagedProjects []ProjectConfig `json:"-"` + AutoCompactTokens int `json:"auto_compact_tokens"` + AgentOSEnabled bool `json:"agentos_enabled"` + AgentOSPolicyEnforcement string `json:"agentos_policy_enforcement"` + AgentOSKillSwitch bool `json:"agentos_kill_switch"` +} + +// ── Global settings helpers ─────────────────────────────────────────────────── + +func getSettingString(key string, fallback string) string { + var val string + err := db.DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&val) + if err == sql.ErrNoRows { + return fallback + } + return val +} + +func setSettingString(key string, value string) { + db.DB.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, value) +} + +func getSettingInt(key string, fallback int) int { + strVal := getSettingString(key, "") + if strVal == "" { + return fallback + } + var intVal int + _, err := fmt.Sscanf(strVal, "%d", &intVal) + if err != nil { + return fallback + } + return intVal +} + +func setSettingInt(key string, value int) { + setSettingString(key, fmt.Sprintf("%d", value)) +} + +func getSettingBool(key string, fallback bool) bool { + raw := strings.ToLower(strings.TrimSpace(getSettingString(key, ""))) + if raw == "" { + return fallback + } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +// ── Per-user settings helpers ───────────────────────────────────────────────── + +// getUserSettingString reads from user_settings; falls back to global settings. +func getUserSettingString(userID, key, fallback string) string { + if userID != "" && userID != "admin" { + var val string + err := db.DB.QueryRow("SELECT value FROM user_settings WHERE user_id = ? AND key = ?", userID, key).Scan(&val) + if err == nil && strings.TrimSpace(val) != "" { + return val + } + } + return getSettingString(key, fallback) +} + +func setUserSettingString(userID, key, value string) { + if userID == "" || userID == "admin" { + setSettingString(key, value) + return + } + db.DB.Exec("INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)", userID, key, value) +} + +func getUserSettingInt(userID, key string, fallback int) int { + strVal := getUserSettingString(userID, key, "") + if strVal == "" { + return fallback + } + var intVal int + _, err := fmt.Sscanf(strVal, "%d", &intVal) + if err != nil { + return fallback + } + return intVal +} + +func setUserSettingInt(userID, key string, value int) { + setUserSettingString(userID, key, fmt.Sprintf("%d", value)) +} + +// ResolveOpenRouterKey returns the user's personal OpenRouter key if set, +// otherwise falls back to the server-wide OPENROUTER_API_KEY env var. +func ResolveOpenRouterKey(userID string) string { + if userID != "" && userID != "admin" { + if key := db.GetUserOpenrouterKey(userID); strings.TrimSpace(key) != "" { + return strings.TrimSpace(key) + } + } + return os.Getenv("OPENROUTER_API_KEY") +} + +// ── Settings payload ────────────────────────────────────────────────────────── + +// GetCurrentSettings returns settings for a given user. Pass "" or "admin" for global/admin view. +// User-specific keys (model, prompt, tokens) come from user_settings with global fallback. +// Platform keys (embedding, agentos) always come from global settings. +func GetCurrentSettings(userID string) SettingsPayload { + // Per-user keys + var featuredModels []string + featuredBytes := getUserSettingString(userID, "featured_models", "[]") + json.Unmarshal([]byte(featuredBytes), &featuredModels) + + defaultModel := getUserSettingString(userID, "default_model", "") + systemPrompt := getUserSettingString(userID, "system_prompt", "You are Apollo, a core intelligent system. You are helpful, direct, and concise.") + autoCompactTokens := getUserSettingInt(userID, "auto_compact_tokens", 80000) + + // Global/platform keys + embeddingApiUrl := getSettingString("embedding_api_url", "http://localhost:11434") + embeddingModel := getSettingString("embedding_model", "nomic-embed-text") + + var managedProjects []ProjectConfig + managedProjectsBytes := getSettingString("managed_projects", "[]") + json.Unmarshal([]byte(managedProjectsBytes), &managedProjects) + + agentOSEnabled := getSettingBool("agentos_enabled", true) + agentOSPolicyEnforcement := getSettingString("agentos_policy_enforcement", "deny_default") + agentOSKillSwitch := getSettingBool("agentos_kill_switch", false) + + return SettingsPayload{ + BearerToken: "", + FeaturedModels: featuredModels, + DefaultModel: defaultModel, + EmbeddingApiUrl: embeddingApiUrl, + EmbeddingModel: embeddingModel, + SystemPrompt: systemPrompt, + ManagedProjects: managedProjects, + AutoCompactTokens: autoCompactTokens, + AgentOSEnabled: agentOSEnabled, + AgentOSPolicyEnforcement: agentOSPolicyEnforcement, + AgentOSKillSwitch: agentOSKillSwitch, + } +} + +// Handler for fetching and updating app settings +func SettingsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + userID := CurrentUserID(r) + + if r.Method == http.MethodGet { + payload := GetCurrentSettings(userID) + json.NewEncoder(w).Encode(payload) + return + } + + if r.Method == http.MethodPost { + payload := GetCurrentSettings(userID) + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Per-user settings — any authenticated user can set these + if payload.FeaturedModels != nil { + bytes, _ := json.Marshal(payload.FeaturedModels) + setUserSettingString(userID, "featured_models", string(bytes)) + } + setUserSettingString(userID, "default_model", payload.DefaultModel) + setUserSettingString(userID, "system_prompt", payload.SystemPrompt) + setUserSettingInt(userID, "auto_compact_tokens", payload.AutoCompactTokens) + + // Platform settings — admin only + if userID == "admin" { + setSettingString("embedding_api_url", payload.EmbeddingApiUrl) + setSettingString("embedding_model", payload.EmbeddingModel) + setSettingString("agentos_enabled", fmt.Sprintf("%t", payload.AgentOSEnabled)) + setSettingString("agentos_policy_enforcement", payload.AgentOSPolicyEnforcement) + setSettingString("agentos_kill_switch", fmt.Sprintf("%t", payload.AgentOSKillSwitch)) + } + + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} diff --git a/dash/backend/handlers/subagents.go b/dash/backend/handlers/subagents.go new file mode 100644 index 0000000..770a40c --- /dev/null +++ b/dash/backend/handlers/subagents.go @@ -0,0 +1,208 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/danilrybalkin/apollo-dash/db" + "github.com/danilrybalkin/apollo-dash/tools" + "github.com/google/uuid" +) + +// SubagentRecord represents a background subagent record in the DB +type SubagentRecord struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Name string `json:"name"` + Task string `json:"task"` + Status string `json:"status"` // running | done | error + Output string `json:"output"` + CreatedAt string `json:"created_at"` +} + +// SubagentsHandler: GET /api/subagents → list all +// +// DELETE /api/subagents?id= → cancel/remove one +func SubagentsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method == http.MethodGet { + rows, err := db.DB.Query("SELECT id, IFNULL(session_id,''), name, task, status, IFNULL(output,''), created_at FROM subagents ORDER BY created_at DESC") + if err != nil { + http.Error(w, "DB error", http.StatusInternalServerError) + return + } + defer rows.Close() + var agents []SubagentRecord + for rows.Next() { + var a SubagentRecord + rows.Scan(&a.ID, &a.SessionID, &a.Name, &a.Task, &a.Status, &a.Output, &a.CreatedAt) + agents = append(agents, a) + } + if agents == nil { + agents = []SubagentRecord{} + } + json.NewEncoder(w).Encode(agents) + return + } + + if r.Method == http.MethodDelete { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id is required", http.StatusBadRequest) + return + } + db.DB.Exec("DELETE FROM subagents WHERE id = ?", id) + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +// SpawnSubagent creates a DB record and launches a background goroutine that runs +// an independent LLM+tool loop for the given task. +func SpawnSubagent(sessionID, name, task string) string { + id := uuid.New().String() + db.DB.Exec( + "INSERT INTO subagents (id, session_id, name, task, status, output) VALUES (?, ?, ?, ?, 'running', '')", + id, sessionID, name, task, + ) + log.Printf("Subagent [%s] spawned: %s", name, task) + + go runSubagent(id, name, task, sessionID) + return id +} + +// GetSubagentStatus returns the current status and output of a subagent +func GetSubagentStatus(id string) (status, output string) { + db.DB.QueryRow("SELECT status, IFNULL(output,'') FROM subagents WHERE id = ?", id).Scan(&status, &output) + return +} + +// runSubagent is the background goroutine that executes the subagent's LLM+tool loop +func runSubagent(id, name, task, sessionID string) { + defer func() { + if r := recover(); r != nil { + db.DB.Exec("UPDATE subagents SET status='error', output=? WHERE id=?", fmt.Sprintf("Panic: %v", r), id) + } + }() + + apiKey := os.Getenv("OPENROUTER_API_KEY") + ollamaUrl := os.Getenv("OLLAMA_API_URL") + + var targetUrl, reqApiKey, model string + + settings := GetCurrentSettings("") + if settings.DefaultModel != "" { + model = settings.DefaultModel + } else { + model = "meta-llama/llama-3-8b-instruct:free" + } + + if ollamaUrl != "" { + targetUrl = ollamaUrl + "/v1/chat/completions" + reqApiKey = "Bearer local" + } else { + targetUrl = "https://openrouter.ai/api/v1/chat/completions" + reqApiKey = "Bearer " + apiKey + } + + sysPrompt := fmt.Sprintf(`You are a background subagent named "%s". Your sole task is: + +%s + +Work autonomously using your tools. When you are done, produce a comprehensive report of your findings, actions taken, or results. Be thorough.`, name, task) + + messages := []map[string]interface{}{ + {"role": "system", "content": sysPrompt}, + {"role": "user", "content": "Begin working on your assigned task."}, + } + + var outputLog string + var projectPath string + if sessionID != "" { + db.DB.QueryRow("SELECT IFNULL(project_path, '') FROM chat_sessions WHERE id = ?", sessionID).Scan(&projectPath) + } + + // Tool loop — up to 8 turns to complete the task + client := &http.Client{Timeout: 300 * time.Second} + for attempt := 0; attempt < 8; attempt++ { + payload, _ := json.Marshal(map[string]interface{}{ + "model": model, + "messages": messages, + "stream": false, + "tools": tools.GetAvailableTools(), + }) + req, _ := http.NewRequest("POST", targetUrl, bytes.NewBuffer(payload)) + req.Header.Set("Authorization", reqApiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil || resp.StatusCode != 200 { + db.DB.Exec("UPDATE subagents SET status='error', output=? WHERE id=?", "LLM request failed: "+err.Error(), id) + return + } + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ToolCalls []map[string]interface{} `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + } + json.NewDecoder(resp.Body).Decode(&result) + resp.Body.Close() + + if len(result.Choices) == 0 { + break + } + + msg := result.Choices[0].Message + if msg.Content != "" { + outputLog += msg.Content + "\n\n" + } + + // Execute tool calls if any + if len(msg.ToolCalls) > 0 { + messages = append(messages, map[string]interface{}{ + "role": "assistant", + "content": msg.Content, + "tool_calls": msg.ToolCalls, + }) + for _, tc := range msg.ToolCalls { + id2, _ := tc["id"].(string) + funcObj, _ := tc["function"].(map[string]interface{}) + tName, _ := funcObj["name"].(string) + tArgs, _ := funcObj["arguments"].(string) + result := tools.ExecuteTool(tName, tArgs, projectPath) + outputLog += fmt.Sprintf("[Tool: %s] %s\n\n", tName, result) + messages = append(messages, map[string]interface{}{ + "role": "tool", "tool_call_id": id2, "name": tName, "content": result, + }) + } + continue + } + + // No tool calls → the agent is done + if result.Choices[0].FinishReason == "stop" || msg.Content != "" { + break + } + } + + // Store final output, notify session + db.DB.Exec("UPDATE subagents SET status='done', output=? WHERE id=?", outputLog, id) + if sessionID != "" { + // Inject a system notification into the session's next context via a DB message + notification := fmt.Sprintf("\nSubagent **%s** has finished its task.\n\n**Summary:**\n%s\n", name, outputLog) + db.DB.Exec("INSERT INTO chat_messages (session_id, role, content) VALUES (?, 'system', ?)", sessionID, notification) + } + log.Printf("Subagent [%s] completed.", name) +} diff --git a/dash/backend/handlers/sys.go b/dash/backend/handlers/sys.go new file mode 100644 index 0000000..631de91 --- /dev/null +++ b/dash/backend/handlers/sys.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "os/exec" + + "github.com/danilrybalkin/apollo-dash/tools" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" +) + +type SysMetrics struct { + CPUPercent []float64 `json:"cpuPercent"` + MemTotal uint64 `json:"memTotal"` + MemUsed uint64 `json:"memUsed"` + MemPercent float64 `json:"memPercent"` + DiskTotal uint64 `json:"diskTotal"` + DiskUsed uint64 `json:"diskUsed"` + DiskPercent float64 `json:"diskPercent"` + NetSent uint64 `json:"netSent"` + NetRecv uint64 `json:"netRecv"` + HostOS string `json:"hostOs"` + Uptime uint64 `json:"uptime"` + Temps []host.TemperatureStat `json:"temps"` +} + +func SysStatsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + metrics := SysMetrics{} + + // CPU + cpuPercents, err := cpu.Percent(0, false) + if err == nil { + metrics.CPUPercent = cpuPercents + } + + // Memory + vMem, err := mem.VirtualMemory() + if err == nil { + metrics.MemTotal = vMem.Total + metrics.MemUsed = vMem.Used + metrics.MemPercent = vMem.UsedPercent + } + + // Disk (root) + d, err := disk.Usage("/") + if err == nil { + metrics.DiskTotal = d.Total + metrics.DiskUsed = d.Used + metrics.DiskPercent = d.UsedPercent + } + + // Network + nv, err := net.IOCounters(false) + if err == nil && len(nv) > 0 { + metrics.NetSent = nv[0].BytesSent + metrics.NetRecv = nv[0].BytesRecv + } + + // Host + hInfo, err := host.Info() + if err == nil { + metrics.HostOS = hInfo.OS + metrics.Uptime = hInfo.Uptime + } + + // Temps + // Note: gopsutil temps might return empty depending on OS (macOS is notorious for missing SMC sensors without extra privileges) + tStats, err := host.SensorsTemperatures() + if err == nil { + metrics.Temps = tStats + } + + json.NewEncoder(w).Encode(metrics) +} + +func SandboxRestoreHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var payload struct { + Hash string `json:"hash"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.Hash == "" { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + cmd := exec.Command("git", "reset", "--hard", payload.Hash) + cmd.Dir = tools.WorkspaceDir + out, err := cmd.CombinedOutput() + + w.Header().Set("Content-Type", "application/json") + if err != nil { + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("Error: %v, Output: %s", err, string(out))}) + return + } + + json.NewEncoder(w).Encode(map[string]string{"success": "true"}) +} diff --git a/dash/backend/handlers/terminal.go b/dash/backend/handlers/terminal.go new file mode 100644 index 0000000..fef19af --- /dev/null +++ b/dash/backend/handlers/terminal.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "os" + "os/exec" + + "github.com/creack/pty" + "github.com/gorilla/websocket" +) + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + // The whole app is behind BasicAuth so we trust same-origin connections + return true + }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +// TerminalHandler: GET /ws/terminal +// Upgrades to a WebSocket connection, spawns a bash PTY, and proxies data bidirectionally. +func TerminalHandler(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Println("WebSocket upgrade failed:", err) + return + } + defer conn.Close() + + // Determine starting directory + startDir := os.Getenv("HOME") + if startDir == "" { + startDir = "/" + } + if projectPath := r.URL.Query().Get("path"); projectPath != "" { + if _, err := os.Stat(projectPath); err == nil { + startDir = projectPath + } + } + + // Spawn a shell inside a PTY + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/bash" + } + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + cmd.Dir = startDir + + ptmx, err := pty.Start(cmd) + if err != nil { + log.Println("PTY start failed:", err) + conn.WriteMessage(websocket.TextMessage, []byte("Failed to start terminal: "+err.Error())) + return + } + defer func() { + ptmx.Close() + cmd.Process.Kill() + }() + + // PTY → WebSocket: forward all terminal output to the browser + go func() { + buf := make([]byte, 1024) + for { + n, err := ptmx.Read(buf) + if n > 0 { + if err2 := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err2 != nil { + return + } + } + if err != nil { + return + } + } + }() + + // WebSocket → PTY: forward all keyboard input from browser to the shell + for { + msgType, data, err := conn.ReadMessage() + if err != nil { + break + } + if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage { + // Check for a resize message: JSON {"type":"resize","cols":N,"rows":N} + if len(data) > 0 && data[0] == '{' { + var msg struct { + Type string `json:"type"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + } + if err := json.Unmarshal(data, &msg); err == nil && msg.Type == "resize" && msg.Cols > 0 && msg.Rows > 0 { + pty.Setsize(ptmx, &pty.Winsize{Cols: msg.Cols, Rows: msg.Rows}) + continue + } + } + ptmx.Write(data) + } + } +} diff --git a/dash/backend/handlers/user_auth.go b/dash/backend/handlers/user_auth.go new file mode 100644 index 0000000..f340544 --- /dev/null +++ b/dash/backend/handlers/user_auth.go @@ -0,0 +1,299 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/danilrybalkin/apollo-dash/db" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +func extractBearerToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + return "" +} + +func RegisterHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if body.Email == "" || body.Password == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "email and password required"}) + return + } + if len(body.Password) < 8 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "password_too_short"}) + return + } + if db.EmailExists(body.Email) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"error": "email_taken"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "internal_error"}) + return + } + userID := uuid.New().String() + user, err := db.CreateUser(userID, body.Email, string(hash), body.Name) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "internal_error"}) + return + } + token := uuid.New().String() + if err := db.CreateUserToken(token, userID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "internal_error"}) + return + } + // Seed demo data and send welcome email after token is committed + go SeedDemoCompany(userID) + go SendWelcomeEmail(context.Background(), body.Email, body.Name) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "user": user, + "token": token, + }) +} + +func LoginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + id, _, hash, _, isBlocked, err := db.GetUserByEmail(body.Email) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid_credentials"}) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid_credentials"}) + return + } + if isBlocked == 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"error": "account_blocked"}) + return + } + token := uuid.New().String() + if err := db.CreateUserToken(token, id); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + user, err := db.GetUserByID(id) + if err != nil || user == nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "user": user, + "token": token, + }) +} + +func MeHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPatch { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := extractBearerToken(r) + if token == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + user, err := db.GetUserByToken(token) + if err != nil || user == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPatch { + var body struct { + Name string `json:"name"` + OpenrouterAPIKey string `json:"openrouter_api_key"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if body.Name != "" { + if err := db.UpdateUserName(user.ID, body.Name); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + user.Name = body.Name + } + if body.OpenrouterAPIKey != "" { + if err := db.UpdateUserOpenrouterKey(user.ID, body.OpenrouterAPIKey); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + user.OpenrouterAPIKey = body.OpenrouterAPIKey + } + } + json.NewEncoder(w).Encode(user) +} + +func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := extractBearerToken(r) + if token == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + user, err := db.GetUserByToken(token) + if err != nil || user == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + var body struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if len(body.NewPassword) < 8 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "password_too_short"}) + return + } + currentHash, err := db.GetUserPasswordHash(user.ID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(body.CurrentPassword)); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid_current_password"}) + return + } + newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := db.UpdateUserPassword(user.ID, string(newHash)); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := extractBearerToken(r) + if token == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + user, err := db.GetUserByToken(token) + if err != nil || user == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + // Require password confirmation + var body struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Password == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "password required to delete account"}) + return + } + currentHash, err := db.GetUserPasswordHash(user.ID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(body.Password)); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid_password"}) + return + } + if err := db.DeleteUser(user.ID); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) +} + +func LogoutHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := extractBearerToken(r) + if token != "" { + db.DeleteUserToken(token) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/dash/backend/handlers/workspace.go b/dash/backend/handlers/workspace.go new file mode 100644 index 0000000..8aa868c --- /dev/null +++ b/dash/backend/handlers/workspace.go @@ -0,0 +1,314 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/danilrybalkin/apollo-dash/db" +) + +// WorkspaceTreeNode represents a file or directory in the tree +type WorkspaceTreeNode struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"isDir"` + Children []*WorkspaceTreeNode `json:"children,omitempty"` +} + +func withinRoot(path string, root string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + if rel == "." { + return true + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// getProjectRoots returns mapped workspace roots for one user from companies. +func getProjectRoots(ownerUserID string) []ProjectConfig { + rows, err := db.DB.Query(` + SELECT IFNULL(name,''), IFNULL(workspace_path,''), IFNULL(deploy_command,'') + FROM companies + WHERE owner_user_id = ? AND TRIM(IFNULL(workspace_path,'')) <> '' + ORDER BY created_at ASC + `, ownerUserID) + if err == nil { + defer rows.Close() + var out []ProjectConfig + for rows.Next() { + var p ProjectConfig + if scanErr := rows.Scan(&p.Name, &p.Path, &p.DeployCommand); scanErr != nil { + continue + } + p.Path = strings.TrimSpace(p.Path) + if p.Path == "" { + continue + } + out = append(out, p) + } + if len(out) > 0 { + return out + } + } + + return []ProjectConfig{} +} + +// securePath validates that the requested path is within one of the allowed project roots +func secureWorkspacePath(ownerUserID string, requestedPath string) (string, bool) { + abs, err := filepath.Abs(requestedPath) + if err != nil { + return "", false + } + roots := getProjectRoots(ownerUserID) + for _, p := range roots { + rootAbs, err := filepath.Abs(p.Path) + if err != nil { + continue + } + if withinRoot(abs, rootAbs) { + return abs, true + } + } + return "", false +} + +// buildTree recursively builds a WorkspaceTreeNode tree up to maxDepth levels +func buildTree(root string, displayPath string, depth int) *WorkspaceTreeNode { + info, err := os.Stat(root) + if err != nil { + return nil + } + node := &WorkspaceTreeNode{ + Name: info.Name(), + Path: displayPath, + IsDir: info.IsDir(), + } + if !info.IsDir() || depth <= 0 { + return node + } + entries, err := os.ReadDir(root) + if err != nil { + return node + } + // Sort: dirs first, then files + sort.Slice(entries, func(i, j int) bool { + if entries[i].IsDir() != entries[j].IsDir() { + return entries[i].IsDir() + } + return entries[i].Name() < entries[j].Name() + }) + for _, entry := range entries { + name := entry.Name() + // Skip hidden files and common noise + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "__pycache__" || name == "venv" || name == "dist" { + continue + } + childPath := filepath.Join(root, name) + childDisplay := filepath.Join(displayPath, name) + child := buildTree(childPath, childDisplay, depth-1) + if child != nil { + node.Children = append(node.Children, child) + } + } + return node +} + +// WorkspaceProjectsHandler: GET /api/workspace/projects +// Returns the list of company-mapped workspace roots. +func WorkspaceProjectsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + projects := getProjectRoots(CurrentUserID(r)) + if projects == nil { + projects = []ProjectConfig{} + } + json.NewEncoder(w).Encode(projects) +} + +// WorkspaceTreeHandler: GET /api/workspace/tree?path= +// Returns the file tree for the given directory path (must be within a mapped company workspace). +func WorkspaceTreeHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + requestedPath := r.URL.Query().Get("path") + if requestedPath == "" { + // Return all project roots as a virtual tree + roots := getProjectRoots(CurrentUserID(r)) + var nodes []*WorkspaceTreeNode + for _, p := range roots { + node := buildTree(p.Path, p.Path, 20) + if node != nil { + node.Name = p.Name // Use friendly name as tree root label + nodes = append(nodes, node) + } + } + if nodes == nil { + nodes = []*WorkspaceTreeNode{} + } + json.NewEncoder(w).Encode(nodes) + return + } + safePath, ok := secureWorkspacePath(CurrentUserID(r), requestedPath) + if !ok { + http.Error(w, "Path not within a managed project", http.StatusForbidden) + return + } + node := buildTree(safePath, safePath, 20) + if node == nil { + http.Error(w, "Path not found", http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(node) +} + +// WorkspaceFileHandler handles GET/POST /api/workspace/file +func WorkspaceFileHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method == http.MethodGet { + // Read file contents + requestedPath := r.URL.Query().Get("path") + if requestedPath == "" { + http.Error(w, "path is required", http.StatusBadRequest) + return + } + safePath, ok := secureWorkspacePath(CurrentUserID(r), requestedPath) + if !ok { + http.Error(w, "Path not within a managed project", http.StatusForbidden) + return + } + content, err := os.ReadFile(safePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(map[string]string{ + "path": safePath, + "content": string(content), + }) + return + } + + if r.Method == http.MethodPost { + // Write file contents + var payload struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + safePath, ok := secureWorkspacePath(CurrentUserID(r), payload.Path) + if !ok { + http.Error(w, "Path not within a managed project", http.StatusForbidden) + return + } + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { + http.Error(w, "Failed to create directory", http.StatusInternalServerError) + return + } + if err := os.WriteFile(safePath, []byte(payload.Content), 0644); err != nil { + http.Error(w, "Failed to write file", http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(map[string]string{"status": "saved"}) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +// WorkspaceDeployHandler: POST /api/workspace/deploy +// Runs the configured deploy command for a project and streams stdout +func WorkspaceDeployHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var payload struct { + Project string `json:"project"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + var target *ProjectConfig + for _, p := range getProjectRoots(CurrentUserID(r)) { + if p.Name == payload.Project { + pc := p + target = &pc + break + } + } + if target == nil { + http.Error(w, "Project not found", http.StatusNotFound) + return + } + + // Stream deploy output as SSE + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + + cmd := exec.Command("bash", "-c", target.DeployCommand) + cmd.Dir = target.Path + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + http.Error(w, "Failed to create stdout pipe", http.StatusInternalServerError) + return + } + cmd.Stderr = cmd.Stdout // Merge stderr into stdout + + if err := cmd.Start(); err != nil { + http.Error(w, "Failed to start deploy command: "+err.Error(), http.StatusInternalServerError) + return + } + + buf := make([]byte, 512) + for { + n, err := stdoutPipe.Read(buf) + if n > 0 { + line := string(buf[:n]) + data, _ := json.Marshal(map[string]string{"output": line}) + w.Write([]byte("data: ")) + w.Write(data) + w.Write([]byte("\n\n")) + if ok { + flusher.Flush() + } + } + if err == io.EOF { + break + } + if err != nil { + break + } + } + + cmd.Wait() + w.Write([]byte("data: {\"output\":\"\\n[Deploy complete]\\n\",\"done\":true}\n\n")) + if ok { + flusher.Flush() + } +} diff --git a/dash/backend/main.go b/dash/backend/main.go new file mode 100644 index 0000000..d72fe4f --- /dev/null +++ b/dash/backend/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/danilrybalkin/apollo-dash/agentos" + "github.com/danilrybalkin/apollo-dash/db" + "github.com/danilrybalkin/apollo-dash/handlers" + "github.com/danilrybalkin/apollo-dash/tools" + "github.com/joho/godotenv" +) + +func main() { + // Load .env + err := godotenv.Load() + if err != nil { + log.Println("No .env file found or error loading it. Using environment variables.") + } + + db.InitDB() + handlers.SeedDefaultModelProfiles() + handlers.InitEmailer() + tools.InitWorkspace() + tools.SyncBundledSkills() + tools.SetSubagentFuncs(handlers.SpawnSubagent, handlers.GetSubagentStatus) + + workspaceRoot := os.Getenv("AGENTHQ_WORKSPACE_ROOT") + if workspaceRoot == "" { + workspaceRoot = tools.WorkspaceDir + } + agentSvc := agentos.NewService(db.DB, workspaceRoot) + handlers.SetAgentOSService(agentSvc) + go agentSvc.Start(context.Background()) + handlers.StartEmailNotifier(context.Background()) + + // Initialize RAG Memory Engine + handlers.LoadMemoryBank() + go handlers.StartMemoryIndexer() + + // Initialize MCP servers (reconnect persisted servers) + go handlers.InitMCPServers() + + port := os.Getenv("PORT") + if port == "" { + port = "4000" + } + + mux := http.NewServeMux() + + // Auth routes (public — no auth required) + mux.HandleFunc("/api/auth/register", handlers.RegisterHandler) + mux.HandleFunc("/api/auth/login", handlers.LoginHandler) + mux.HandleFunc("/api/auth/me", handlers.MeHandler) + mux.HandleFunc("/api/auth/change-password", handlers.ChangePasswordHandler) + mux.HandleFunc("/api/auth/delete-account", handlers.DeleteAccountHandler) + mux.HandleFunc("/api/auth/logout", handlers.LogoutHandler) + + // Admin routes (require DASHBOARD_PASSWORD bearer token) + mux.HandleFunc("/api/admin/stats", handlers.AdminStatsHandler) + mux.HandleFunc("/api/admin/users", handlers.AdminUsersHandler) + mux.HandleFunc("/api/admin/users/", handlers.AdminUserByIDHandler) + + // Billing (Vulta payments + webhook) + mux.HandleFunc("/api/billing/checkout", handlers.BillingCheckoutHandler) + mux.HandleFunc("/api/billing/webhook", handlers.BillingWebhookHandler) + + // API Routes under /api/* + mux.HandleFunc("/api/sys", handlers.SysStatsHandler) + mux.HandleFunc("/api/ping", handlers.PingHandler) + mux.HandleFunc("/api/vps", handlers.VpsManagerHandler) + mux.HandleFunc("/api/chat", handlers.AiChatHandler) + mux.HandleFunc("/api/models", handlers.AiModelsHandler) + mux.HandleFunc("/api/sessions", handlers.AiSessionsHandler) + mux.HandleFunc("/api/messages", handlers.AiMessagesHandler) + mux.HandleFunc("/api/settings", handlers.SettingsHandler) + mux.HandleFunc("/api/sandbox/restore", handlers.SandboxRestoreHandler) + + // Workspace IDE routes + mux.HandleFunc("/api/workspace/projects", handlers.WorkspaceProjectsHandler) + mux.HandleFunc("/api/workspace/tree", handlers.WorkspaceTreeHandler) + mux.HandleFunc("/api/workspace/file", handlers.WorkspaceFileHandler) + mux.HandleFunc("/api/workspace/deploy", handlers.WorkspaceDeployHandler) + + // Background Subagents + mux.HandleFunc("/api/subagents", handlers.SubagentsHandler) + mux.HandleFunc("/api/companies", handlers.AgentOSCompaniesHandler) + mux.HandleFunc("/api/companies/", handlers.AgentOSCompanyByIDHandler) + // Compatibility aliases for reverse proxies that strip the /api prefix. + mux.HandleFunc("/companies", handlers.AgentOSCompaniesHandler) + mux.HandleFunc("/companies/", handlers.AgentOSCompanyByIDHandler) + mux.HandleFunc("/api/departments", handlers.AgentOSDepartmentsHandler) + mux.HandleFunc("/api/departments/", handlers.AgentOSDepartmentByIDHandler) + mux.HandleFunc("/api/agents", handlers.AgentOSAgentsHandler) + mux.HandleFunc("/api/agents/", handlers.AgentOSAgentByIDHandler) + mux.HandleFunc("/api/model-profiles", handlers.AgentOSModelProfilesHandler) + mux.HandleFunc("/api/threads", handlers.AgentOSThreadsHandler) + mux.HandleFunc("/api/threads/", handlers.AgentOSThreadMessagesHandler) + mux.HandleFunc("/api/tasks", handlers.AgentOSTasksHandler) + mux.HandleFunc("/api/tasks/", handlers.AgentOSTaskByIDHandler) + mux.HandleFunc("/api/consensus/rounds", handlers.AgentOSConsensusRoundsHandler) + mux.HandleFunc("/api/consensus/rounds/", handlers.AgentOSConsensusRoundByIDHandler) + mux.HandleFunc("/api/memory/query", handlers.AgentOSMemoryQueryHandler) + mux.HandleFunc("/api/memory/write", handlers.AgentOSMemoryWriteHandler) + mux.HandleFunc("/api/memory/timeline", handlers.AgentOSMemoryTimelineHandler) + mux.HandleFunc("/api/schedules", handlers.AgentOSSchedulesHandler) + mux.HandleFunc("/api/schedules/", handlers.AgentOSScheduleByIDHandler) + mux.HandleFunc("/api/events", handlers.AgentOSEventsHandler) + mux.HandleFunc("/api/events/stream", handlers.AgentOSEventsStreamHandler) + mux.HandleFunc("/api/policies", handlers.AgentOSPoliciesHandler) + mux.HandleFunc("/api/policies/test", handlers.AgentOSPolicyTestHandler) + mux.HandleFunc("/api/approvals", handlers.AgentOSApprovalsHandler) + mux.HandleFunc("/api/approvals/resolve", handlers.AgentOSApprovalsResolveHandler) + mux.HandleFunc("/api/audit", handlers.AgentOSAuditHandler) + mux.HandleFunc("/api/audit/verify", handlers.AgentOSAuditVerifyHandler) + mux.HandleFunc("/api/health/agentos", handlers.AgentOSHealthHandler) + mux.HandleFunc("/api/topology", handlers.AgentOSTopologyHandler) + mux.HandleFunc("/api/inter-agent", handlers.AgentInboxHandler) + + // Execution Mode Confirmations + mux.HandleFunc("/api/confirm", handlers.ConfirmHandler) + + // MCP (Model Context Protocol) server management + mux.HandleFunc("/api/mcp", handlers.MCPHandler) + + // WebSocket Terminal (PTY) + mux.HandleFunc("/ws/terminal", handlers.TerminalHandler) + + // Third-party API Endpoints (OpenAI format wrapper) + mux.HandleFunc("/api/ext/chat/completions", handlers.AiExternalChatHandler) + mux.HandleFunc("/api/ext/models", handlers.AiModelsHandler) + + // Combine API routes and wrap them in auth (if desired to protect API) + // Or we can protect everything (Static + API) + // Let's protect EVERYTHING so the dashboard is a true Auth Wall. + + // Serve React Frontend + // The frontend/dist folder will be created by Vite build + frontendDir := filepath.Join("..", "frontend", "dist") + fs := http.FileServer(http.Dir(frontendDir)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + cleanPath := strings.TrimPrefix(filepath.Clean(r.URL.Path), string(filepath.Separator)) + requestedPath := filepath.Join(frontendDir, cleanPath) + if info, err := os.Stat(requestedPath); err == nil && !info.IsDir() { + fs.ServeHTTP(w, r) + return + } + http.ServeFile(w, r, filepath.Join(frontendDir, "index.html")) + }) + + // Wrap entire mux with Auth + protectedMux := handlers.BasicAuthMiddleware(mux) + + fmt.Printf("AgentHQ Backend running on port %s\n", port) + fmt.Printf("Serving static files from %s\n", frontendDir) + log.Fatal(http.ListenAndServe(":"+port, protectedMux)) +} diff --git a/dash/backend/skills/deployment.md b/dash/backend/skills/deployment.md new file mode 100644 index 0000000..41a6e0b --- /dev/null +++ b/dash/backend/skills/deployment.md @@ -0,0 +1,124 @@ +--- +description: Docker and deployment — Dockerfile best practices, docker-compose, systemd services, nginx reverse proxy, zero-downtime deploys +--- + +# Deployment Skill + +## Dockerfile Best Practices + +### Multi-stage Go build +```dockerfile +FROM golang:1.24-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download # cache dependency layer separately +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/server . +COPY --from=builder /app/skills ./skills +EXPOSE 4000 +CMD ["./server"] +``` + +### Layer caching rules +- Copy `go.mod` + `go.sum` BEFORE source code — `go mod download` cache only invalidates when dependencies change +- Put rarely-changing layers (apt installs) before frequently-changing ones (source code) +- Use `.dockerignore` to exclude `node_modules`, `.git`, `dist` + +## Docker Compose +```yaml +services: + agenthq: + build: ./dash/backend + ports: ["4000:4000"] + environment: + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - AGENTHQ_WORKSPACE_ROOT=/app/data/workspaces + volumes: + - agenthq-data:/app/data + restart: unless-stopped + + frontend: + build: ./dash/frontend + depends_on: [agenthq] + +volumes: + agenthq-data: +``` + +## systemd Service +```ini +# /etc/systemd/system/apollo.service +[Unit] +Description=Apollo Dashboard +After=network.target + +[Service] +Type=simple +User=apollo +WorkingDirectory=/opt/apollo +ExecStart=/opt/apollo/server +Restart=on-failure +RestartSec=5s +Environment=PORT=4000 +EnvironmentFile=/opt/apollo/.env + +[Install] +WantedBy=multi-user.target +``` +```bash +systemctl daemon-reload && systemctl enable --now apollo +journalctl -u apollo -f # follow logs +``` + +## nginx Reverse Proxy +```nginx +server { + listen 443 ssl; + server_name apollo.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/apollo.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/apollo.yourdomain.com/privkey.pem; + + location / { + proxy_pass http://localhost:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; # WebSocket support + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # SSE: disable buffering + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + } +} +``` + +## Zero-Downtime Deploy +```bash +# Build new binary +go build -o server.new . + +# Swap atomically +mv server server.old && mv server.new server + +# Graceful restart (sends SIGTERM → process drains → exits) +kill -SIGTERM $(pidof server) +# Or with systemd: +systemctl reload apollo # if ExecReload is configured +systemctl restart apollo # hard restart + +# Keep old binary for quick rollback +``` + +## SSL Certificate (Let's Encrypt) +```bash +certbot --nginx -d apollo.yourdomain.com +# Auto-renewal (already installed by certbot): +systemctl status certbot.timer +``` diff --git a/dash/backend/skills/git_workflow.md b/dash/backend/skills/git_workflow.md new file mode 100644 index 0000000..60ddaac --- /dev/null +++ b/dash/backend/skills/git_workflow.md @@ -0,0 +1,93 @@ +--- +description: Git workflows — branching strategy, interactive rebase, cherry-pick, bisect, stash, undo recipes +--- + +# Git Workflow Skill + +## Branch Strategy +``` +main — always deployable +dev — integration branch +feat/NAME — feature branches (from dev) +fix/NAME — bug fixes +hotfix/NAME — emergency patches directly from main +``` + +## Interactive Rebase (squash, reorder, edit) +```bash +git rebase -i HEAD~5 # rebase last 5 commits +# In editor: pick → squash (s) to merge, reword (r) to edit message +git rebase -i main # squash entire feature branch for clean merge +``` + +## Cherry-Pick +```bash +git cherry-pick abc1234 # apply single commit +git cherry-pick abc1234..def5678 # apply range of commits +git cherry-pick -n abc1234 # stage without committing (--no-commit) +``` + +## Bisect (find the commit that introduced a bug) +```bash +git bisect start +git bisect bad # current commit is broken +git bisect good v2.1.0 # last known good tag +# git does binary search — test each checkout: +git bisect good # or: git bisect bad +git bisect reset # when done +``` + +## Stash +```bash +git stash # stash all tracked changes +git stash -u # include untracked files +git stash push -m "WIP: auth" # named stash +git stash list +git stash pop # apply + remove latest +git stash apply stash@{2} # apply specific, keep in list +``` + +## Undo Recipes +```bash +# Undo last commit, keep changes staged +git reset --soft HEAD~1 + +# Undo last commit, unstage changes (default) +git reset HEAD~1 + +# Undo last commit, DISCARD changes (dangerous) +git reset --hard HEAD~1 + +# Revert a specific commit (safe, creates new commit) +git revert abc1234 + +# Unstage a file +git restore --staged file.go + +# Discard working copy changes in a file +git restore file.go +``` + +## Rebase vs Merge +| Situation | Use | +|---|---| +| Feature branch → main (clean history) | `rebase` then fast-forward merge | +| Hotfix with exact timestamp preservation | `merge --no-ff` | +| Public branch (others have checked out) | Always `merge` — never rebase public history | + +## Useful Aliases +```bash +git config --global alias.lg "log --oneline --graph --decorate --all" +git config --global alias.st "status -sb" +git config --global alias.undo "reset HEAD~1 --mixed" +``` + +## Conventional Commits +``` +feat: add user authentication +fix: resolve null pointer in order processing +docs: update API documentation +refactor: extract payment service +perf: optimize database query caching +chore: upgrade Go to 1.24 +``` diff --git a/dash/backend/skills/go_debugging.md b/dash/backend/skills/go_debugging.md new file mode 100644 index 0000000..2b13e3b --- /dev/null +++ b/dash/backend/skills/go_debugging.md @@ -0,0 +1,86 @@ +--- +description: Debugging Go services — race conditions, goroutine leaks, profiling, pprof, structured logging +--- + +# Go Debugging Skill + +## Race Conditions +Always run tests with: `go test -race ./...` +Common causes: shared maps without mutex, goroutines reading/writing same variable. + +```go +// Safe map access pattern +var mu sync.RWMutex +mu.RLock() +val := myMap[key] +mu.RUnlock() +``` + +## Goroutine Leak Detection +```go +// Import in test file +import "github.com/uber-go/goleak" + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} +``` + +## pprof Profiling +Add to main.go for CPU + mem profiling endpoints: +```go +import _ "net/http/pprof" +// Then: go tool pprof http://localhost:6060/debug/pprof/heap +``` + +Common pprof commands: +``` +go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine +go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap +go tool pprof -http=:8080 http://localhost:6060/debug/pprof/cpu?seconds=30 +``` + +## Structured Logging (slog, Go 1.21+) +```go +import "log/slog" +slog.Info("request received", "method", r.Method, "path", r.URL.Path, "latency_ms", ms) +slog.Error("database error", "err", err, "query", q) +``` + +## JSON Decode Gotchas +- `json.Decoder` reads lazily — always close body with `defer r.Body.Close()` +- Unknown fields are silently ignored unless you use `decoder.DisallowUnknownFields()` +- `time.Time` marshals as RFC3339 by default + +## HTTP Timeout Pattern +```go +client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + }, +} +``` + +## Error Wrapping +```go +if err != nil { + return fmt.Errorf("getting user %d: %w", id, err) +} +// Unwrap: errors.Is(err, ErrNotFound), errors.As(err, &myErr) +``` + +## go vet Checks +`go vet ./...` catches: printf format mismatches, unreachable code, suspicious composite literals, incorrect mutex copies. + +## Benchmark Pattern +```go +func BenchmarkMyFunc(b *testing.B) { + for b.Loop() { // Go 1.24+ — or: for i := 0; i < b.N; i++ + MyFunc() + } +} +// Run: go test -bench=. -benchmem ./... +``` diff --git a/dash/backend/skills/llm_integration.md b/dash/backend/skills/llm_integration.md new file mode 100644 index 0000000..dd1a9ec --- /dev/null +++ b/dash/backend/skills/llm_integration.md @@ -0,0 +1,128 @@ +--- +description: LLM API integration — streaming SSE, tool/function calling, prompt engineering, token management, OpenAI-compatible APIs +--- + +# LLM Integration Skill + +## Streaming SSE (Server-Sent Events) Pattern + +### Backend (Go) +```go +w.Header().Set("Content-Type", "text/event-stream") +w.Header().Set("Cache-Control", "no-cache") +w.Header().Set("Connection", "keep-alive") +flusher, _ := w.(http.Flusher) + +// Stream each chunk +w.Write([]byte("data: " + jsonChunk + "\n\n")) +flusher.Flush() +// Terminate: +w.Write([]byte("data: [DONE]\n\n")) +``` + +### Frontend (React) +```js +const response = await fetch('/api/chat', { method: 'POST', signal: controller.signal, body: ... }); +const reader = response.body.getReader(); +const decoder = new TextDecoder(); +let buffer = ''; +while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + for (const line of buffer.split('\n')) { + if (line.startsWith('data: ') && line !== 'data: [DONE]') { + const chunk = JSON.parse(line.slice(6)); + const delta = chunk.choices[0]?.delta?.content ?? ''; + setOutput(prev => prev + delta); + } + } + buffer = buffer.split('\n').pop(); // keep incomplete line +} +``` + +## Tool/Function Calling + +### Tool Definition Schema +```json +{ + "type": "function", + "function": { + "name": "read_file", + "description": "Reads the content of a file. Use this to understand existing code before making changes.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path relative to workspace root" } + }, + "required": ["path"] + } + } +} +``` + +### Tool Loop Pattern (Go) +```go +for attempt := 0; attempt < maxAttempts; attempt++ { + resp := callLLM(messages, tools) + if resp.ToolCalls == nil { break } // No tools called → final response + + messages = append(messages, resp.AsAssistantMessage()) + for _, tc := range resp.ToolCalls { + result := executeTool(tc.Name, tc.Arguments) + messages = append(messages, toolResultMessage(tc.ID, tc.Name, result)) + } +} +``` + +## Token Management + +### Rough estimates +- 1 token ≈ 4 characters (English) +- 1 token ≈ 0.75 words +- A typical code file of 100 lines ≈ 800–1200 tokens +- GPT-4o context: 128k tokens; Claude 3.7: 200k tokens + +### Context compression trigger +``` +if estimatedTokens > 80_000: + summarize middle messages → replace with summary + keep: system prompt + first 2 turns + last 4 turns +``` + +## Prompt Engineering Principles + +### Chain of Thought +Add to system prompt: *"Before answering, think step by step. Write your reasoning, then your final answer."* + +### Few-Shot Examples +``` +User: Convert 5 miles to km +Assistant: 5 × 1.60934 = 8.0467 km + +User: Convert 10 miles to km +Assistant: +``` + +### Structured Output +Request JSON and validate: +``` +Respond only with valid JSON matching this schema: {"action": "string", "confidence": 0-1, "reasoning": "string"} +Do not include any text outside the JSON object. +``` + +### Persona + Constraints +``` +You are a senior Go engineer. You MUST: +- Always run go vet after editing +- Never use global variables +- Return errors — never panic +- Write table-driven tests +``` + +## OpenRouter-Specific Notes +- `X-Title` header for dashboard tracking +- Model IDs: `anthropic/claude-3.7-sonnet`, `google/gemini-2.0-flash`, `openai/gpt-4o` +- Streaming: same as OpenAI (`stream: true` in payload) +- Tool calling: same schema as OpenAI function calling spec +- Some models don't support tools — check `supported_parameters` from `/api/v1/models` diff --git a/dash/backend/skills/react_patterns.md b/dash/backend/skills/react_patterns.md new file mode 100644 index 0000000..73ccd84 --- /dev/null +++ b/dash/backend/skills/react_patterns.md @@ -0,0 +1,85 @@ +--- +description: React performance — memo, useMemo, useCallback, virtualization, ref patterns, common pitfalls +--- + +# React Patterns Skill + +## Memoization Decision Tree +1. Is the component re-rendering too often? → `React.memo(Component)` +2. Is an expensive calculation running every render? → `useMemo` +3. Is a callback being re-created every render and passed to a child? → `useCallback` +4. Do you need to access a DOM node or preserve a value without re-rendering? → `useRef` + +## React.memo +```jsx +const ExpensiveList = React.memo(({ items, onSelect }) => { + return items.map(item => ); +}); +// Only re-renders when items or onSelect reference changes +``` + +## useMemo — expensive transforms +```jsx +const sorted = useMemo(() => + [...items].sort((a, b) => a.name.localeCompare(b.name)), + [items] // recalculate only when items changes +); +``` + +## useCallback — stable handlers +```jsx +const handleClick = useCallback((id) => { + setSelected(id); +}, []); // stable if no dependencies +``` + +## useRef Patterns +```jsx +// DOM access +const inputRef = useRef(null); +useEffect(() => { inputRef.current?.focus(); }, []); + +// Mutable value without triggering re-render (e.g. abort controller, timer) +const abortRef = useRef(null); +abortRef.current = new AbortController(); +``` + +## Virtualization (large lists) +Use `react-window` or `@tanstack/react-virtual` for lists > 200 items: +```jsx +import { FixedSizeList } from 'react-window'; + + {({ index, style }) =>
{items[index].name}
} +
+``` + +## Context Performance +Context re-renders ALL consumers when value changes. +Split contexts: `` + `` rather than one giant ``. +Use `useMemo` for context values: +```jsx +const value = useMemo(() => ({ user, setUser }), [user]); + +``` + +## State Update Pitfalls +```jsx +// WRONG: stale closure +setCount(count + 1); setCount(count + 1); // only increments by 1 + +// CORRECT: updater function +setCount(c => c + 1); setCount(c => c + 1); // increments by 2 +``` + +## Cleanup Pattern +```jsx +useEffect(() => { + const controller = new AbortController(); + fetch('/api/data', { signal: controller.signal }).then(setData); + return () => controller.abort(); // cleanup on unmount +}, []); +``` + +## Key Prop Rules +- Always use stable unique IDs — never array index for dynamic/reorderable lists +- Changing `key` forces full remount (useful to reset a component's state intentionally) diff --git a/dash/backend/skills/sql_tuning.md b/dash/backend/skills/sql_tuning.md new file mode 100644 index 0000000..3cb2f96 --- /dev/null +++ b/dash/backend/skills/sql_tuning.md @@ -0,0 +1,112 @@ +--- +description: SQLite and SQL tuning — indexing, EXPLAIN QUERY PLAN, query optimization, WAL mode, common patterns +--- + +# SQL Tuning Skill + +## SQLite Performance Essentials + +### WAL Mode (critical for concurrent reads) +```sql +PRAGMA journal_mode=WAL; -- survives restart +PRAGMA synchronous=NORMAL; -- safe + faster than FULL +PRAGMA cache_size=-32000; -- 32MB page cache +PRAGMA temp_store=MEMORY; +``` + +### EXPLAIN QUERY PLAN +```sql +EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'a@b.com'; +-- Look for: "SCAN TABLE users" (bad) vs "SEARCH TABLE users USING INDEX" (good) +``` + +## Indexing Strategy + +### When to add an index +- Column appears in WHERE, JOIN ON, ORDER BY, GROUP BY +- Column has high cardinality (many distinct values) +- Table has > 10k rows and query runs frequently + +### Common index patterns +```sql +-- Simple index +CREATE INDEX idx_users_email ON users(email); + +-- Composite index (order matters! put equality first, then range) +CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC); + +-- Covering index (includes all columns the query needs) +CREATE INDEX idx_orders_cover ON orders(user_id, status) INCLUDE (total, created_at); + +-- Partial index (for boolean or enum filters) +CREATE INDEX idx_active_users ON users(email) WHERE active = 1; +``` + +### Don't index +- Columns you always scan fully (e.g. boolean with 2 values on a small table) +- Rarely-queried columns on write-heavy tables (indexes slow down INSERT/UPDATE) + +## Query Optimization Patterns + +### N+1 Problem Fix +```sql +-- Bad: SELECT user for each order +-- Good: JOIN them once +SELECT o.id, o.total, u.name +FROM orders o +JOIN users u ON u.id = o.user_id +WHERE o.status = 'pending'; +``` + +### Pagination (use keyset, not OFFSET for large tables) +```sql +-- Bad (slow on large tables): LIMIT 20 OFFSET 10000 +-- Good (keyset pagination): +SELECT * FROM orders +WHERE created_at < ? AND id < ? +ORDER BY created_at DESC, id DESC +LIMIT 20; +``` + +### Batch Inserts +```sql +-- Instead of looping INSERT: +INSERT INTO events (type, payload) VALUES + ('click', '{}'), + ('view', '{}'), + ('submit', '{}'); +-- Or use: BEGIN TRANSACTION + multiple inserts + COMMIT +``` + +### UPSERT +```sql +INSERT INTO settings (key, value) +VALUES ('theme', 'dark') +ON CONFLICT(key) DO UPDATE SET value = excluded.value; +``` + +## SQLite in Go (database/sql) +```go +// Always use prepared statements for repeated queries +stmt, err := db.Prepare("INSERT INTO logs (msg) VALUES (?)") +defer stmt.Close() +stmt.Exec("hello") + +// Transactions for batch operations +tx, _ := db.Begin() +for _, item := range items { + tx.Exec("INSERT INTO ...", item) +} +tx.Commit() + +// Row scanning +row := db.QueryRow("SELECT id, name FROM users WHERE id = ?", id) +var u User +row.Scan(&u.ID, &u.Name) +``` + +## Table Design Checklist +- Every table has an integer PRIMARY KEY (rowid alias in SQLite) +- Timestamps as `INTEGER` (Unix epoch) — faster comparisons than TEXT +- Use `NOT NULL DEFAULT` where possible — avoids NULL handling complexity +- Foreign keys: `PRAGMA foreign_keys = ON;` (off by default in SQLite!) diff --git a/dash/backend/tools/contextplus_native.go b/dash/backend/tools/contextplus_native.go new file mode 100644 index 0000000..e92b2fe --- /dev/null +++ b/dash/backend/tools/contextplus_native.go @@ -0,0 +1,2841 @@ +package tools + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/danilrybalkin/apollo-dash/db" + sitter "github.com/smacker/go-tree-sitter" + sitterbash "github.com/smacker/go-tree-sitter/bash" + sitterc "github.com/smacker/go-tree-sitter/c" + sittercpp "github.com/smacker/go-tree-sitter/cpp" + sittercsharp "github.com/smacker/go-tree-sitter/csharp" + sittergolang "github.com/smacker/go-tree-sitter/golang" + sitterjava "github.com/smacker/go-tree-sitter/java" + sitterjavascript "github.com/smacker/go-tree-sitter/javascript" + sitterkotlin "github.com/smacker/go-tree-sitter/kotlin" + sitterphp "github.com/smacker/go-tree-sitter/php" + sitterpython "github.com/smacker/go-tree-sitter/python" + sitterruby "github.com/smacker/go-tree-sitter/ruby" + sitterrust "github.com/smacker/go-tree-sitter/rust" + sitterscala "github.com/smacker/go-tree-sitter/scala" + sittersql "github.com/smacker/go-tree-sitter/sql" + sitterswift "github.com/smacker/go-tree-sitter/swift" + sittertsx "github.com/smacker/go-tree-sitter/typescript/tsx" + sittertypescript "github.com/smacker/go-tree-sitter/typescript/typescript" + sitteryaml "github.com/smacker/go-tree-sitter/yaml" + "gonum.org/v1/gonum/mat" +) + +var contextCodeExt = map[string]bool{ + ".go": true, ".ts": true, ".tsx": true, ".js": true, ".jsx": true, + ".py": true, ".rs": true, ".java": true, ".kt": true, ".swift": true, + ".c": true, ".h": true, ".cpp": true, ".hpp": true, ".cs": true, + ".php": true, ".rb": true, ".scala": true, ".m": true, ".mm": true, + ".sql": true, ".sh": true, ".yaml": true, ".yml": true, ".json": true, + ".toml": true, +} + +var contextSkipDirs = map[string]bool{ + ".git": true, ".svn": true, ".hg": true, + "node_modules": true, "vendor": true, "dist": true, "build": true, + ".next": true, ".turbo": true, ".idea": true, ".vscode": true, + ".apollo_contextplus": true, ".mcp_data": true, +} + +type contextSymbol struct { + Name string + Kind string + Line int + Signature string +} + +type contextFileEntry struct { + RelPath string + AbsPath string + Hash string + Header string + Symbols []contextSymbol + Content string +} + +type contextSearchCandidate struct { + Entry contextFileEntry + KeywordScore float64 + Semantic float64 + Combined float64 +} + +type contextIdentifierEntry struct { + File contextFileEntry + Symbol contextSymbol + Doc string + Keyword float64 + Semantic float64 + Combined float64 + CallSites []string +} + +type contextRestorePointFile struct { + Path string `json:"path"` + Existed bool `json:"existed"` +} + +type contextRestorePoint struct { + ID string `json:"id"` + Timestamp int64 `json:"timestamp"` + Message string `json:"message"` + Files []contextRestorePointFile `json:"files"` +} + +type contextHub struct { + RelPath string + AbsPath string + Links []string +} + +type contextIndexedFile struct { + RelPath string `json:"rel_path"` + Ext string `json:"ext"` + Lang string `json:"lang"` + Header string `json:"header"` + Snippet string `json:"snippet"` + Symbols []contextSymbol `json:"symbols"` + MTime int64 `json:"mtime"` + Size int64 `json:"size"` + Hash string `json:"hash"` +} + +type contextProjectIndex struct { + Version int `json:"version"` + Root string `json:"root"` + UpdatedAt int64 `json:"updated_at"` + Files map[string]contextIndexedFile `json:"files"` +} + +type contextSemanticNode struct { + ID string + Depth int + FileIndices []int + Children []*contextSemanticNode + Locked bool +} + +var contextLanguageByExt = map[string]string{ + ".go": "go", ".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "javascript", + ".py": "python", ".rs": "rust", ".java": "java", ".kt": "kotlin", ".swift": "swift", + ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp", ".cs": "csharp", + ".php": "php", ".rb": "ruby", ".scala": "scala", ".sql": "sql", ".sh": "bash", + ".yaml": "yaml", ".yml": "yaml", +} + +var ( + contextEmbedMu sync.Mutex + contextEmbedCache = map[string]map[string][]float64{} + contextEmbedLoaded = map[string]bool{} + contextEmbedDirty = map[string]bool{} + contextEmbedSaving = map[string]bool{} + contextEmbedModel = map[string]string{} + contextEmbedBaseURL = map[string]string{} + + contextIndexMu sync.Mutex + contextIndexes = map[string]*contextProjectIndex{} + contextTrackerActive = map[string]bool{} + contextPendingWarm = map[string]map[string]bool{} +) + +func executeGetContextTree(rawArgs string, projectRoot string) string { + var args struct { + TargetPath string `json:"target_path"` + DepthLimit int `json:"depth_limit"` + IncludeSymbols *bool `json:"include_symbols"` + MaxTokens int `json:"max_tokens"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: invalid arguments." + } + + root := contextRoot(projectRoot) + target := root + if strings.TrimSpace(args.TargetPath) != "" { + safePath, err := securePath(args.TargetPath, projectRoot) + if err != nil { + return err.Error() + } + target = safePath + } + if args.MaxTokens <= 0 { + args.MaxTokens = 20000 + } + _ = contextEnsureIndex(root) + + includeSymbols := true + if args.IncludeSymbols != nil { + includeSymbols = *args.IncludeSymbols + } + + levels := []int{2, 1, 0} + if !includeSymbols { + levels = []int{1, 0} + } + + var rendered string + for _, level := range levels { + rendered = contextRenderTree(target, root, args.DepthLimit, level) + if contextEstimateTokens(rendered) <= args.MaxTokens { + break + } + } + + if strings.TrimSpace(rendered) == "" { + return "No files found for context tree." + } + + if len(rendered) > 18000 { + rendered = rendered[:18000] + "\n\n... [CONTEXT TREE TRUNCATED]" + } + return rendered +} + +func executeGetFileSkeleton(rawArgs string, projectRoot string) string { + var args struct { + FilePath string `json:"file_path"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || strings.TrimSpace(args.FilePath) == "" { + return "Error: 'file_path' is required." + } + + safePath, err := securePath(args.FilePath, projectRoot) + if err != nil { + return err.Error() + } + + data, err := os.ReadFile(safePath) + if err != nil { + return fmt.Sprintf("Error reading file: %v", err) + } + + symbols := contextParseSymbolsByExt(strings.ToLower(filepath.Ext(safePath)), string(data)) + if len(symbols) == 0 { + return fmt.Sprintf("No symbols found in %s", args.FilePath) + } + + rel := contextRelativePath(contextRoot(projectRoot), safePath) + var b strings.Builder + b.WriteString(fmt.Sprintf("File Skeleton: %s\n\n", rel)) + for _, s := range symbols { + b.WriteString(fmt.Sprintf("- [%s] %s (line %d)\n %s\n", s.Kind, s.Name, s.Line, s.Signature)) + } + + out := b.String() + if len(out) > 18000 { + out = out[:18000] + "\n\n... [SKELETON TRUNCATED]" + } + return out +} + +func executeGetBlastRadius(rawArgs string, projectRoot string) string { + var args struct { + SymbolName string `json:"symbol_name"` + FileContext string `json:"file_context"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || strings.TrimSpace(args.SymbolName) == "" { + return "Error: 'symbol_name' is required." + } + + entries, err := contextCollectFiles(contextRoot(projectRoot), contextRoot(projectRoot)) + if err != nil { + return fmt.Sprintf("Error indexing files: %v", err) + } + + wordRe := regexp.MustCompile(`\\b` + regexp.QuoteMeta(args.SymbolName) + `\\b`) + exclude := strings.TrimSpace(args.FileContext) + if exclude != "" { + exclude = filepath.Clean(strings.TrimPrefix(exclude, "/")) + } + + var hits []string + for _, e := range entries { + content, err := os.ReadFile(e.AbsPath) + if err != nil { + continue + } + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if !wordRe.MatchString(line) { + continue + } + if exclude != "" && filepath.Clean(e.RelPath) == exclude { + symbols := e.Symbols + isDefinition := false + for _, s := range symbols { + if s.Name == args.SymbolName && s.Line == i+1 { + isDefinition = true + break + } + } + if isDefinition { + continue + } + } + hits = append(hits, fmt.Sprintf("%s:%d: %s", e.RelPath, i+1, strings.TrimSpace(line))) + if len(hits) >= 400 { + hits = append(hits, "... [ADDITIONAL REFERENCES TRUNCATED]") + break + } + } + if len(hits) >= 401 { + break + } + } + + if len(hits) == 0 { + return fmt.Sprintf("No references found for symbol '%s'.", args.SymbolName) + } + + return fmt.Sprintf("Blast Radius for '%s' (%d hits):\n\n%s", args.SymbolName, len(hits), strings.Join(hits, "\n")) +} + +func executeRunStaticAnalysisNative(rawArgs string, projectRoot string) string { + var args struct { + TargetPath string `json:"target_path"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: invalid arguments." + } + + root := contextRoot(projectRoot) + target := root + if strings.TrimSpace(args.TargetPath) != "" { + safePath, err := securePath(args.TargetPath, projectRoot) + if err != nil { + return err.Error() + } + target = safePath + } + + langs := contextDetectLanguages(target) + if len(langs) == 0 { + return "No supported source files found for static analysis." + } + + var reports []string + if langs["go"] && contextPathExists(filepath.Join(root, "go.mod")) { + reports = append(reports, contextRunCheckCommand(root, "go vet", "go", "vet", "./...")) + } + if (langs["typescript"] || langs["javascript"]) && contextPathExists(filepath.Join(root, "package.json")) { + if langs["typescript"] && contextPathExists(filepath.Join(root, "tsconfig.json")) { + reports = append(reports, contextRunCheckCommand(root, "tsc --noEmit", "npx", "tsc", "--noEmit", "--pretty", "false")) + } + if contextPathExists(filepath.Join(root, "eslint.config.js")) || contextPathExists(filepath.Join(root, ".eslintrc")) || contextPathExists(filepath.Join(root, ".eslintrc.js")) { + reports = append(reports, contextRunCheckCommand(root, "eslint", "npx", "eslint", ".", "--format", "compact")) + } + } + if langs["python"] { + pyFiles := contextFindFilesByExt(target, ".py", 80) + if len(pyFiles) > 0 { + args := []string{"-m", "py_compile"} + args = append(args, pyFiles...) + reports = append(reports, contextRunCheckCommand(root, "python -m py_compile", "python3", args...)) + } + } + if langs["rust"] && contextPathExists(filepath.Join(root, "Cargo.toml")) { + reports = append(reports, contextRunCheckCommand(root, "cargo check", "cargo", "check", "--message-format=short")) + } + + if len(reports) == 0 { + return "No runnable static analyzers detected for this project path." + } + + out := strings.Join(reports, "\n\n") + if len(out) > 18000 { + out = out[:18000] + "\n\n... [STATIC ANALYSIS OUTPUT TRUNCATED]" + } + return out +} + +func executeSemanticCodeSearch(rawArgs string, projectRoot string) string { + var args struct { + Query string `json:"query"` + TopK int `json:"top_k"` + SemanticWeight *float64 `json:"semantic_weight"` + KeywordWeight *float64 `json:"keyword_weight"` + MinSemanticScore *float64 `json:"min_semantic_score"` + MinKeywordScore *float64 `json:"min_keyword_score"` + MinCombinedScore *float64 `json:"min_combined_score"` + RequireKeywordMatch *bool `json:"require_keyword_match"` + RequireSemanticMatch *bool `json:"require_semantic_match"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || strings.TrimSpace(args.Query) == "" { + return "Error: 'query' is required." + } + + if args.TopK <= 0 { + args.TopK = 5 + } + semW := 0.72 + kwW := 0.28 + if args.SemanticWeight != nil { + semW = *args.SemanticWeight + } + if args.KeywordWeight != nil { + kwW = *args.KeywordWeight + } + if semW < 0 { + semW = 0 + } + if kwW < 0 { + kwW = 0 + } + if semW == 0 && kwW == 0 { + semW = 0.72 + kwW = 0.28 + } + + root := contextRoot(projectRoot) + entries, err := contextCollectFiles(root, root) + if err != nil { + return fmt.Sprintf("Error indexing project: %v", err) + } + if len(entries) == 0 { + return "No code files found for semantic search." + } + + queryWords := contextSplitWords(args.Query) + candidates := make([]contextSearchCandidate, 0, len(entries)) + for _, e := range entries { + doc := contextFileDocForSearch(e) + kwScore := contextKeywordScore(queryWords, doc) + candidates = append(candidates, contextSearchCandidate{Entry: e, KeywordScore: kwScore}) + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].KeywordScore == candidates[j].KeywordScore { + return candidates[i].Entry.RelPath < candidates[j].Entry.RelPath + } + return candidates[i].KeywordScore > candidates[j].KeywordScore + }) + + poolSize := 80 + if len(candidates) < poolSize { + poolSize = len(candidates) + } + pool := candidates[:poolSize] + + queryVec, queryErr := contextEmbedding(root, "query:"+args.Query) + for i := range pool { + doc := contextFileDocForSearch(pool[i].Entry) + if queryErr == nil && len(queryVec) > 0 { + docVec, err := contextEmbedding(root, fmt.Sprintf("file:%s:%s:%s", pool[i].Entry.RelPath, pool[i].Entry.Hash, doc)) + if err == nil { + pool[i].Semantic = contextCosine(queryVec, docVec) + } + } + pool[i].Combined = semW*pool[i].Semantic + kwW*pool[i].KeywordScore + } + + minSemantic := contextNormalizeThreshold(args.MinSemanticScore) + minKeyword := contextNormalizeThreshold(args.MinKeywordScore) + minCombined := contextNormalizeThreshold(args.MinCombinedScore) + requireKeyword := args.RequireKeywordMatch != nil && *args.RequireKeywordMatch + requireSemantic := args.RequireSemanticMatch != nil && *args.RequireSemanticMatch + + filtered := make([]contextSearchCandidate, 0, len(pool)) + for _, c := range pool { + if c.Semantic < minSemantic { + continue + } + if c.KeywordScore < minKeyword { + continue + } + if c.Combined < minCombined { + continue + } + if requireKeyword && c.KeywordScore <= 0 { + continue + } + if requireSemantic && c.Semantic <= 0 { + continue + } + filtered = append(filtered, c) + } + + sort.Slice(filtered, func(i, j int) bool { + if filtered[i].Combined == filtered[j].Combined { + return filtered[i].Entry.RelPath < filtered[j].Entry.RelPath + } + return filtered[i].Combined > filtered[j].Combined + }) + + if len(filtered) == 0 { + return "No semantic matches found." + } + if len(filtered) > args.TopK { + filtered = filtered[:args.TopK] + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("Semantic Code Search: %s\n\n", args.Query)) + for i, c := range filtered { + b.WriteString(fmt.Sprintf("%d. %s\n", i+1, c.Entry.RelPath)) + b.WriteString(fmt.Sprintf(" combined=%.3f semantic=%.3f keyword=%.3f\n", c.Combined, c.Semantic, c.KeywordScore)) + if c.Entry.Header != "" { + b.WriteString(fmt.Sprintf(" header: %s\n", c.Entry.Header)) + } + if len(c.Entry.Symbols) > 0 { + maxSymbols := 4 + if len(c.Entry.Symbols) < maxSymbols { + maxSymbols = len(c.Entry.Symbols) + } + for si := 0; si < maxSymbols; si++ { + s := c.Entry.Symbols[si] + b.WriteString(fmt.Sprintf(" symbol: %s (%s) line %d\n", s.Name, s.Kind, s.Line)) + } + } + b.WriteString("\n") + } + + out := b.String() + if len(out) > 18000 { + out = out[:18000] + "\n\n... [SEARCH OUTPUT TRUNCATED]" + } + return out +} + +func executeSemanticIdentifierSearch(rawArgs string, projectRoot string) string { + var args struct { + Query string `json:"query"` + TopK int `json:"top_k"` + TopCallsPerIdentifier int `json:"top_calls_per_identifier"` + IncludeKinds []string `json:"include_kinds"` + SemanticWeight *float64 `json:"semantic_weight"` + KeywordWeight *float64 `json:"keyword_weight"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || strings.TrimSpace(args.Query) == "" { + return "Error: 'query' is required." + } + if args.TopK <= 0 { + args.TopK = 5 + } + if args.TopCallsPerIdentifier <= 0 { + args.TopCallsPerIdentifier = 10 + } + + semW := 0.78 + kwW := 0.22 + if args.SemanticWeight != nil { + semW = *args.SemanticWeight + } + if args.KeywordWeight != nil { + kwW = *args.KeywordWeight + } + + allowedKinds := map[string]bool{} + for _, k := range args.IncludeKinds { + allowedKinds[strings.ToLower(strings.TrimSpace(k))] = true + } + + root := contextRoot(projectRoot) + entries, err := contextCollectFiles(root, root) + if err != nil { + return fmt.Sprintf("Error indexing project: %v", err) + } + + var identifiers []contextIdentifierEntry + queryWords := contextSplitWords(args.Query) + for _, file := range entries { + for _, sym := range file.Symbols { + if len(allowedKinds) > 0 && !allowedKinds[strings.ToLower(sym.Kind)] { + continue + } + doc := fmt.Sprintf("%s %s %s %s %s", sym.Name, sym.Kind, sym.Signature, file.Header, file.RelPath) + kw := contextKeywordScore(queryWords, doc) + identifiers = append(identifiers, contextIdentifierEntry{ + File: file, + Symbol: sym, + Doc: doc, + Keyword: kw, + Combined: kwW * kw, + }) + } + } + + if len(identifiers) == 0 { + return "No identifiers found for semantic search." + } + + sort.Slice(identifiers, func(i, j int) bool { + if identifiers[i].Keyword == identifiers[j].Keyword { + return identifiers[i].Symbol.Name < identifiers[j].Symbol.Name + } + return identifiers[i].Keyword > identifiers[j].Keyword + }) + + poolSize := 120 + if len(identifiers) < poolSize { + poolSize = len(identifiers) + } + pool := identifiers[:poolSize] + + queryVec, queryErr := contextEmbedding(root, "identifier-query:"+args.Query) + for i := range pool { + if queryErr == nil && len(queryVec) > 0 { + v, err := contextEmbedding( + root, + fmt.Sprintf("identifier:%s:%d:%s:%s", pool[i].File.RelPath, pool[i].Symbol.Line, pool[i].File.Hash, pool[i].Doc), + ) + if err == nil { + pool[i].Semantic = contextCosine(queryVec, v) + } + } + pool[i].Combined = semW*pool[i].Semantic + kwW*pool[i].Keyword + } + + sort.Slice(pool, func(i, j int) bool { + if pool[i].Combined == pool[j].Combined { + return pool[i].File.RelPath < pool[j].File.RelPath + } + return pool[i].Combined > pool[j].Combined + }) + + if len(pool) > args.TopK { + pool = pool[:args.TopK] + } + + allFiles := entries + for i := range pool { + pool[i].CallSites = contextFindCallSites(allFiles, pool[i].Symbol.Name, pool[i].File.RelPath, pool[i].Symbol.Line, args.TopCallsPerIdentifier) + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("Semantic Identifier Search: %s\n\n", args.Query)) + for i, r := range pool { + b.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, r.Symbol.Name, r.Symbol.Kind)) + b.WriteString(fmt.Sprintf(" file: %s:%d\n", r.File.RelPath, r.Symbol.Line)) + b.WriteString(fmt.Sprintf(" score: combined=%.3f semantic=%.3f keyword=%.3f\n", r.Combined, r.Semantic, r.Keyword)) + b.WriteString(fmt.Sprintf(" signature: %s\n", r.Symbol.Signature)) + if len(r.CallSites) > 0 { + b.WriteString(" call sites:\n") + for _, cs := range r.CallSites { + b.WriteString(" - " + cs + "\n") + } + } + b.WriteString("\n") + } + return b.String() +} + +func executeSemanticNavigate(rawArgs string, projectRoot string) string { + var args struct { + MaxDepth int `json:"max_depth"` + MaxClusters int `json:"max_clusters"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: invalid arguments." + } + if args.MaxDepth <= 0 { + args.MaxDepth = 3 + } + if args.MaxClusters <= 0 { + args.MaxClusters = 20 + } + + root := contextRoot(projectRoot) + entries, err := contextCollectFiles(root, root) + if err != nil { + return fmt.Sprintf("Error indexing project: %v", err) + } + if len(entries) == 0 { + return "No files found for semantic navigation." + } + + vectors := contextBuildSemanticVectors(root, entries) + if len(vectors) != len(entries) || len(vectors) == 0 { + return "Unable to build semantic vectors for navigation." + } + + const maxSpectralFiles = 220 + coreIndices := make([]int, 0, len(entries)) + for i := range entries { + coreIndices = append(coreIndices, i) + } + sampled := false + if len(coreIndices) > maxSpectralFiles { + coreIndices = contextSelectDiverseIndices(vectors, maxSpectralFiles) + sampled = true + } + + tree := &contextSemanticNode{ + ID: "1", + Depth: 1, + FileIndices: append([]int(nil), coreIndices...), + } + leafCount := 1 + for leafCount < args.MaxClusters { + candidate := contextSelectSplitCandidate(tree, args.MaxDepth) + if candidate == nil { + break + } + left, right, ok := contextSpectralBisect(vectors, candidate.FileIndices) + if !ok { + candidate.Locked = true + continue + } + candidate.Children = []*contextSemanticNode{ + {ID: candidate.ID + ".1", Depth: candidate.Depth + 1, FileIndices: left}, + {ID: candidate.ID + ".2", Depth: candidate.Depth + 1, FileIndices: right}, + } + leafCount++ + } + + if sampled { + coreSet := map[int]bool{} + for _, idx := range coreIndices { + coreSet[idx] = true + } + leaves := contextCollectLeafNodes(tree) + centroids := make([][]float64, len(leaves)) + for i, leaf := range leaves { + centroids[i] = contextCentroidForIndices(vectors, leaf.FileIndices) + } + for idx := range entries { + if coreSet[idx] { + continue + } + bestLeaf := 0 + bestScore := -2.0 + for li := range leaves { + score := contextCosine(vectors[idx], centroids[li]) + if score > bestScore { + bestScore = score + bestLeaf = li + } + } + leaves[bestLeaf].FileIndices = append(leaves[bestLeaf].FileIndices, idx) + } + } + + for _, leaf := range contextCollectLeafNodes(tree) { + sort.Ints(leaf.FileIndices) + } + + var b strings.Builder + b.WriteString("Semantic Navigate (spectral clustering)\n\n") + b.WriteString(fmt.Sprintf("files: %d", len(entries))) + if sampled { + b.WriteString(fmt.Sprintf(", spectral-core: %d", len(coreIndices))) + } + b.WriteString(fmt.Sprintf(", depth<=%d, max_clusters=%d\n\n", args.MaxDepth, args.MaxClusters)) + contextRenderSemanticNode(&b, tree, entries, 0) + + out := b.String() + if len(out) > 18000 { + out = out[:18000] + "\n\n... [SEMANTIC NAVIGATION TRUNCATED]" + } + return out +} + +func executeGetFeatureHub(rawArgs string, projectRoot string) string { + var args struct { + HubPath string `json:"hub_path"` + Feature string `json:"feature_name"` + ShowOrphan *bool `json:"show_orphans"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: invalid arguments." + } + + root := contextRoot(projectRoot) + hubs, err := contextCollectHubs(root) + if err != nil { + return fmt.Sprintf("Error loading hubs: %v", err) + } + + if args.ShowOrphan != nil && *args.ShowOrphan { + orphans := contextFindHubOrphans(root, hubs) + if len(orphans) == 0 { + return "No orphaned source files found." + } + if len(orphans) > 300 { + orphans = append(orphans[:300], "... [ADDITIONAL ORPHANS TRUNCATED]") + } + return "Orphan Source Files:\n\n" + strings.Join(orphans, "\n") + } + + if strings.TrimSpace(args.HubPath) == "" && strings.TrimSpace(args.Feature) == "" { + if len(hubs) == 0 { + return "No feature hubs found (.md files with [[wikilinks]])." + } + var lines []string + for _, h := range hubs { + lines = append(lines, fmt.Sprintf("- %s (%d links)", h.RelPath, len(h.Links))) + } + sort.Strings(lines) + return "Feature Hubs:\n\n" + strings.Join(lines, "\n") + } + + selected := contextSelectHub(root, hubs, strings.TrimSpace(args.HubPath), strings.TrimSpace(args.Feature)) + if selected == nil { + return "No matching feature hub found." + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("Feature Hub: %s\n\n", selected.RelPath)) + if len(selected.Links) == 0 { + b.WriteString("No wikilinks found in this hub.") + return b.String() + } + for _, l := range selected.Links { + resolved := contextResolveHubLink(root, selected.AbsPath, l) + if resolved == "" { + b.WriteString(fmt.Sprintf("- %s (unresolved)\n", l)) + continue + } + rel := contextRelativePath(root, resolved) + b.WriteString(fmt.Sprintf("- %s\n", rel)) + if data, err := os.ReadFile(resolved); err == nil { + syms := contextParseSymbolsByExt(strings.ToLower(filepath.Ext(resolved)), string(data)) + if len(syms) > 0 { + maxSymbols := 4 + if len(syms) < maxSymbols { + maxSymbols = len(syms) + } + for i := 0; i < maxSymbols; i++ { + s := syms[i] + b.WriteString(fmt.Sprintf(" - %s (%s) line %d\n", s.Name, s.Kind, s.Line)) + } + } + } + } + + out := b.String() + if len(out) > 18000 { + out = out[:18000] + "\n\n... [FEATURE HUB OUTPUT TRUNCATED]" + } + return out +} + +func executeProposeCommitNative(rawArgs string, projectRoot string) string { + var args struct { + FilePath string `json:"file_path"` + NewContent string `json:"new_content"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || strings.TrimSpace(args.FilePath) == "" { + return "Error: 'file_path' and 'new_content' are required." + } + if strings.TrimSpace(args.NewContent) == "" { + return "Error: new content is empty." + } + + lineCount := strings.Count(args.NewContent, "\n") + 1 + if lineCount > 5000 { + return fmt.Sprintf("Error: file has %d lines; exceeds safety limit of 5000.", lineCount) + } + + root := contextRoot(projectRoot) + safePath, err := securePath(args.FilePath, projectRoot) + if err != nil { + return err.Error() + } + rel := contextRelativePath(root, safePath) + + rpID, err := contextCreateRestorePoint(root, []string{rel}, "propose_commit before write") + if err != nil { + return fmt.Sprintf("Error creating restore point: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(safePath), 0o755); err != nil { + return fmt.Sprintf("Error preparing directory: %v", err) + } + if err := os.WriteFile(safePath, []byte(args.NewContent), 0o644); err != nil { + return fmt.Sprintf("Error writing file: %v", err) + } + _ = contextEnsureIndex(root) + + warnings := contextValidateCommitContent(args.NewContent) + if warnings != "" { + return fmt.Sprintf("Saved %s\nRestore point: %s\n\nValidation warnings:\n%s", rel, rpID, warnings) + } + return fmt.Sprintf("Saved %s\nRestore point: %s", rel, rpID) +} + +func executeListRestorePointsNative(rawArgs string, projectRoot string) string { + root := contextRoot(projectRoot) + points, err := contextListRestorePoints(root) + if err != nil { + return fmt.Sprintf("Error listing restore points: %v", err) + } + if len(points) == 0 { + return "No restore points found." + } + + var lines []string + for _, p := range points { + var fileNames []string + for _, f := range p.Files { + fileNames = append(fileNames, f.Path) + } + lines = append(lines, fmt.Sprintf("%s | %s | %s | %s", p.ID, time.UnixMilli(p.Timestamp).Format(time.RFC3339), strings.Join(fileNames, ", "), p.Message)) + } + return fmt.Sprintf("Restore Points (%d):\n\n%s", len(lines), strings.Join(lines, "\n")) +} + +func executeUndoChangeNative(rawArgs string, projectRoot string) string { + var args struct { + PointID string `json:"point_id"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || strings.TrimSpace(args.PointID) == "" { + return "Error: 'point_id' is required." + } + + root := contextRoot(projectRoot) + restored, err := contextRestorePointByID(root, args.PointID) + if err != nil { + return fmt.Sprintf("Error restoring point: %v", err) + } + _ = contextEnsureIndex(root) + if len(restored) == 0 { + return "No files restored." + } + return fmt.Sprintf("Restored %d file(s):\n%s", len(restored), strings.Join(restored, "\n")) +} + +func contextRoot(projectRoot string) string { + if strings.TrimSpace(projectRoot) != "" { + return projectRoot + } + return WorkspaceDir +} + +func contextEstimateTokens(s string) int { + if s == "" { + return 0 + } + return (len(s) / 4) + 1 +} + +func contextRenderTree(target string, root string, depthLimit int, level int) string { + info, err := os.Stat(target) + if err != nil { + return "" + } + + var b strings.Builder + if info.IsDir() { + rel := contextRelativePath(root, target) + if rel == "." || rel == "" { + rel = "/" + } + b.WriteString(fmt.Sprintf("Context Tree (%s)\n\n", rel)) + contextRenderTreeDir(&b, target, root, 0, depthLimit, level) + } else { + rel := contextRelativePath(root, target) + b.WriteString(fmt.Sprintf("Context Tree (%s)\n\n", rel)) + contextRenderTreeFile(&b, target, rel, root, level, "") + } + return b.String() +} + +func contextRenderTreeDir(b *strings.Builder, dir string, root string, depth int, depthLimit int, level int) { + if depthLimit > 0 && depth > depthLimit { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") && name != ".env" && name != ".github" { + if contextSkipDirs[name] { + continue + } + } + if e.IsDir() { + if contextSkipDirs[name] { + continue + } + indent := strings.Repeat(" ", depth) + b.WriteString(fmt.Sprintf("%s- %s/\n", indent, name)) + contextRenderTreeDir(b, filepath.Join(dir, name), root, depth+1, depthLimit, level) + continue + } + + abs := filepath.Join(dir, name) + ext := strings.ToLower(filepath.Ext(abs)) + if !contextCodeExt[ext] && ext != ".md" { + continue + } + rel := contextRelativePath(root, abs) + indent := strings.Repeat(" ", depth) + contextRenderTreeFile(b, abs, rel, root, level, indent) + } +} + +func contextRenderTreeFile(b *strings.Builder, abs string, rel string, root string, level int, indent string) { + if level <= 0 { + b.WriteString(fmt.Sprintf("%s- %s\n", indent, rel)) + return + } + + header := "" + var symbols []contextSymbol + if idxFile, ok := contextGetIndexedFile(root, rel); ok { + header = idxFile.Header + symbols = idxFile.Symbols + } + if header == "" || (level > 1 && len(symbols) == 0) { + data, err := os.ReadFile(abs) + if err == nil { + content := string(data) + header = contextFileHeader(content) + if level > 1 { + symbols = contextParseSymbolsByExt(strings.ToLower(filepath.Ext(abs)), content) + } + } + } + if header != "" { + b.WriteString(fmt.Sprintf("%s- %s :: %s\n", indent, rel, header)) + } else { + b.WriteString(fmt.Sprintf("%s- %s\n", indent, rel)) + } + if level <= 1 { + return + } + + maxSymbols := 8 + if len(symbols) < maxSymbols { + maxSymbols = len(symbols) + } + for i := 0; i < maxSymbols; i++ { + s := symbols[i] + b.WriteString(fmt.Sprintf("%s - %s %s:%d\n", indent, s.Kind, s.Name, s.Line)) + } +} + +func contextCollectFiles(root string, target string) ([]contextFileEntry, error) { + if err := contextEnsureIndex(root); err != nil { + return nil, err + } + + targetAbs := filepath.Clean(target) + if targetAbs == "" { + targetAbs = root + } + + contextIndexMu.Lock() + idx := contextIndexes[root] + if idx == nil { + contextIndexMu.Unlock() + return []contextFileEntry{}, nil + } + entries := make([]contextFileEntry, 0, len(idx.Files)) + for rel, f := range idx.Files { + abs := filepath.Join(root, rel) + if !contextPathWithinTarget(abs, targetAbs) { + continue + } + entries = append(entries, contextFileEntry{ + RelPath: rel, + AbsPath: abs, + Hash: f.Hash, + Header: f.Header, + Symbols: f.Symbols, + Content: f.Snippet, + }) + } + contextIndexMu.Unlock() + + sort.Slice(entries, func(i, j int) bool { + return entries[i].RelPath < entries[j].RelPath + }) + return entries, nil +} + +func contextLooksText(content string) bool { + if content == "" { + return true + } + if strings.IndexByte(content, 0) >= 0 { + return false + } + return true +} + +func contextFileHeader(content string) string { + lines := strings.Split(content, "\n") + parts := make([]string, 0, 2) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts = append(parts, line) + if len(parts) == 2 { + break + } + } + if len(parts) == 0 { + return "" + } + header := strings.Join(parts, " | ") + if len(header) > 160 { + header = header[:160] + } + return header +} + +func contextParseSymbolsByExt(ext string, content string) []contextSymbol { + tsSymbols, ok := contextParseSymbolsByTreeSitter(ext, content) + regexSymbols := contextParseSymbolsRegexByExt(ext, content) + if ok && len(tsSymbols) > 0 { + return contextMergeSymbols(tsSymbols, regexSymbols) + } + return regexSymbols +} + +func contextParseSymbolsRegexByExt(ext string, content string) []contextSymbol { + lines := strings.Split(content, "\n") + var out []contextSymbol + + appendMatch := func(line string, idx int, kind string, re *regexp.Regexp) { + m := re.FindStringSubmatch(line) + if len(m) < 2 { + return + } + sig := strings.TrimSpace(line) + if len(sig) > 200 { + sig = sig[:200] + } + out = append(out, contextSymbol{Name: m[1], Kind: kind, Line: idx + 1, Signature: sig}) + } + + for i, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + + switch ext { + case ".go": + appendMatch(line, i, "function", regexp.MustCompile(`^func\\s+(?:\\([^)]*\\)\\s*)?([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`)) + appendMatch(line, i, "type", regexp.MustCompile(`^type\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+(?:struct|interface)\\b`)) + appendMatch(line, i, "const", regexp.MustCompile(`^const\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + appendMatch(line, i, "variable", regexp.MustCompile(`^var\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + case ".ts", ".tsx", ".js", ".jsx": + appendMatch(line, i, "function", regexp.MustCompile(`^function\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`)) + appendMatch(line, i, "function", regexp.MustCompile(`^(?:export\\s+)?const\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:async\\s*)?\\(`)) + appendMatch(line, i, "class", regexp.MustCompile(`^class\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + appendMatch(line, i, "class", regexp.MustCompile(`^export\\s+class\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + appendMatch(line, i, "interface", regexp.MustCompile(`^interface\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + appendMatch(line, i, "type", regexp.MustCompile(`^type\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=`)) + case ".py": + appendMatch(line, i, "function", regexp.MustCompile(`^def\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`)) + appendMatch(line, i, "class", regexp.MustCompile(`^class\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + case ".rs": + appendMatch(line, i, "function", regexp.MustCompile(`^fn\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`)) + appendMatch(line, i, "struct", regexp.MustCompile(`^struct\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + appendMatch(line, i, "enum", regexp.MustCompile(`^enum\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + appendMatch(line, i, "impl", regexp.MustCompile(`^impl\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + default: + appendMatch(line, i, "function", regexp.MustCompile(`^(?:public|private|protected|static|async|final|export)?\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`)) + appendMatch(line, i, "class", regexp.MustCompile(`^class\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`)) + } + } + + if len(out) > 400 { + out = out[:400] + } + return out +} + +func contextParseSymbolsByTreeSitter(ext string, content string) ([]contextSymbol, bool) { + lang := contextTreeSitterLanguage(ext) + if lang == nil { + return nil, false + } + source := []byte(content) + parser := sitter.NewParser() + defer parser.Close() + parser.SetLanguage(lang) + + tree, err := parser.ParseCtx(context.Background(), nil, source) + if err != nil || tree == nil { + return nil, false + } + defer tree.Close() + + root := tree.RootNode() + if root == nil { + return nil, true + } + + var symbols []contextSymbol + contextCollectTreeSitterSymbols(root, source, ext, &symbols) + if len(symbols) > 400 { + symbols = symbols[:400] + } + return symbols, true +} + +func contextCollectTreeSitterSymbols(node *sitter.Node, source []byte, ext string, out *[]contextSymbol) { + if node == nil { + return + } + kind, name := contextExtractTreeSitterSymbol(node, source, ext) + if name != "" { + lineText := contextLineByNumber(string(source), int(node.StartPoint().Row)+1) + if len(lineText) > 220 { + lineText = lineText[:220] + } + *out = append(*out, contextSymbol{ + Name: name, + Kind: kind, + Line: int(node.StartPoint().Row) + 1, + Signature: strings.TrimSpace(lineText), + }) + } + for i := uint32(0); i < node.NamedChildCount(); i++ { + contextCollectTreeSitterSymbols(node.NamedChild(int(i)), source, ext, out) + } +} + +func contextExtractTreeSitterSymbol(node *sitter.Node, source []byte, ext string) (string, string) { + nodeType := node.Type() + switch ext { + case ".go": + switch nodeType { + case "function_declaration", "method_declaration": + if n := node.ChildByFieldName("name"); n != nil { + return "function", strings.TrimSpace(n.Content(source)) + } + case "type_spec": + if n := node.ChildByFieldName("name"); n != nil { + return "type", strings.TrimSpace(n.Content(source)) + } + case "const_spec": + if n := node.ChildByFieldName("name"); n != nil { + return "const", strings.TrimSpace(n.Content(source)) + } + case "var_spec": + if n := node.ChildByFieldName("name"); n != nil { + return "variable", strings.TrimSpace(n.Content(source)) + } + } + case ".ts", ".tsx", ".js", ".jsx": + switch nodeType { + case "function_declaration", "generator_function_declaration": + if n := node.ChildByFieldName("name"); n != nil { + return "function", strings.TrimSpace(n.Content(source)) + } + case "method_definition": + if n := node.ChildByFieldName("name"); n != nil { + return "method", strings.TrimSpace(n.Content(source)) + } + case "class_declaration": + if n := node.ChildByFieldName("name"); n != nil { + return "class", strings.TrimSpace(n.Content(source)) + } + case "interface_declaration": + if n := node.ChildByFieldName("name"); n != nil { + return "interface", strings.TrimSpace(n.Content(source)) + } + case "type_alias_declaration": + if n := node.ChildByFieldName("name"); n != nil { + return "type", strings.TrimSpace(n.Content(source)) + } + case "variable_declarator": + n := node.ChildByFieldName("name") + v := node.ChildByFieldName("value") + if n != nil && v != nil { + vt := v.Type() + if vt == "arrow_function" || vt == "function_expression" || vt == "generator_function" || vt == "method_definition" { + return "function", strings.TrimSpace(n.Content(source)) + } + } + } + case ".py": + switch nodeType { + case "function_definition": + if n := node.ChildByFieldName("name"); n != nil { + return "function", strings.TrimSpace(n.Content(source)) + } + case "class_definition": + if n := node.ChildByFieldName("name"); n != nil { + return "class", strings.TrimSpace(n.Content(source)) + } + } + case ".rs": + switch nodeType { + case "function_item": + if n := node.ChildByFieldName("name"); n != nil { + return "function", strings.TrimSpace(n.Content(source)) + } + case "struct_item": + if n := node.ChildByFieldName("name"); n != nil { + return "struct", strings.TrimSpace(n.Content(source)) + } + case "enum_item": + if n := node.ChildByFieldName("name"); n != nil { + return "enum", strings.TrimSpace(n.Content(source)) + } + case "trait_item": + if n := node.ChildByFieldName("name"); n != nil { + return "trait", strings.TrimSpace(n.Content(source)) + } + } + default: + switch nodeType { + case "function_definition", "function_declaration", "method_definition", "class_declaration": + if n := node.ChildByFieldName("name"); n != nil { + kind := "function" + if nodeType == "class_declaration" { + kind = "class" + } else if nodeType == "method_definition" { + kind = "method" + } + return kind, strings.TrimSpace(n.Content(source)) + } + } + } + return "", "" +} + +func contextTreeSitterLanguage(ext string) *sitter.Language { + switch ext { + case ".go": + return sittergolang.GetLanguage() + case ".ts": + return sittertypescript.GetLanguage() + case ".tsx": + return sittertsx.GetLanguage() + case ".js", ".jsx": + return sitterjavascript.GetLanguage() + case ".py": + return sitterpython.GetLanguage() + case ".rs": + return sitterrust.GetLanguage() + case ".java": + return sitterjava.GetLanguage() + case ".kt": + return sitterkotlin.GetLanguage() + case ".swift": + return sitterswift.GetLanguage() + case ".c", ".h": + return sitterc.GetLanguage() + case ".cpp", ".hpp": + return sittercpp.GetLanguage() + case ".cs": + return sittercsharp.GetLanguage() + case ".php": + return sitterphp.GetLanguage() + case ".rb": + return sitterruby.GetLanguage() + case ".scala": + return sitterscala.GetLanguage() + case ".sql": + return sittersql.GetLanguage() + case ".sh": + return sitterbash.GetLanguage() + case ".yaml", ".yml": + return sitteryaml.GetLanguage() + default: + return nil + } +} + +func contextMergeSymbols(primary []contextSymbol, fallback []contextSymbol) []contextSymbol { + seen := map[string]bool{} + out := make([]contextSymbol, 0, len(primary)+len(fallback)) + for _, s := range primary { + key := strings.ToLower(fmt.Sprintf("%s|%s|%d", s.Kind, s.Name, s.Line)) + if seen[key] { + continue + } + seen[key] = true + out = append(out, s) + } + for _, s := range fallback { + key := strings.ToLower(fmt.Sprintf("%s|%s|%d", s.Kind, s.Name, s.Line)) + if seen[key] { + continue + } + seen[key] = true + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Line == out[j].Line { + return out[i].Name < out[j].Name + } + return out[i].Line < out[j].Line + }) + if len(out) > 400 { + out = out[:400] + } + return out +} + +func contextFileDocForSearch(e contextFileEntry) string { + var symbolNames []string + for i, s := range e.Symbols { + if i >= 16 { + break + } + symbolNames = append(symbolNames, s.Name) + } + prefix := e.Content + if len(prefix) > 1200 { + prefix = prefix[:1200] + } + return strings.ToLower(strings.Join([]string{e.RelPath, e.Header, strings.Join(symbolNames, " "), prefix}, "\n")) +} + +func contextSplitWords(s string) []string { + s = strings.ToLower(s) + re := regexp.MustCompile(`[a-z0-9_]+`) + words := re.FindAllString(s, -1) + seen := map[string]bool{} + var out []string + for _, w := range words { + if len(w) < 2 || seen[w] { + continue + } + seen[w] = true + out = append(out, w) + } + return out +} + +func contextKeywordScore(queryWords []string, doc string) float64 { + if len(queryWords) == 0 || doc == "" { + return 0 + } + doc = strings.ToLower(doc) + hits := 0.0 + for _, w := range queryWords { + if strings.Contains(doc, w) { + hits += 1.0 + } + } + return hits / float64(len(queryWords)) +} + +func contextNormalizeThreshold(v *float64) float64 { + if v == nil { + return 0 + } + value := *v + if value > 1 { + value = value / 100.0 + } + if value < 0 { + return 0 + } + if value > 1 { + return 1 + } + return value +} + +func contextCosine(a []float64, b []float64) float64 { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return 0 + } + dot := 0.0 + normA := 0.0 + normB := 0.0 + for i := 0; i < len(a); i++ { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + if normA == 0 || normB == 0 { + return 0 + } + return dot / (math.Sqrt(normA) * math.Sqrt(normB)) +} + +func contextEmbedding(root string, text string) ([]float64, error) { + if strings.TrimSpace(text) == "" { + return nil, fmt.Errorf("empty text") + } + + baseURL := contextGetSetting("embedding_api_url", "http://localhost:11434") + model := contextGetSetting("embedding_model", "nomic-embed-text") + cacheKey := contextEmbeddingKey(baseURL, model, text) + + contextEmbedMu.Lock() + contextEmbedModel[root] = model + contextEmbedBaseURL[root] = baseURL + if !contextEmbedLoaded[root] { + contextEmbedCache[root] = contextLoadEmbeddingCache(root) + contextEmbedLoaded[root] = true + } + rootCache := contextEmbedCache[root] + if v, ok := rootCache[cacheKey]; ok { + contextEmbedMu.Unlock() + return v, nil + } + contextEmbedMu.Unlock() + + isOpenAICompatible := strings.Contains(baseURL, "/v1") || strings.Contains(baseURL, "openrouter.ai") + endpoint := strings.TrimRight(baseURL, "/") + "/api/embeddings" + payload := map[string]interface{}{"model": model, "prompt": text} + if isOpenAICompatible { + endpoint = strings.TrimRight(baseURL, "/") + "/embeddings" + payload = map[string]interface{}{"model": model, "input": text} + } + b, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", endpoint, bytes.NewBuffer(b)) + req.Header.Set("Content-Type", "application/json") + if isOpenAICompatible { + apiKey := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) + if apiKey == "" || apiKey == "your_openrouter_api_key_here" { + apiKey = strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + } + client := &http.Client{Timeout: 45 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("embedding API status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var ollamaOut struct { + Embedding []float64 `json:"embedding"` + } + var embedding []float64 + if err := json.Unmarshal(body, &ollamaOut); err == nil && len(ollamaOut.Embedding) > 0 { + embedding = ollamaOut.Embedding + } else { + var openAIOut struct { + Data []struct { + Embedding []float64 `json:"embedding"` + } `json:"data"` + } + if err := json.Unmarshal(body, &openAIOut); err != nil { + return nil, err + } + if len(openAIOut.Data) > 0 { + embedding = openAIOut.Data[0].Embedding + } + } + if len(embedding) == 0 { + return nil, fmt.Errorf("empty embedding vector") + } + + contextEmbedMu.Lock() + if _, ok := contextEmbedCache[root]; !ok { + contextEmbedCache[root] = map[string][]float64{} + } + contextEmbedCache[root][cacheKey] = embedding + contextEmbedDirty[root] = true + shouldSave := !contextEmbedSaving[root] + if shouldSave { + contextEmbedSaving[root] = true + } + contextEmbedMu.Unlock() + + if shouldSave { + go contextPersistEmbeddingCache(root) + } + + return embedding, nil +} + +func contextEmbeddingKey(baseURL string, model string, text string) string { + h := sha1.Sum([]byte(baseURL + "|" + model + "|" + text)) + return hex.EncodeToString(h[:]) +} + +func contextLoadEmbeddingCache(root string) map[string][]float64 { + cachePath := filepath.Join(root, ".apollo_contextplus", "embeddings_cache.json") + data, err := os.ReadFile(cachePath) + if err != nil { + return map[string][]float64{} + } + var m map[string][]float64 + if json.Unmarshal(data, &m) != nil { + return map[string][]float64{} + } + if m == nil { + return map[string][]float64{} + } + return m +} + +func contextPersistEmbeddingCache(root string) { + defer func() { + contextEmbedMu.Lock() + contextEmbedSaving[root] = false + if contextEmbedDirty[root] { + contextEmbedDirty[root] = false + contextEmbedSaving[root] = true + contextEmbedMu.Unlock() + go contextPersistEmbeddingCache(root) + return + } + contextEmbedMu.Unlock() + }() + + time.Sleep(300 * time.Millisecond) + + contextEmbedMu.Lock() + if !contextEmbedDirty[root] { + contextEmbedMu.Unlock() + return + } + cacheCopy := make(map[string][]float64, len(contextEmbedCache)) + rootCache, ok := contextEmbedCache[root] + if !ok { + contextEmbedMu.Unlock() + return + } + cacheCopy = make(map[string][]float64, len(rootCache)) + for k, v := range rootCache { + cacheCopy[k] = v + } + contextEmbedDirty[root] = false + contextEmbedMu.Unlock() + + data, err := json.Marshal(cacheCopy) + if err != nil { + return + } + baseDir := filepath.Join(root, ".apollo_contextplus") + _ = os.MkdirAll(baseDir, 0o755) + _ = os.WriteFile(filepath.Join(baseDir, "embeddings_cache.json"), data, 0o644) +} + +func contextGetSetting(key string, def string) string { + var val string + err := db.DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&val) + if err != nil || strings.TrimSpace(val) == "" { + return def + } + return strings.TrimSpace(val) +} + +func contextFindCallSites(entries []contextFileEntry, symbol string, defFile string, defLine int, limit int) []string { + if limit <= 0 { + limit = 10 + } + re := regexp.MustCompile(`\\b` + regexp.QuoteMeta(symbol) + `\\b`) + var sites []string + for _, e := range entries { + content, err := os.ReadFile(e.AbsPath) + if err != nil { + continue + } + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if !re.MatchString(line) { + continue + } + if e.RelPath == defFile && i+1 == defLine { + continue + } + sites = append(sites, fmt.Sprintf("%s:%d: %s", e.RelPath, i+1, strings.TrimSpace(line))) + if len(sites) >= limit { + return sites + } + } + } + return sites +} + +func contextTopTerms(files []contextFileEntry, max int) []string { + stop := map[string]bool{ + "the": true, "and": true, "for": true, "with": true, "from": true, + "const": true, "func": true, "function": true, "class": true, + "type": true, "import": true, "export": true, "return": true, + } + counts := map[string]int{} + for _, f := range files { + tokens := contextSplitWords(f.RelPath + " " + f.Header) + for _, t := range tokens { + if len(t) < 3 || stop[t] { + continue + } + counts[t]++ + } + } + type kv struct { + K string + V int + } + var list []kv + for k, v := range counts { + list = append(list, kv{k, v}) + } + sort.Slice(list, func(i, j int) bool { return list[i].V > list[j].V }) + if len(list) > max { + list = list[:max] + } + var out []string + for _, item := range list { + out = append(out, item.K) + } + return out +} + +func contextBuildSemanticVectors(root string, entries []contextFileEntry) [][]float64 { + raw := make([][]float64, len(entries)) + docs := make([]string, len(entries)) + targetDim := 0 + + for i, e := range entries { + doc := contextFileDocForSearch(e) + docs[i] = doc + vec, err := contextEmbedding(root, fmt.Sprintf("file:%s:%s:%s", e.RelPath, e.Hash, doc)) + if err == nil && len(vec) > 0 { + raw[i] = vec + if targetDim == 0 { + targetDim = len(vec) + } + } + } + + if targetDim == 0 { + targetDim = 96 + } + + out := make([][]float64, len(entries)) + for i := range entries { + if len(raw[i]) == targetDim { + out[i] = contextNormalizeVector(raw[i]) + continue + } + if len(raw[i]) > 0 { + out[i] = contextNormalizeVector(contextResizeVector(raw[i], targetDim)) + continue + } + out[i] = contextLexicalVector(docs[i], targetDim) + } + return out +} + +func contextResizeVector(vec []float64, size int) []float64 { + if size <= 0 { + return []float64{} + } + out := make([]float64, size) + if len(vec) == 0 { + return out + } + if len(vec) >= size { + copy(out, vec[:size]) + return out + } + copy(out, vec) + return out +} + +func contextLexicalVector(doc string, dim int) []float64 { + vec := make([]float64, dim) + if dim <= 0 { + return vec + } + words := contextSplitWords(doc) + if len(words) == 0 { + return vec + } + for _, w := range words { + h := sha1.Sum([]byte(w)) + i1 := (int(h[0])<<8 | int(h[1])) % dim + i2 := (int(h[2])<<8 | int(h[3])) % dim + s1 := 1.0 + s2 := 1.0 + if h[4]%2 == 0 { + s1 = -1 + } + if h[5]%2 == 0 { + s2 = -1 + } + vec[i1] += s1 + vec[i2] += s2 + } + return contextNormalizeVector(vec) +} + +func contextNormalizeVector(vec []float64) []float64 { + out := make([]float64, len(vec)) + copy(out, vec) + norm := 0.0 + for _, v := range out { + norm += v * v + } + if norm == 0 { + return out + } + norm = math.Sqrt(norm) + for i := range out { + out[i] /= norm + } + return out +} + +func contextSelectDiverseIndices(vectors [][]float64, limit int) []int { + n := len(vectors) + if n == 0 || limit <= 0 { + return nil + } + if n <= limit { + out := make([]int, n) + for i := range vectors { + out[i] = i + } + return out + } + + start := 0 + bestNorm := -1.0 + for i, v := range vectors { + norm := 0.0 + for _, x := range v { + norm += x * x + } + if norm > bestNorm { + bestNorm = norm + start = i + } + } + + selected := []int{start} + selectedSet := map[int]bool{start: true} + minDist := make([]float64, n) + for i := range minDist { + minDist[i] = math.Inf(1) + } + + for len(selected) < limit { + last := selected[len(selected)-1] + for i := 0; i < n; i++ { + if selectedSet[i] { + continue + } + dist := 1.0 - contextCosine(vectors[i], vectors[last]) + if dist < minDist[i] { + minDist[i] = dist + } + } + + next := -1 + nextDist := -1.0 + for i := 0; i < n; i++ { + if selectedSet[i] { + continue + } + if minDist[i] > nextDist { + nextDist = minDist[i] + next = i + } + } + if next < 0 { + break + } + selected = append(selected, next) + selectedSet[next] = true + } + + sort.Ints(selected) + return selected +} + +func contextSelectSplitCandidate(root *contextSemanticNode, maxDepth int) *contextSemanticNode { + leaves := contextCollectLeafNodes(root) + var best *contextSemanticNode + for _, leaf := range leaves { + if leaf.Locked { + continue + } + if leaf.Depth >= maxDepth { + continue + } + if len(leaf.FileIndices) < 6 { + continue + } + if best == nil || len(leaf.FileIndices) > len(best.FileIndices) { + best = leaf + } + } + return best +} + +func contextCollectLeafNodes(root *contextSemanticNode) []*contextSemanticNode { + if root == nil { + return nil + } + if len(root.Children) == 0 { + return []*contextSemanticNode{root} + } + var out []*contextSemanticNode + for _, child := range root.Children { + out = append(out, contextCollectLeafNodes(child)...) + } + return out +} + +func contextSpectralBisect(vectors [][]float64, indices []int) ([]int, []int, bool) { + n := len(indices) + if n < 4 { + return nil, nil, false + } + + sim := make([]float64, n*n) + for i := 0; i < n; i++ { + sim[i*n+i] = 1 + for j := i + 1; j < n; j++ { + s := contextCosine(vectors[indices[i]], vectors[indices[j]]) + if s < 0 { + s = 0 + } + sim[i*n+j] = s + sim[j*n+i] = s + } + } + + useSparse := n > 30 + neighbors := 12 + if n-1 < neighbors { + neighbors = n - 1 + } + adj := make([]bool, n*n) + if useSparse { + for i := 0; i < n; i++ { + type pair struct { + j int + s float64 + } + var scores []pair + for j := 0; j < n; j++ { + if i == j { + continue + } + if sim[i*n+j] <= 0 { + continue + } + scores = append(scores, pair{j: j, s: sim[i*n+j]}) + } + sort.Slice(scores, func(a, b int) bool { return scores[a].s > scores[b].s }) + if len(scores) > neighbors { + scores = scores[:neighbors] + } + for _, p := range scores { + adj[i*n+p.j] = true + adj[p.j*n+i] = true + } + } + } else { + for i := 0; i < n; i++ { + for j := i + 1; j < n; j++ { + if sim[i*n+j] > 0 { + adj[i*n+j] = true + adj[j*n+i] = true + } + } + } + } + + w := make([]float64, n*n) + degree := make([]float64, n) + for i := 0; i < n; i++ { + for j := i + 1; j < n; j++ { + if !adj[i*n+j] { + continue + } + v := sim[i*n+j] + w[i*n+j] = v + w[j*n+i] = v + degree[i] += v + degree[j] += v + } + } + for i := 0; i < n; i++ { + if degree[i] == 0 { + degree[i] = 1e-9 + } + } + + lData := make([]float64, n*n) + for i := 0; i < n; i++ { + lData[i*n+i] = 1 + for j := i + 1; j < n; j++ { + if w[i*n+j] <= 0 { + continue + } + v := -w[i*n+j] / math.Sqrt(degree[i]*degree[j]) + lData[i*n+j] = v + lData[j*n+i] = v + } + } + lap := mat.NewSymDense(n, lData) + var eig mat.EigenSym + if ok := eig.Factorize(lap, true); !ok { + return nil, nil, false + } + values := eig.Values(nil) + if len(values) < 2 { + return nil, nil, false + } + order := make([]int, len(values)) + for i := range values { + order[i] = i + } + sort.Slice(order, func(i, j int) bool { return values[order[i]] < values[order[j]] }) + fiedlerCol := order[1] + + vecs := mat.NewDense(n, n, nil) + eig.VectorsTo(vecs) + fiedler := make([]float64, n) + for i := 0; i < n; i++ { + fiedler[i] = vecs.At(i, fiedlerCol) + } + threshold := contextMedianFloat64(fiedler) + + var left, right []int + for i := 0; i < n; i++ { + if fiedler[i] <= threshold { + left = append(left, indices[i]) + } else { + right = append(right, indices[i]) + } + } + if len(left) < 2 || len(right) < 2 { + left = nil + right = nil + for i := 0; i < n; i++ { + if fiedler[i] <= 0 { + left = append(left, indices[i]) + } else { + right = append(right, indices[i]) + } + } + } + if len(left) < 2 || len(right) < 2 { + return nil, nil, false + } + return left, right, true +} + +func contextMedianFloat64(values []float64) float64 { + if len(values) == 0 { + return 0 + } + cp := append([]float64(nil), values...) + sort.Float64s(cp) + mid := len(cp) / 2 + if len(cp)%2 == 1 { + return cp[mid] + } + return (cp[mid-1] + cp[mid]) / 2 +} + +func contextCentroidForIndices(vectors [][]float64, indices []int) []float64 { + if len(indices) == 0 { + return nil + } + dim := len(vectors[indices[0]]) + centroid := make([]float64, dim) + for _, idx := range indices { + v := vectors[idx] + limit := dim + if len(v) < limit { + limit = len(v) + } + for i := 0; i < limit; i++ { + centroid[i] += v[i] + } + } + for i := range centroid { + centroid[i] /= float64(len(indices)) + } + return contextNormalizeVector(centroid) +} + +func contextRenderSemanticNode(b *strings.Builder, node *contextSemanticNode, entries []contextFileEntry, depth int) { + if node == nil { + return + } + indent := strings.Repeat(" ", depth) + files := make([]contextFileEntry, 0, len(node.FileIndices)) + for _, idx := range node.FileIndices { + if idx >= 0 && idx < len(entries) { + files = append(files, entries[idx]) + } + } + terms := contextTopTerms(files, 5) + + label := node.ID + if len(terms) > 0 { + show := 3 + if len(terms) < show { + show = len(terms) + } + label = fmt.Sprintf("%s [%s]", node.ID, strings.Join(terms[:show], ", ")) + } + + b.WriteString(fmt.Sprintf("%s- Cluster %s (%d files)\n", indent, label, len(files))) + if len(node.Children) == 0 { + show := 6 + if len(files) < show { + show = len(files) + } + for i := 0; i < show; i++ { + b.WriteString(fmt.Sprintf("%s - %s\n", indent, files[i].RelPath)) + } + if len(files) > show { + b.WriteString(fmt.Sprintf("%s - ...\n", indent)) + } + return + } + for _, child := range node.Children { + contextRenderSemanticNode(b, child, entries, depth+1) + } +} + +func contextCollectHubs(root string) ([]contextHub, error) { + var hubs []contextHub + re := regexp.MustCompile(`\\[\\[([^\\]|#]+)[^\\]]*\\]\\]`) + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if contextSkipDirs[d.Name()] { + return filepath.SkipDir + } + if strings.HasPrefix(d.Name(), ".") && d.Name() != ".github" { + return filepath.SkipDir + } + return nil + } + if strings.ToLower(filepath.Ext(path)) != ".md" { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + matches := re.FindAllStringSubmatch(string(data), -1) + if len(matches) == 0 { + return nil + } + links := make([]string, 0, len(matches)) + for _, m := range matches { + if len(m) < 2 { + continue + } + links = append(links, strings.TrimSpace(m[1])) + } + hubs = append(hubs, contextHub{RelPath: contextRelativePath(root, path), AbsPath: path, Links: links}) + return nil + }) + return hubs, err +} + +func contextFindHubOrphans(root string, hubs []contextHub) []string { + linked := map[string]bool{} + for _, h := range hubs { + for _, l := range h.Links { + resolved := contextResolveHubLink(root, h.AbsPath, l) + if resolved == "" { + continue + } + linked[contextRelativePath(root, resolved)] = true + } + } + + entries, _ := contextCollectFiles(root, root) + var orphans []string + for _, e := range entries { + if !linked[e.RelPath] { + orphans = append(orphans, e.RelPath) + } + } + sort.Strings(orphans) + return orphans +} + +func contextSelectHub(root string, hubs []contextHub, hubPath string, feature string) *contextHub { + if strings.TrimSpace(hubPath) != "" { + clean := filepath.Clean(strings.TrimPrefix(hubPath, "/")) + for i := range hubs { + if filepath.Clean(hubs[i].RelPath) == clean { + return &hubs[i] + } + } + } + if strings.TrimSpace(feature) != "" { + needle := strings.ToLower(feature) + for i := range hubs { + if strings.Contains(strings.ToLower(hubs[i].RelPath), needle) { + return &hubs[i] + } + } + } + return nil +} + +func contextResolveHubLink(root string, hubAbs string, link string) string { + link = strings.TrimSpace(link) + if link == "" { + return "" + } + candidate := link + if filepath.Ext(candidate) == "" { + for _, ext := range []string{".ts", ".tsx", ".js", ".jsx", ".go", ".py", ".rs", ".md"} { + if contextPathExists(filepath.Join(root, candidate+ext)) { + candidate = candidate + ext + break + } + } + } + + if strings.HasPrefix(candidate, "/") { + abs := filepath.Join(root, strings.TrimPrefix(candidate, "/")) + if contextPathExists(abs) { + return abs + } + } + + hubDir := filepath.Dir(hubAbs) + local := filepath.Clean(filepath.Join(hubDir, candidate)) + if strings.HasPrefix(local, root) && contextPathExists(local) { + return local + } + + global := filepath.Clean(filepath.Join(root, candidate)) + if strings.HasPrefix(global, root) && contextPathExists(global) { + return global + } + return "" +} + +func contextPathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func contextValidateCommitContent(content string) string { + var warnings []string + lines := strings.Split(content, "\n") + if len(lines) < 2 { + warnings = append(warnings, "- Missing 2-line file header comment.") + } else { + first := strings.TrimSpace(lines[0]) + second := strings.TrimSpace(lines[1]) + if !contextIsCommentLine(first) || !contextIsCommentLine(second) { + warnings = append(warnings, "- First two lines are not comments; Context+ style headers are recommended.") + } + } + if len(lines) > 1000 { + warnings = append(warnings, "- File exceeds 1000 lines; split into smaller modules.") + } + return strings.Join(warnings, "\n") +} + +func contextIsCommentLine(line string) bool { + return strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "--") +} + +func contextIndexPath(root string) string { + return filepath.Join(contextDataDir(root), "index.json") +} + +func contextEnsureIndex(root string) error { + contextEnsureTracker(root) + + contextIndexMu.Lock() + defer contextIndexMu.Unlock() + + idx, ok := contextIndexes[root] + if !ok || idx == nil { + idx = contextLoadIndex(root) + contextIndexes[root] = idx + } + + seen := map[string]bool{} + var changed []string + removed := false + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if contextSkipDirs[name] { + return filepath.SkipDir + } + if strings.HasPrefix(name, ".") && name != ".github" { + return filepath.SkipDir + } + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if !contextCodeExt[ext] { + return nil + } + + info, infoErr := d.Info() + if infoErr != nil { + return nil + } + + rel := contextRelativePath(root, path) + seen[rel] = true + + existing, exists := idx.Files[rel] + mtime := info.ModTime().UnixMilli() + size := info.Size() + if exists && existing.MTime == mtime && existing.Size == size { + return nil + } + + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + content := string(data) + if !contextLooksText(content) { + return nil + } + + snippet := content + if len(snippet) > 2000 { + snippet = snippet[:2000] + } + fileHash := contextHashString(content) + idx.Files[rel] = contextIndexedFile{ + RelPath: rel, + Ext: ext, + Lang: contextLanguageByExt[ext], + Header: contextFileHeader(content), + Snippet: snippet, + Symbols: contextParseSymbolsByExt(ext, content), + MTime: mtime, + Size: size, + Hash: fileHash, + } + changed = append(changed, rel) + return nil + }) + if err != nil { + return err + } + + for rel := range idx.Files { + if seen[rel] { + continue + } + delete(idx.Files, rel) + removed = true + } + + if len(changed) > 0 || removed { + idx.UpdatedAt = time.Now().UnixMilli() + if saveErr := contextSaveIndex(root, idx); saveErr != nil { + return saveErr + } + } + + if len(changed) > 0 { + contextQueuePendingWarmLocked(root, changed) + } + + return nil +} + +func contextLoadIndex(root string) *contextProjectIndex { + idx := &contextProjectIndex{ + Version: 1, + Root: root, + UpdatedAt: time.Now().UnixMilli(), + Files: map[string]contextIndexedFile{}, + } + data, err := os.ReadFile(contextIndexPath(root)) + if err != nil { + return idx + } + var loaded contextProjectIndex + if json.Unmarshal(data, &loaded) != nil { + return idx + } + if loaded.Files == nil { + loaded.Files = map[string]contextIndexedFile{} + } + loaded.Root = root + if loaded.Version <= 0 { + loaded.Version = 1 + } + return &loaded +} + +func contextSaveIndex(root string, idx *contextProjectIndex) error { + if idx == nil { + return nil + } + if err := os.MkdirAll(contextDataDir(root), 0o755); err != nil { + return err + } + b, err := json.Marshal(idx) + if err != nil { + return err + } + return os.WriteFile(contextIndexPath(root), b, 0o644) +} + +func contextEnsureTracker(root string) { + if !contextEmbedTrackerEnabled() { + return + } + contextIndexMu.Lock() + if contextTrackerActive[root] { + contextIndexMu.Unlock() + return + } + contextTrackerActive[root] = true + contextIndexMu.Unlock() + go contextTrackerLoop(root) +} + +func contextTrackerLoop(root string) { + debounce := contextEnvInt("CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS", 700, 200, 10000) + maxFiles := contextEnvInt("CONTEXTPLUS_EMBED_TRACKER_MAX_FILES", 8, 1, 20) + ticker := time.NewTicker(time.Duration(debounce) * time.Millisecond) + defer ticker.Stop() + for range ticker.C { + _ = contextEnsureIndex(root) + files := contextPopPendingWarm(root, maxFiles) + if len(files) == 0 { + continue + } + + contextIndexMu.Lock() + idx := contextIndexes[root] + indexedFiles := make([]contextIndexedFile, 0, len(files)) + for _, rel := range files { + if f, ok := idx.Files[rel]; ok { + indexedFiles = append(indexedFiles, f) + } + } + contextIndexMu.Unlock() + + for _, f := range indexedFiles { + contextWarmEmbeddingsForFile(root, f) + } + } +} + +func contextQueuePendingWarmLocked(root string, relPaths []string) { + set, ok := contextPendingWarm[root] + if !ok { + set = map[string]bool{} + contextPendingWarm[root] = set + } + for _, rel := range relPaths { + set[rel] = true + } +} + +func contextPopPendingWarm(root string, max int) []string { + contextIndexMu.Lock() + defer contextIndexMu.Unlock() + set, ok := contextPendingWarm[root] + if !ok || len(set) == 0 { + return nil + } + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) > max { + keys = keys[:max] + } + for _, k := range keys { + delete(set, k) + } + return keys +} + +func contextWarmEmbeddingsForFile(root string, file contextIndexedFile) { + fileDoc := strings.ToLower(strings.Join([]string{ + file.RelPath, + file.Header, + contextSymbolsToText(file.Symbols, 16), + file.Snippet, + }, "\n")) + if strings.TrimSpace(fileDoc) != "" { + _, _ = contextEmbedding(root, fmt.Sprintf("file:%s:%s:%s", file.RelPath, file.Hash, fileDoc)) + } + + maxIdentifiers := 16 + if len(file.Symbols) < maxIdentifiers { + maxIdentifiers = len(file.Symbols) + } + for i := 0; i < maxIdentifiers; i++ { + s := file.Symbols[i] + idDoc := strings.ToLower(fmt.Sprintf("%s %s %s %s %s", s.Name, s.Kind, s.Signature, file.RelPath, file.Header)) + _, _ = contextEmbedding(root, fmt.Sprintf("identifier:%s:%d:%s:%s", file.RelPath, s.Line, file.Hash, idDoc)) + } +} + +func contextSymbolsToText(symbols []contextSymbol, limit int) string { + if len(symbols) == 0 || limit <= 0 { + return "" + } + if len(symbols) < limit { + limit = len(symbols) + } + parts := make([]string, 0, limit) + for i := 0; i < limit; i++ { + parts = append(parts, symbols[i].Name) + } + return strings.Join(parts, " ") +} + +func contextEmbedTrackerEnabled() bool { + raw := strings.ToLower(strings.TrimSpace(os.Getenv("CONTEXTPLUS_EMBED_TRACKER"))) + if raw == "" { + return true + } + return raw != "0" && raw != "false" && raw != "no" && raw != "off" +} + +func contextEnvInt(name string, def int, min int, max int) int { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil { + return def + } + if n < min { + return min + } + if n > max { + return max + } + return n +} + +func contextPathWithinTarget(path string, target string) bool { + cleanPath := filepath.Clean(path) + cleanTarget := filepath.Clean(target) + if cleanPath == cleanTarget { + return true + } + return strings.HasPrefix(cleanPath, cleanTarget+string(os.PathSeparator)) +} + +func contextGetIndexedFile(root string, relPath string) (contextIndexedFile, bool) { + contextIndexMu.Lock() + defer contextIndexMu.Unlock() + idx, ok := contextIndexes[root] + if !ok || idx == nil { + return contextIndexedFile{}, false + } + f, ok := idx.Files[filepath.Clean(relPath)] + return f, ok +} + +func contextHashString(content string) string { + sum := sha1.Sum([]byte(content)) + return hex.EncodeToString(sum[:]) +} + +func contextLineByNumber(content string, lineNumber int) string { + if lineNumber <= 0 { + return "" + } + lines := strings.Split(content, "\n") + if lineNumber > len(lines) { + return "" + } + return strings.TrimSpace(lines[lineNumber-1]) +} + +func contextDataDir(root string) string { + return filepath.Join(root, ".apollo_contextplus") +} + +func contextRestoreBaseDir(root string) string { + return filepath.Join(contextDataDir(root), "restore_points") +} + +func contextCreateRestorePoint(root string, relPaths []string, message string) (string, error) { + if err := os.MkdirAll(contextRestoreBaseDir(root), 0o755); err != nil { + return "", err + } + + random := make([]byte, 3) + _, _ = rand.Read(random) + rpID := fmt.Sprintf("rp-%d-%s", time.Now().UnixMilli(), hex.EncodeToString(random)) + rpDir := filepath.Join(contextRestoreBaseDir(root), rpID) + filesDir := filepath.Join(rpDir, "files") + if err := os.MkdirAll(filesDir, 0o755); err != nil { + return "", err + } + + point := contextRestorePoint{ + ID: rpID, + Timestamp: time.Now().UnixMilli(), + Message: message, + Files: make([]contextRestorePointFile, 0, len(relPaths)), + } + + for _, rel := range relPaths { + rel = filepath.Clean(strings.TrimPrefix(rel, "/")) + abs := filepath.Join(root, rel) + fileEntry := contextRestorePointFile{Path: rel} + if contextPathExists(abs) { + fileEntry.Existed = true + backupPath := filepath.Join(filesDir, rel) + if err := os.MkdirAll(filepath.Dir(backupPath), 0o755); err != nil { + return "", err + } + src, err := os.Open(abs) + if err != nil { + return "", err + } + dst, err := os.Create(backupPath) + if err != nil { + src.Close() + return "", err + } + _, copyErr := io.Copy(dst, src) + src.Close() + dst.Close() + if copyErr != nil { + return "", copyErr + } + } + point.Files = append(point.Files, fileEntry) + } + + metaBytes, _ := json.MarshalIndent(point, "", " ") + if err := os.WriteFile(filepath.Join(rpDir, "meta.json"), metaBytes, 0o644); err != nil { + return "", err + } + return rpID, nil +} + +func contextListRestorePoints(root string) ([]contextRestorePoint, error) { + base := contextRestoreBaseDir(root) + if !contextPathExists(base) { + return []contextRestorePoint{}, nil + } + dirs, err := os.ReadDir(base) + if err != nil { + return nil, err + } + var points []contextRestorePoint + for _, d := range dirs { + if !d.IsDir() { + continue + } + metaPath := filepath.Join(base, d.Name(), "meta.json") + data, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var p contextRestorePoint + if json.Unmarshal(data, &p) != nil { + continue + } + points = append(points, p) + } + sort.Slice(points, func(i, j int) bool { return points[i].Timestamp > points[j].Timestamp }) + return points, nil +} + +func contextRestorePointByID(root string, pointID string) ([]string, error) { + metaPath := filepath.Join(contextRestoreBaseDir(root), pointID, "meta.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return nil, fmt.Errorf("restore point not found") + } + var p contextRestorePoint + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + + filesDir := filepath.Join(contextRestoreBaseDir(root), pointID, "files") + var restored []string + for _, f := range p.Files { + abs := filepath.Join(root, f.Path) + if !strings.HasPrefix(abs, root) { + continue + } + if f.Existed { + backup := filepath.Join(filesDir, f.Path) + if !contextPathExists(backup) { + continue + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return restored, err + } + src, err := os.Open(backup) + if err != nil { + return restored, err + } + dst, err := os.Create(abs) + if err != nil { + src.Close() + return restored, err + } + _, copyErr := io.Copy(dst, src) + src.Close() + dst.Close() + if copyErr != nil { + return restored, copyErr + } + restored = append(restored, f.Path) + } else { + _ = os.Remove(abs) + restored = append(restored, f.Path) + } + } + return restored, nil +} + +func contextRelativePath(root string, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + return path + } + if rel == "." { + return rel + } + return filepath.Clean(rel) +} + +func contextDetectLanguages(target string) map[string]bool { + langs := map[string]bool{} + _ = filepath.WalkDir(target, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if contextSkipDirs[d.Name()] { + return filepath.SkipDir + } + if strings.HasPrefix(d.Name(), ".") && d.Name() != ".github" { + return filepath.SkipDir + } + return nil + } + switch strings.ToLower(filepath.Ext(path)) { + case ".go": + langs["go"] = true + case ".ts", ".tsx": + langs["typescript"] = true + case ".js", ".jsx": + langs["javascript"] = true + case ".py": + langs["python"] = true + case ".rs": + langs["rust"] = true + } + return nil + }) + return langs +} + +func contextFindFilesByExt(root string, ext string, limit int) []string { + var out []string + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if contextSkipDirs[d.Name()] { + return filepath.SkipDir + } + if strings.HasPrefix(d.Name(), ".") && d.Name() != ".github" { + return filepath.SkipDir + } + return nil + } + if strings.EqualFold(filepath.Ext(path), ext) { + out = append(out, path) + if len(out) >= limit { + return io.EOF + } + } + return nil + }) + return out +} + +func contextRunCheckCommand(dir string, title string, name string, args ...string) string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + output := strings.TrimSpace(string(out)) + if ctx.Err() == context.DeadlineExceeded { + return fmt.Sprintf("[%s]\nTimed out after 2m", title) + } + if err != nil { + if output == "" { + return fmt.Sprintf("[%s]\nFailed: %v", title, err) + } + return fmt.Sprintf("[%s]\n%s", title, output) + } + if output == "" { + return fmt.Sprintf("[%s]\n✓ No issues", title) + } + return fmt.Sprintf("[%s]\n%s", title, output) +} + +func contextFindIntArg(raw map[string]interface{}, key string, def int) int { + v, ok := raw[key] + if !ok { + return def + } + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case string: + if n, err := strconv.Atoi(t); err == nil { + return n + } + } + return def +} diff --git a/dash/backend/tools/exec.go b/dash/backend/tools/exec.go new file mode 100644 index 0000000..cbd0b96 --- /dev/null +++ b/dash/backend/tools/exec.go @@ -0,0 +1,54 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "time" +) + +// Executes a shell command strictly jailed inside the Workspace sandbox +func executeCommand(rawArgs string, projectRoot string) string { + var args struct { + Command string `json:"command"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Command == "" { + return "Error: Invalid arguments. 'command' is required." + } + + // 15 Second Hard Timeout to prevent AI locking the server with frozen processes + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-c", args.Command) + + // Jail execution working directory to the project root if available, otherwise global sandbox + workDir := WorkspaceDir + if projectRoot != "" { + workDir = projectRoot + } + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + _ = cmd.Run() + + if ctx.Err() == context.DeadlineExceeded { + return fmt.Sprintf("Error: Command timed out after 15 seconds.\nStdout: %s\nStderr: %s", stdout.String(), stderr.String()) + } + + result := fmt.Sprintf("Exit Code: %v\n\n--- Stdout ---\n%s\n--- Stderr ---\n%s", + cmd.ProcessState.ExitCode(), + stdout.String(), + stderr.String(), + ) + + if len(result) > 15000 { + return result[:15000] + "\n\n... [TERMINAL TRUNCATED FOR LENGTH]" + } + return result +} diff --git a/dash/backend/tools/fs.go b/dash/backend/tools/fs.go new file mode 100644 index 0000000..178c4a5 --- /dev/null +++ b/dash/backend/tools/fs.go @@ -0,0 +1,525 @@ +package tools + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/danilrybalkin/apollo-dash/db" +) + +var WorkspaceDir = resolveWorkspaceDir() + +func resolveWorkspaceDir() string { + for _, key := range []string{"AGENTHQ_WORKSPACE_ROOT", "APOLLO_WORKSPACE_ROOT"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + if abs, err := filepath.Abs(value); err == nil { + return filepath.Clean(abs) + } + return filepath.Clean(value) + } + } + if abs, err := filepath.Abs(filepath.Join("data", "workspaces")); err == nil { + return filepath.Clean(abs) + } + return filepath.Clean(filepath.Join("data", "workspaces")) +} + +func withinBase(path string, base string) bool { + rel, err := filepath.Rel(base, path) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// Ensures the workspace directory exists on boot +func InitWorkspace() { + if _, err := os.Stat(WorkspaceDir); os.IsNotExist(err) { + err := os.MkdirAll(WorkspaceDir, 0755) + if err != nil { + fmt.Printf("Startup Error: Failed to create AI Workspace at %s: %v\n", WorkspaceDir, err) + } else { + fmt.Printf("Startup Success: Created AI Sandbox Workspace at %s\n", WorkspaceDir) + } + } + + // Ensure the workspace is a Git repository to enable the Time Machine / Checkpointing Checkpoint System + cmd := exec.Command("git", "init") + cmd.Dir = WorkspaceDir + _ = cmd.Run() +} + +// Security function to prevent path traversal (e.g. reading /etc/shadow) +func securePath(requestedPath string, projectRoot string) (string, error) { + // If the LLM passes an absolute path inside the workspace, or a relative one like file.txt + cleanRequested := filepath.Clean(requestedPath) + + // Determine the effective root (either the specific project or the global workspace) + effectiveRoot := WorkspaceDir + if projectRoot != "" { + effectiveRoot = projectRoot + } + + // If it doesn't already start with the effective root, prepend it + if !strings.HasPrefix(cleanRequested, effectiveRoot) { + cleanRequested = filepath.Join(effectiveRoot, cleanRequested) + } + + // Final evaluation to ensure it didn't use ../../../ to escape + cleanFinal := filepath.Clean(cleanRequested) + + // Must be within WorkspaceDir ALWAYS + if !withinBase(cleanFinal, WorkspaceDir) { + return "", fmt.Errorf("security violation: path traversal detected (%s)", requestedPath) + } + + // If projectRoot is set, it MUST be within that too + if projectRoot != "" && !withinBase(cleanFinal, projectRoot) { + return "", fmt.Errorf("security violation: path outside assigned project root (%s)", requestedPath) + } + + return cleanFinal, nil +} + +// ---------------------------------------------------- +// TIME MACHINE +// ---------------------------------------------------- + +func CreateCheckpoint(message string) string { + // Add all changes to git stagings + addCmd := exec.Command("git", "add", "-A") + addCmd.Dir = WorkspaceDir + _ = addCmd.Run() + + // Commit with the automated message + commitCmd := exec.Command("git", "commit", "-m", "[AgentHQ Auto-Checkpoint] "+message) + commitCmd.Dir = WorkspaceDir + commitCmd.Run() + + revCmd := exec.Command("git", "rev-parse", "HEAD") + revCmd.Dir = WorkspaceDir + out, err := revCmd.Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + return "" +} + +// ---------------------------------------------------- +// IMPLEMENTATIONS +// ---------------------------------------------------- + +func executeListFiles(rawArgs string, projectRoot string) string { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: Invalid arguments." + } + + targetPath := args.Path + if targetPath == "" { + targetPath = projectRoot + if targetPath == "" { + targetPath = WorkspaceDir + } + } + + safePath, err := securePath(targetPath, projectRoot) + if err != nil { + return err.Error() + } + + files, err := ioutil.ReadDir(safePath) + if err != nil { + return fmt.Sprintf("Error reading directory: %v", err) + } + + if len(files) == 0 { + return "Directory is empty." + } + + var out strings.Builder + for _, f := range files { + kind := "FILE" + if f.IsDir() { + kind = "DIR " + } + out.WriteString(fmt.Sprintf("[%s] %s (%d bytes)\n", kind, f.Name(), f.Size())) + } + return out.String() +} + +func executeReadFile(rawArgs string, projectRoot string) string { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Path == "" { + return "Error: Invalid arguments. 'path' is required." + } + + safePath, err := securePath(args.Path, projectRoot) + if err != nil { + return err.Error() + } + + data, err := ioutil.ReadFile(safePath) + if err != nil { + return fmt.Sprintf("Error reading file: %v", err) + } + + // Truncate to prevent massive tokens + content := string(data) + if len(content) > 15000 { + content = content[:15000] + "\n\n... [FILE TRUNCATED FOR LENGTH LIMITS]" + } + + return content +} + +func executeWriteFile(rawArgs string, projectRoot string) string { + var args struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Path == "" { + return "Error: Invalid arguments. 'path' and 'content' are required." + } + + safePath, err := securePath(args.Path, projectRoot) + if err != nil { + return err.Error() + } + + // Ensure parent dir exists + parent := filepath.Dir(safePath) + os.MkdirAll(parent, 0755) + + err = ioutil.WriteFile(safePath, []byte(args.Content), 0644) + if err != nil { + return fmt.Sprintf("Error writing file: %v", err) + } + + return fmt.Sprintf("Success: Wrote %d bytes to %s", len(args.Content), args.Path) +} + +func executeEditFile(rawArgs string, projectRoot string) string { + var args struct { + Path string `json:"path"` + Search string `json:"search"` + Replace string `json:"replace"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Path == "" || args.Search == "" { + return "Error: Invalid arguments. 'path', 'search', and 'replace' are required." + } + + safePath, err := securePath(args.Path, projectRoot) + if err != nil { + return err.Error() + } + + data, err := ioutil.ReadFile(safePath) + if err != nil { + return fmt.Sprintf("Error reading file: %v", err) + } + + content := string(data) + + occurrences := strings.Count(content, args.Search) + if occurrences == 0 { + return "Error: The exact search string was not found in the file." + } else if occurrences > 1 { + return "Error: The search string appears multiple times. Please provide a larger unique search block to ensure the correct code is replaced." + } + + newContent := strings.Replace(content, args.Search, args.Replace, 1) + + if err := os.WriteFile(safePath, []byte(newContent), 0644); err != nil { + return fmt.Sprintf("Error writing modified file: %v", err) + } + + return fmt.Sprintf("Success: Replaced block in %s.", safePath) +} + +func executeAddWorkspaceProject(rawArgs string, projectRoot string) string { + var args struct { + Name string `json:"name"` + Path string `json:"path"` + DeployCommand string `json:"deploy_command"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: Invalid arguments." + } + + if args.Name == "" || args.Path == "" { + return "Error: name and path are required." + } + + safePath, err := securePath(args.Path, projectRoot) + if err != nil { + return err.Error() + } + + // Make sure the directory exists natively + if _, err := os.Stat(safePath); os.IsNotExist(err) { + if err := os.MkdirAll(safePath, 0755); err != nil { + return fmt.Sprintf("Error creating project directory: %v", err) + } + } + + // Link to internal ManagedProjects settings DB so it appears in the File Explorer + var val string + err = db.DB.QueryRow("SELECT value FROM settings WHERE key = 'managed_projects'").Scan(&val) + if err != nil { + val = "[]" + } + + var projects []map[string]interface{} + json.Unmarshal([]byte(val), &projects) + + // Check if already mounted + for _, p := range projects { + if p["path"] == safePath { + return fmt.Sprintf("Project at %s is already managed under name '%s'.", safePath, p["name"]) + } + } + + deployCmd := args.DeployCommand + if deployCmd == "" { + deployCmd = "echo 'No deploy command configured'" + } + + projects = append(projects, map[string]interface{}{ + "name": args.Name, + "path": safePath, + "deploy_command": deployCmd, + }) + + bytes, _ := json.Marshal(projects) + db.DB.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('managed_projects', ?)", string(bytes)) + + return fmt.Sprintf("Success: Project '%s' mapped to workspace %s and is now visible in the File Explorer.", args.Name, safePath) +} + +func executeGrepSearch(rawArgs string, projectRoot string) string { + var args struct { + Pattern string `json:"pattern"` + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Pattern == "" { + return "Error: Invalid arguments. 'pattern' is required." + } + + targetPath := args.Path + if targetPath == "" { + targetPath = projectRoot + if targetPath == "" { + targetPath = WorkspaceDir // Default to workspace root if empty + } + } + + safePath, err := securePath(targetPath, projectRoot) + if err != nil { + return err.Error() + } + + re, err := regexp.Compile(args.Pattern) + if err != nil { + return fmt.Sprintf("Error: Invalid regex pattern: %v", err) + } + + var results []string + matches := 0 + MAX_MATCHES := 100 + + filepath.Walk(safePath, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + + // Skip hidden files to save time + if strings.HasPrefix(filepath.Base(path), ".") { + return nil + } + + data, err := ioutil.ReadFile(path) + if err == nil { + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if re.MatchString(line) { + relPath := strings.TrimPrefix(path, WorkspaceDir) + if !strings.HasPrefix(relPath, "/") { + relPath = "/" + relPath + } + results = append(results, fmt.Sprintf("%s:%d: %s", relPath, i+1, strings.TrimSpace(line))) + matches++ + if matches >= MAX_MATCHES { + return fmt.Errorf("max_matches") + } + } + } + } + return nil + }) + + if len(results) == 0 { + return "No matches found." + } + + out := strings.Join(results, "\n") + if matches >= MAX_MATCHES { + out += "\n\n... [ADDITIONAL MATCHES TRUNCATED]" + } + + return out +} + +// executeFindFiles searches for files by glob pattern within the workspace +func executeFindFiles(rawArgs string, projectRoot string) string { + var args struct { + Pattern string `json:"pattern"` + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Pattern == "" { + return "Error: 'pattern' is required (e.g. '*.go', 'test_*.py')." + } + + searchPath := args.Path + if searchPath == "" { + searchPath = projectRoot + if searchPath == "" { + searchPath = WorkspaceDir + } + } + safePath, err := securePath(searchPath, projectRoot) + if err != nil { + return err.Error() + } + + cmd := exec.Command("find", safePath, "-name", args.Pattern, "-not", "-path", "*/.*", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/vendor/*") + out, err := cmd.Output() + if err != nil { + return fmt.Sprintf("Error running find: %v", err) + } + result := strings.TrimSpace(string(out)) + if result == "" { + return fmt.Sprintf("No files matching '%s' found in %s", args.Pattern, searchPath) + } + // Strip the workspace prefix for readability + lines := strings.Split(result, "\n") + for i, l := range lines { + lines[i] = strings.TrimPrefix(l, WorkspaceDir+"/") + } + return strings.Join(lines, "\n") +} + +// executeRenameFile renames or moves a file/directory within the workspace +func executeRenameFile(rawArgs string, projectRoot string) string { + var args struct { + From string `json:"from"` + To string `json:"to"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.From == "" || args.To == "" { + return "Error: 'from' and 'to' paths are required." + } + + fromPath, err := securePath(args.From, projectRoot) + if err != nil { + return fmt.Sprintf("Error (from): %v", err) + } + toPath, err := securePath(args.To, projectRoot) + if err != nil { + return fmt.Sprintf("Error (to): %v", err) + } + + if _, err := os.Stat(fromPath); os.IsNotExist(err) { + return fmt.Sprintf("Error: Source path '%s' does not exist.", args.From) + } + + // Ensure destination parent dir exists + os.MkdirAll(filepath.Dir(toPath), 0755) + + if err := os.Rename(fromPath, toPath); err != nil { + return fmt.Sprintf("Error renaming: %v", err) + } + return fmt.Sprintf("Success: Renamed '%s' → '%s'", args.From, args.To) +} + +// executeCheckCode runs language-appropriate linting/type-checking on a file or directory +func executeCheckCode(rawArgs string, projectRoot string) string { + var args struct { + Path string `json:"path"` + Language string `json:"language"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Path == "" { + return "Error: 'path' is required." + } + + safePath, err := securePath(args.Path, projectRoot) + if err != nil { + return err.Error() + } + + lang := strings.ToLower(args.Language) + // Auto-detect from extension if not specified + if lang == "" { + ext := strings.ToLower(filepath.Ext(args.Path)) + switch ext { + case ".go": + lang = "go" + case ".ts", ".tsx": + lang = "typescript" + case ".js", ".jsx": + lang = "javascript" + case ".py": + lang = "python" + case ".rs": + lang = "rust" + } + } + + var cmd *exec.Cmd + switch lang { + case "go": + // Run go vet on the directory containing the file + dir := safePath + if info, _ := os.Stat(safePath); info != nil && !info.IsDir() { + dir = filepath.Dir(safePath) + } + cmd = exec.Command("go", "vet", "./...") + cmd.Dir = dir + case "typescript": + cmd = exec.Command("npx", "tsc", "--noEmit", "--pretty") + cmd.Dir = WorkspaceDir + case "javascript": + cmd = exec.Command("npx", "eslint", safePath, "--format", "compact") + cmd.Dir = WorkspaceDir + case "python": + cmd = exec.Command("python3", "-m", "flake8", safePath, "--max-line-length", "120") + case "rust": + cmd = exec.Command("cargo", "check", "--message-format=short") + cmd.Dir = WorkspaceDir + default: + return fmt.Sprintf("Error: Cannot automatically detect language for '%s'. Pass language='go'|'typescript'|'javascript'|'python'|'rust'.", args.Path) + } + + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + result := strings.TrimSpace(string(out)) + if err == nil { + if result == "" { + return fmt.Sprintf("✓ No issues found in '%s'", args.Path) + } + return result + } + if result == "" { + return fmt.Sprintf("Check failed: %v", err) + } + return result +} diff --git a/dash/backend/tools/mcp.go b/dash/backend/tools/mcp.go new file mode 100644 index 0000000..003a609 --- /dev/null +++ b/dash/backend/tools/mcp.go @@ -0,0 +1,447 @@ +package tools + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os/exec" + "strings" + "sync" + "time" + + "github.com/danilrybalkin/apollo-dash/db" +) + +// MCPServerConfig defines a single MCP server connection +type MCPServerConfig struct { + Name string `json:"name"` + Command string `json:"command"` // e.g. "npx @modelcontextprotocol/server-postgres postgresql://..." + Transport string `json:"transport"` // "stdio" (default) or "http" + URL string `json:"url"` // for HTTP transport +} + +// MCPTool represents a tool discovered from an MCP server +type MCPTool struct { + Server string `json:"server"` + Name string `json:"name"` + Description string `json:"description"` + Schema map[string]interface{} `json:"schema"` +} + +var ( + mcpMu sync.RWMutex + mcpTools []MCPTool + mcpProcs = map[string]*mcpProcess{} +) + +type mcpProcess struct { + cfg MCPServerConfig + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Scanner + mu sync.Mutex + status string // "running", "crashed", "starting", "retrying" + lastError string + stopped bool +} + +// mcpRPCRequest sends a JSON-RPC message to the MCP server process (stdio or http) +func (p *mcpProcess) call(method string, params interface{}) (map[string]interface{}, error) { + if p.cfg.Transport == "http" { + return p.callHttp(method, params) + } + return p.callStdio(method, params) +} + +func (p *mcpProcess) callHttp(method string, params interface{}) (map[string]interface{}, error) { + reqID := time.Now().UnixNano() + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "id": reqID, + "method": method, + "params": params, + } + b, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", p.cfg.URL, strings.NewReader(string(b))) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var r map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, err + } + if errObj, ok := r["error"]; ok { + return nil, fmt.Errorf("MCP HTTP error: %v", errObj) + } + if result, ok := r["result"].(map[string]interface{}); ok { + return result, nil + } + return nil, fmt.Errorf("invalid MCP HTTP response") +} + +func (p *mcpProcess) callStdio(method string, params interface{}) (map[string]interface{}, error) { + p.mu.Lock() + defer p.mu.Unlock() + + reqID := time.Now().UnixNano() + req := map[string]interface{}{ + "jsonrpc": "2.0", + "id": reqID, + "method": method, + "params": params, + } + b, _ := json.Marshal(req) + _, err := fmt.Fprintf(p.stdin, "%s\n", string(b)) + if err != nil { + return nil, fmt.Errorf("write to MCP process failed: %w", err) + } + + // Read next line as response (MCP uses newline-delimited JSON-RPC) + if p.stdout.Scan() { + var resp map[string]interface{} + if err := json.Unmarshal([]byte(p.stdout.Text()), &resp); err != nil { + return nil, fmt.Errorf("invalid MCP response: %w", err) + } + if errObj, ok := resp["error"]; ok { + return nil, fmt.Errorf("MCP error: %v", errObj) + } + if result, ok := resp["result"].(map[string]interface{}); ok { + return result, nil + } + } + return nil, fmt.Errorf("no response from MCP server") +} + +// StartMCPServer launches an MCP server process and discovers its tools +func StartMCPServer(cfg MCPServerConfig) { + if cfg.Transport == "http" { + startHttpMCPServer(cfg) + return + } + startStdioMCPServer(cfg) +} + +func startHttpMCPServer(cfg MCPServerConfig) { + proc := &mcpProcess{ + cfg: cfg, + status: "running", + } + mcpMu.Lock() + mcpProcs[cfg.Name] = proc + mcpMu.Unlock() + + // Initialize + _, err := proc.call("initialize", map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{"name": "AgentHQ", "version": "1.0"}, + }) + if err != nil { + proc.status = "crashed" + proc.lastError = err.Error() + return + } + discoverTools(cfg.Name, proc) +} + +func startStdioMCPServer(cfg MCPServerConfig) { + go func() { + backoff := 1 * time.Second + for { + mcpMu.RLock() + p, ok := mcpProcs[cfg.Name] + mcpMu.RUnlock() + if ok && p.stopped { + return + } + + log.Printf("MCP: Starting stdio server '%s'...", cfg.Name) + proc, err := launchStdioProcess(cfg) + if err != nil { + log.Printf("MCP: Failed to launch '%s': %v", cfg.Name, err) + mcpMu.Lock() + mcpProcs[cfg.Name] = &mcpProcess{cfg: cfg, status: "crashed", lastError: err.Error()} + mcpMu.Unlock() + time.Sleep(backoff) + if backoff < 60*time.Second { + backoff *= 2 + } + continue + } + + mcpMu.Lock() + mcpProcs[cfg.Name] = proc + mcpMu.Unlock() + + // Initialize + _, err = proc.call("initialize", map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{"name": "AgentHQ", "version": "1.0"}, + }) + if err != nil { + log.Printf("MCP: Initialize failed for '%s': %v", cfg.Name, err) + proc.status = "crashed" + proc.lastError = err.Error() + proc.cmd.Process.Kill() + time.Sleep(backoff) + continue + } + + discoverTools(cfg.Name, proc) + backoff = 1 * time.Second // Reset backoff on success + + // Watchdog: wait for process to exit + err = proc.cmd.Wait() + log.Printf("MCP: Server '%s' exited: %v", cfg.Name, err) + + mcpMu.RLock() + isStopped := mcpProcs[cfg.Name].stopped + mcpMu.RUnlock() + if isStopped { + return + } + + mcpMu.Lock() + proc.status = "retrying" + if err != nil { + proc.lastError = err.Error() + } + mcpMu.Unlock() + + time.Sleep(backoff) + if backoff < 60*time.Second { + backoff *= 2 + } + } + }() +} + +func launchStdioProcess(cfg MCPServerConfig) (*mcpProcess, error) { + parts := strings.Fields(cfg.Command) + if len(parts) == 0 { + return nil, fmt.Errorf("empty command") + } + cmd := exec.Command(parts[0], parts[1:]...) + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + if err := cmd.Start(); err != nil { + return nil, err + } + + return &mcpProcess{ + cfg: cfg, + cmd: cmd, + stdin: stdin, + stdout: bufio.NewScanner(stdoutPipe), + status: "running", + }, nil +} + +func discoverTools(serverName string, proc *mcpProcess) { + toolsResult, err := proc.call("tools/list", map[string]interface{}{}) + if err != nil { + log.Printf("MCP: tools/list failed for '%s': %v", serverName, err) + return + } + + toolsList, _ := toolsResult["tools"].([]interface{}) + mcpMu.Lock() + // Clear old tools for this server + var filtered []MCPTool + for _, t := range mcpTools { + if t.Server != serverName { + filtered = append(filtered, t) + } + } + mcpTools = filtered + + for _, t := range toolsList { + toolMap, ok := t.(map[string]interface{}) + if !ok { + continue + } + name, _ := toolMap["name"].(string) + desc, _ := toolMap["description"].(string) + schema, _ := toolMap["inputSchema"].(map[string]interface{}) + mcpTools = append(mcpTools, MCPTool{ + Server: serverName, + Name: fmt.Sprintf("mcp_%s_%s", serverName, name), + Description: fmt.Sprintf("[MCP:%s] %s", serverName, desc), + Schema: schema, + }) + } + mcpMu.Unlock() +} + +// GetMCPTools returns discovered MCP tools (used by tools/registry.go via MCP integration) +func GetMCPTools() []MCPTool { + mcpMu.RLock() + defer mcpMu.RUnlock() + result := make([]MCPTool, len(mcpTools)) + copy(result, mcpTools) + return result +} + +// CallMCPTool executes an MCP tool by its registry name. +func CallMCPTool(toolName, rawArgs string) string { + // toolName format: mcp_{server}_{toolName} + parts := strings.SplitN(strings.TrimPrefix(toolName, "mcp_"), "_", 2) + if len(parts) != 2 { + return fmt.Sprintf("Error: invalid MCP tool name '%s'", toolName) + } + serverName, toolName := parts[0], parts[1] + + mcpMu.RLock() + proc, ok := mcpProcs[serverName] + mcpMu.RUnlock() + if !ok { + return fmt.Sprintf("Error: MCP server '%s' is not running.", serverName) + } + + var argsMap map[string]interface{} + json.Unmarshal([]byte(rawArgs), &argsMap) + + result, err := proc.call("tools/call", map[string]interface{}{ + "name": toolName, + "arguments": argsMap, + }) + if err != nil { + return fmt.Sprintf("MCP tool error: %v", err) + } + + // Extract text content from MCP response + if content, ok := result["content"].([]interface{}); ok { + var parts []string + for _, c := range content { + if m, ok := c.(map[string]interface{}); ok { + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "\n") + } + b, _ := json.Marshal(result) + return string(b) +} + +// MCPHandler: GET /api/mcp → list MCP tools, POST → configure new servers +func MCPHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + mcpMu.RLock() + tools := make([]MCPTool, len(mcpTools)) + copy(tools, mcpTools) + + serverSummaries := []map[string]interface{}{} + configs := getMCPServerConfigs() + for _, cfg := range configs { + summary := map[string]interface{}{ + "name": cfg.Name, + "transport": cfg.Transport, + "command": cfg.Command, + "url": cfg.URL, + "status": "offline", + "lastError": "", + } + if p, ok := mcpProcs[cfg.Name]; ok { + summary["status"] = p.status + summary["lastError"] = p.lastError + } + serverSummaries = append(serverSummaries, summary) + } + mcpMu.RUnlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "tools": tools, + "servers": serverSummaries, + }) + + case http.MethodPost: + var cfg MCPServerConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + // Persist to DB + b, _ := json.Marshal(cfg) + db.DB.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + fmt.Sprintf("mcp_server_%s", cfg.Name), string(b)) + // Start the server process + go StartMCPServer(cfg) + json.NewEncoder(w).Encode(map[string]string{"status": "starting", "name": cfg.Name}) + + case http.MethodDelete: + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + db.DB.Exec("DELETE FROM settings WHERE key = ?", fmt.Sprintf("mcp_server_%s", name)) + mcpMu.Lock() + if proc, ok := mcpProcs[name]; ok { + proc.stopped = true + if proc.cmd != nil && proc.cmd.Process != nil { + proc.cmd.Process.Kill() + } + delete(mcpProcs, name) + } + // Remove tools from this server + var filtered []MCPTool + for _, t := range mcpTools { + if t.Server != name { + filtered = append(filtered, t) + } + } + mcpTools = filtered + mcpMu.Unlock() + json.NewEncoder(w).Encode(map[string]string{"status": "removed"}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func getMCPServerConfigs() []MCPServerConfig { + rows, err := db.DB.Query("SELECT value FROM settings WHERE key LIKE 'mcp_server_%'") + if err != nil { + return nil + } + defer rows.Close() + var cfgs []MCPServerConfig + for rows.Next() { + var val string + if err := rows.Scan(&val); err == nil { + var cfg MCPServerConfig + if json.Unmarshal([]byte(val), &cfg) == nil { + cfgs = append(cfgs, cfg) + } + } + } + return cfgs +} + +// InitMCPServers loads and starts all persisted MCP servers at startup +func InitMCPServers() { + cfgs := getMCPServerConfigs() + for _, cfg := range cfgs { + go StartMCPServer(cfg) + } +} diff --git a/dash/backend/tools/registry.go b/dash/backend/tools/registry.go new file mode 100644 index 0000000..884e0f6 --- /dev/null +++ b/dash/backend/tools/registry.go @@ -0,0 +1,511 @@ +package tools + +import ( + "encoding/json" + "fmt" + "log" + "os/exec" + "strings" +) + +type ToolDefinition struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters struct { + Type string `json:"type"` + Properties map[string]interface{} `json:"properties"` + Required []string `json:"required,omitempty"` + } `json:"parameters"` + } `json:"function"` +} + +// Generate the array of JSON schemas attached to the stream payload +func GetAvailableTools() []ToolDefinition { + var registry []ToolDefinition + + // ====================================== + // 1. Sandboxed FS Tools + // ====================================== + fsList := ToolDefinition{Type: "function"} + fsList.Function.Name = "list_files" + fsList.Function.Description = "Lists files and directories in the AI Sandbox." + fsList.Function.Parameters.Type = "object" + fsList.Function.Parameters.Properties = map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Relative or absolute path. If empty, lists the Sandbox root.", + }, + } + registry = append(registry, fsList) + + fsRead := ToolDefinition{Type: "function"} + fsRead.Function.Name = "read_file" + fsRead.Function.Description = "Reads the textual contents of a file inside the AI Sandbox." + fsRead.Function.Parameters.Type = "object" + fsRead.Function.Parameters.Properties = map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Target file path.", + }, + } + fsRead.Function.Parameters.Required = []string{"path"} + registry = append(registry, fsRead) + + fsWrite := ToolDefinition{Type: "function"} + fsWrite.Function.Name = "write_file" + fsWrite.Function.Description = "Writes or overwrites a text file inside the AI Sandbox." + fsWrite.Function.Parameters.Type = "object" + fsWrite.Function.Parameters.Properties = map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Target file path.", + }, + "content": map[string]interface{}{ + "type": "string", + "description": "Full file content to write.", + }, + } + fsWrite.Function.Parameters.Required = []string{"path", "content"} + registry = append(registry, fsWrite) + + fsEdit := ToolDefinition{Type: "function"} + fsEdit.Function.Name = "edit_file" + fsEdit.Function.Description = "Modifies an existing file by replacing a unique search block with new content inside the Sandbox." + fsEdit.Function.Parameters.Type = "object" + fsEdit.Function.Parameters.Properties = map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Target file path.", + }, + "search": map[string]interface{}{ + "type": "string", + "description": "The exact block of code or text to find. It must be completely unique within the file.", + }, + "replace": map[string]interface{}{ + "type": "string", + "description": "The new content to replace the search block with.", + }, + } + fsEdit.Function.Parameters.Required = []string{"path", "search", "replace"} + registry = append(registry, fsEdit) + + fsGrep := ToolDefinition{Type: "function"} + fsGrep.Function.Name = "grep_search" + fsGrep.Function.Description = "Recursively searches all files in the Sandbox for a specific Regex pattern." + fsGrep.Function.Parameters.Type = "object" + fsGrep.Function.Parameters.Properties = map[string]interface{}{ + "pattern": map[string]interface{}{ + "type": "string", + "description": "Regex or string pattern to search for.", + }, + "path": map[string]interface{}{ + "type": "string", + "description": "Optional subdirectory to restrict search. Defaults to Sandbox root.", + }, + } + fsGrep.Function.Parameters.Required = []string{"pattern"} + registry = append(registry, fsGrep) + + addProject := ToolDefinition{Type: "function"} + addProject.Function.Name = "add_workspace_project" + addProject.Function.Description = "Creates a new project directory and permanently mounts it into the UI Workspace File Explorer." + addProject.Function.Parameters.Type = "object" + addProject.Function.Parameters.Properties = map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "The user-facing display name of the project.", + }, + "path": map[string]interface{}{ + "type": "string", + "description": "The folder path to create and mount.", + }, + "deploy_command": map[string]interface{}{ + "type": "string", + "description": "Optional terminal command to run when 'Deploy' is clicked.", + }, + } + addProject.Function.Parameters.Required = []string{"name", "path"} + registry = append(registry, addProject) + + // ====================================== + // User Interaction Toolkit + // ====================================== + askQ := ToolDefinition{Type: "function"} + askQ.Function.Name = "ask_user_question" + askQ.Function.Description = "Pauses AI execution to ask the user a specific question. Use this to gather architectural preferences, clarify requirements, or request a decision before drafting plans or writing code." + askQ.Function.Parameters.Type = "object" + askQ.Function.Parameters.Properties = map[string]interface{}{ + "question": map[string]interface{}{ + "type": "string", + "description": "The precise question to ask the user.", + }, + "options": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "An array of 2 to 5 distinct options for the user to choose from (e.g. ['React', 'Vue', 'Vanilla JS']).", + }, + } + askQ.Function.Parameters.Required = []string{"question", "options"} + registry = append(registry, askQ) + + // ====================================== + // 3. Sandboxed Terminal Execution + // ====================================== + termExec := ToolDefinition{Type: "function"} + termExec.Function.Name = "execute_command" + termExec.Function.Description = "Executes a raw bash terminal command strictly jailed within the AI Sandbox directory." + termExec.Function.Parameters.Type = "object" + termExec.Function.Parameters.Properties = map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + "description": "The exact shell command string (e.g. 'npm install react'). Avoid interactive commands like 'nano' or 'vim'.", + }, + } + termExec.Function.Parameters.Required = []string{"command"} + registry = append(registry, termExec) + + // ====================================== + // 4. Web Toolkit + // ====================================== + webScrape := ToolDefinition{Type: "function"} + webScrape.Function.Name = "web_scrape" + webScrape.Function.Description = "Downloads a specific webpage and extracts all plaintext paragraphs, stripping HTML junk." + webScrape.Function.Parameters.Type = "object" + webScrape.Function.Parameters.Properties = map[string]interface{}{ + "url": map[string]interface{}{ + "type": "string", + "description": "The exact 'https://' URL to scrape.", + }, + } + webScrape.Function.Parameters.Required = []string{"url"} + registry = append(registry, webScrape) + + // ====================================== + // 5. Version Control / Checkpointing + // ====================================== + undoOp := ToolDefinition{Type: "function"} + undoOp.Function.Name = "undo_checkpoint" + undoOp.Function.Description = "Physically reverts the entire Sandbox workspace back to its state prior to the last file modification or terminal command execution." + undoOp.Function.Parameters.Type = "object" + undoOp.Function.Parameters.Properties = map[string]interface{}{} + registry = append(registry, undoOp) + + // ====================================== + // 5b. Extended File Operations + // ====================================== + findFiles := ToolDefinition{Type: "function"} + findFiles.Function.Name = "find_files" + findFiles.Function.Description = "Finds files by name glob pattern within the workspace (e.g. '*.go', 'test_*', 'README.md'). Skips hidden dirs, node_modules, and vendor." + findFiles.Function.Parameters.Type = "object" + findFiles.Function.Parameters.Properties = map[string]interface{}{ + "pattern": map[string]interface{}{"type": "string", "description": "Glob pattern for file names, e.g. '*.go', 'test_*'."}, + "path": map[string]interface{}{"type": "string", "description": "Optional subdirectory to search within."}, + } + findFiles.Function.Parameters.Required = []string{"pattern"} + registry = append(registry, findFiles) + + renameFile := ToolDefinition{Type: "function"} + renameFile.Function.Name = "rename_file" + renameFile.Function.Description = "Renames or moves a file or directory within the workspace." + renameFile.Function.Parameters.Type = "object" + renameFile.Function.Parameters.Properties = map[string]interface{}{ + "from": map[string]interface{}{"type": "string", "description": "Source path (relative to workspace)."}, + "to": map[string]interface{}{"type": "string", "description": "Destination path (relative to workspace)."}, + } + renameFile.Function.Parameters.Required = []string{"from", "to"} + registry = append(registry, renameFile) + + checkCode := ToolDefinition{Type: "function"} + checkCode.Function.Name = "check_code" + checkCode.Function.Description = "Runs language-appropriate static analysis on a file or directory: go vet (Go), tsc --noEmit (TypeScript), eslint (JavaScript), flake8 (Python), cargo check (Rust). Auto-detects language from file extension." + checkCode.Function.Parameters.Type = "object" + checkCode.Function.Parameters.Properties = map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "File or directory path to analyze."}, + "language": map[string]interface{}{"type": "string", "description": "Optional: 'go'|'typescript'|'javascript'|'python'|'rust'. Auto-detected if omitted."}, + } + checkCode.Function.Parameters.Required = []string{"path"} + registry = append(registry, checkCode) + + contextTree := ToolDefinition{Type: "function"} + contextTree.Function.Name = "get_context_tree" + contextTree.Function.Description = "Native structural context tree of the current project. Returns folders/files, file headers, and optional symbols with line ranges." + contextTree.Function.Parameters.Type = "object" + contextTree.Function.Parameters.Properties = map[string]interface{}{ + "target_path": map[string]interface{}{"type": "string", "description": "Optional subpath to analyze (relative to project root)."}, + "depth_limit": map[string]interface{}{"type": "number", "description": "Optional directory depth limit."}, + "include_symbols": map[string]interface{}{"type": "boolean", "description": "Include symbol-level details (default true)."}, + "max_tokens": map[string]interface{}{"type": "number", "description": "Approximate output token cap. Tool auto-prunes if exceeded."}, + } + registry = append(registry, contextTree) + + fileSkeleton := ToolDefinition{Type: "function"} + fileSkeleton.Function.Name = "get_file_skeleton" + fileSkeleton.Function.Description = "Native file skeleton view. Returns signatures for functions/classes/types and line ranges without dumping full file bodies." + fileSkeleton.Function.Parameters.Type = "object" + fileSkeleton.Function.Parameters.Properties = map[string]interface{}{ + "file_path": map[string]interface{}{"type": "string", "description": "Target file path (relative to project root)."}, + } + fileSkeleton.Function.Parameters.Required = []string{"file_path"} + registry = append(registry, fileSkeleton) + + semanticSearch := ToolDefinition{Type: "function"} + semanticSearch.Function.Name = "semantic_code_search" + semanticSearch.Function.Description = "Native semantic code search with hybrid embedding + keyword ranking over project files." + semanticSearch.Function.Parameters.Type = "object" + semanticSearch.Function.Parameters.Properties = map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "Natural language search query."}, + "top_k": map[string]interface{}{"type": "number", "description": "Max number of matches to return."}, + "semantic_weight": map[string]interface{}{"type": "number", "description": "Weight for embedding similarity."}, + "keyword_weight": map[string]interface{}{"type": "number", "description": "Weight for keyword overlap score."}, + "min_semantic_score": map[string]interface{}{"type": "number", "description": "Minimum semantic score threshold (0-1 or 0-100)."}, + "min_keyword_score": map[string]interface{}{"type": "number", "description": "Minimum keyword score threshold (0-1 or 0-100)."}, + "min_combined_score": map[string]interface{}{"type": "number", "description": "Minimum combined score threshold (0-1 or 0-100)."}, + "require_keyword_match": map[string]interface{}{"type": "boolean", "description": "When true, discard results with no keyword overlap."}, + "require_semantic_match": map[string]interface{}{"type": "boolean", "description": "When true, discard results with no semantic match."}, + } + semanticSearch.Function.Parameters.Required = []string{"query"} + registry = append(registry, semanticSearch) + + semanticIdentifiers := ToolDefinition{Type: "function"} + semanticIdentifiers.Function.Name = "semantic_identifier_search" + semanticIdentifiers.Function.Description = "Native identifier-level semantic search for functions/classes/variables with ranked call sites." + semanticIdentifiers.Function.Parameters.Type = "object" + semanticIdentifiers.Function.Parameters.Properties = map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "Natural language query for identifier intent."}, + "top_k": map[string]interface{}{"type": "number", "description": "Max identifiers to return."}, + "top_calls_per_identifier": map[string]interface{}{"type": "number", "description": "Max call sites per identifier."}, + "include_kinds": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Optional kind filter list (e.g. function,class,variable)."}, + "semantic_weight": map[string]interface{}{"type": "number", "description": "Weight for embedding similarity."}, + "keyword_weight": map[string]interface{}{"type": "number", "description": "Weight for keyword score."}, + } + semanticIdentifiers.Function.Parameters.Required = []string{"query"} + registry = append(registry, semanticIdentifiers) + + blastRadius := ToolDefinition{Type: "function"} + blastRadius.Function.Name = "get_blast_radius" + blastRadius.Function.Description = "Native blast-radius analysis. Finds references/import-usage lines for a symbol across the project." + blastRadius.Function.Parameters.Type = "object" + blastRadius.Function.Parameters.Properties = map[string]interface{}{ + "symbol_name": map[string]interface{}{"type": "string", "description": "Symbol name to trace."}, + "file_context": map[string]interface{}{"type": "string", "description": "Optional defining file path to avoid counting definition line."}, + } + blastRadius.Function.Parameters.Required = []string{"symbol_name"} + registry = append(registry, blastRadius) + + staticAnalysis := ToolDefinition{Type: "function"} + staticAnalysis.Function.Name = "run_static_analysis" + staticAnalysis.Function.Description = "Native multi-language static analysis runner (go vet, tsc/eslint, py_compile, cargo check where applicable)." + staticAnalysis.Function.Parameters.Type = "object" + staticAnalysis.Function.Parameters.Properties = map[string]interface{}{ + "target_path": map[string]interface{}{"type": "string", "description": "Optional file/directory to scope analysis."}, + } + registry = append(registry, staticAnalysis) + + semanticNavigate := ToolDefinition{Type: "function"} + semanticNavigate.Function.Name = "semantic_navigate" + semanticNavigate.Function.Description = "Native semantic navigator that clusters project files into topic groups for high-level exploration." + semanticNavigate.Function.Parameters.Type = "object" + semanticNavigate.Function.Parameters.Properties = map[string]interface{}{ + "max_depth": map[string]interface{}{"type": "number", "description": "Optional cluster depth hint."}, + "max_clusters": map[string]interface{}{"type": "number", "description": "Maximum clusters to return."}, + } + registry = append(registry, semanticNavigate) + + featureHub := ToolDefinition{Type: "function"} + featureHub.Function.Name = "get_feature_hub" + featureHub.Function.Description = "Native feature-hub graph over markdown wikilinks. List hubs, inspect a hub, or detect orphaned code files." + featureHub.Function.Parameters.Type = "object" + featureHub.Function.Parameters.Properties = map[string]interface{}{ + "hub_path": map[string]interface{}{"type": "string", "description": "Optional explicit hub markdown path."}, + "feature_name": map[string]interface{}{"type": "string", "description": "Optional feature name to resolve to a hub."}, + "show_orphans": map[string]interface{}{"type": "boolean", "description": "If true, list source files not linked from hubs."}, + } + registry = append(registry, featureHub) + + proposeCommit := ToolDefinition{Type: "function"} + proposeCommit.Function.Name = "propose_commit" + proposeCommit.Function.Description = "Native guarded write operation. Creates a restore point, writes file content, and returns validation warnings." + proposeCommit.Function.Parameters.Type = "object" + proposeCommit.Function.Parameters.Properties = map[string]interface{}{ + "file_path": map[string]interface{}{"type": "string", "description": "File path to write (relative to project root)."}, + "new_content": map[string]interface{}{"type": "string", "description": "Full new file content."}, + } + proposeCommit.Function.Parameters.Required = []string{"file_path", "new_content"} + registry = append(registry, proposeCommit) + + listRestorePoints := ToolDefinition{Type: "function"} + listRestorePoints.Function.Name = "list_restore_points" + listRestorePoints.Function.Description = "List native restore points created before propose_commit writes." + listRestorePoints.Function.Parameters.Type = "object" + listRestorePoints.Function.Parameters.Properties = map[string]interface{}{} + registry = append(registry, listRestorePoints) + + undoChange := ToolDefinition{Type: "function"} + undoChange.Function.Name = "undo_change" + undoChange.Function.Description = "Restore files to the state captured by a native restore point ID." + undoChange.Function.Parameters.Type = "object" + undoChange.Function.Parameters.Properties = map[string]interface{}{ + "point_id": map[string]interface{}{"type": "string", "description": "Restore point ID from list_restore_points."}, + } + undoChange.Function.Parameters.Required = []string{"point_id"} + registry = append(registry, undoChange) + + webSearch := ToolDefinition{Type: "function"} + webSearch.Function.Name = "web_search" + webSearch.Function.Description = "Searches the internet using DuckDuckGo. Use to find documentation, look up error messages, research libraries, or find solutions to problems." + webSearch.Function.Parameters.Type = "object" + webSearch.Function.Parameters.Properties = map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "The search query string."}, + } + webSearch.Function.Parameters.Required = []string{"query"} + registry = append(registry, webSearch) + + // ====================================== + // 5c. Skills System + // ====================================== + loadSkill := ToolDefinition{Type: "function"} + loadSkill.Function.Name = "load_skill" + loadSkill.Function.Description = "Loads the full content of a named skill file. Skills provide deep specialized knowledge (debugging patterns, architectural recipes, language idioms) loaded on demand." + loadSkill.Function.Parameters.Type = "object" + loadSkill.Function.Parameters.Properties = map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "The skill name (e.g. 'go_debugging', 'react_patterns', 'git_workflow')."}, + } + loadSkill.Function.Parameters.Required = []string{"name"} + registry = append(registry, loadSkill) + + // ====================================== + // 6. Background Subagents + // ====================================== + spawnAgent := ToolDefinition{Type: "function"} + spawnAgent.Function.Name = "spawn_subagent" + spawnAgent.Function.Description = "Spawns a background AI subagent to handle a long-running task independently (research, code review, testing, etc.) without blocking the main conversation. Returns a subagent ID." + spawnAgent.Function.Parameters.Type = "object" + spawnAgent.Function.Parameters.Properties = map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "A short, descriptive name for this subagent (e.g., 'Researcher-1', 'CodeReviewer')."}, + "task": map[string]interface{}{"type": "string", "description": "A precise, self-contained task description for the subagent."}, + "session_id": map[string]interface{}{"type": "string", "description": "The current session ID so the subagent can notify the parent conversation when done."}, + } + spawnAgent.Function.Parameters.Required = []string{"name", "task"} + registry = append(registry, spawnAgent) + + getAgentStatus := ToolDefinition{Type: "function"} + getAgentStatus.Function.Name = "get_subagent_status" + getAgentStatus.Function.Description = "Checks the status and output of a spawned background subagent by its ID." + getAgentStatus.Function.Parameters.Type = "object" + getAgentStatus.Function.Parameters.Properties = map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "The subagent ID returned by spawn_subagent."}, + } + getAgentStatus.Function.Parameters.Required = []string{"id"} + registry = append(registry, getAgentStatus) + + // ====================================== + // 8. Dynamic MCP Tools + // ====================================== + mcpTools := GetMCPTools() + for _, mt := range mcpTools { + mTool := ToolDefinition{Type: "function"} + mTool.Function.Name = mt.Name + mTool.Function.Description = mt.Description + mTool.Function.Parameters.Type = "object" + mTool.Function.Parameters.Properties = map[string]interface{}{} + if propsRaw, ok := mt.Schema["properties"]; ok { + if props, ok := propsRaw.(map[string]interface{}); ok { + mTool.Function.Parameters.Properties = props + } + } + if req, ok := mt.Schema["required"].([]interface{}); ok { + for _, r := range req { + if rs, ok := r.(string); ok { + mTool.Function.Parameters.Required = append(mTool.Function.Parameters.Required, rs) + } + } + } + registry = append(registry, mTool) + } + + return registry +} + +func executeUndoCheckpoint(rawArgs string) string { + cmd := exec.Command("git", "reset", "--hard", "HEAD~1") + cmd.Dir = WorkspaceDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Sprintf("Error reverting checkpoint: %v\nOutput: %s", err, string(out)) + } + return fmt.Sprintf("Success: Sandbox reverted to previous checkpoint.\n%s", string(out)) +} + +// Routes tool call arguments to the strict Go function implementations +func ExecuteTool(name string, rawArgs string, projectRoot string) string { + var args map[string]interface{} + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return fmt.Sprintf("Error: Invalid JSON arguments passed to %s", name) + } + + log.Printf("Tool Dispatcher: Executing '%s'", name) + + switch name { + case "list_files": + return executeListFiles(rawArgs, projectRoot) + case "read_file": + return executeReadFile(rawArgs, projectRoot) + case "write_file": + return executeWriteFile(rawArgs, projectRoot) + case "edit_file": + return executeEditFile(rawArgs, projectRoot) + case "add_workspace_project": + return executeAddWorkspaceProject(rawArgs, projectRoot) + case "grep_search": + return executeGrepSearch(rawArgs, projectRoot) + case "execute_command": + return executeCommand(rawArgs, projectRoot) + case "undo_checkpoint": + return executeUndoCheckpoint(rawArgs) + case "web_scrape": + return executeWebScrape(rawArgs) + case "web_search": + return executeWebSearch(rawArgs) + case "find_files": + return executeFindFiles(rawArgs, projectRoot) + case "rename_file": + return executeRenameFile(rawArgs, projectRoot) + case "check_code": + return executeCheckCode(rawArgs, projectRoot) + case "get_context_tree": + return executeGetContextTree(rawArgs, projectRoot) + case "get_file_skeleton": + return executeGetFileSkeleton(rawArgs, projectRoot) + case "semantic_code_search": + return executeSemanticCodeSearch(rawArgs, projectRoot) + case "semantic_identifier_search": + return executeSemanticIdentifierSearch(rawArgs, projectRoot) + case "get_blast_radius": + return executeGetBlastRadius(rawArgs, projectRoot) + case "run_static_analysis": + return executeRunStaticAnalysisNative(rawArgs, projectRoot) + case "semantic_navigate": + return executeSemanticNavigate(rawArgs, projectRoot) + case "get_feature_hub": + return executeGetFeatureHub(rawArgs, projectRoot) + case "propose_commit": + return executeProposeCommitNative(rawArgs, projectRoot) + case "list_restore_points": + return executeListRestorePointsNative(rawArgs, projectRoot) + case "undo_change": + return executeUndoChangeNative(rawArgs, projectRoot) + case "load_skill": + return executeLoadSkill(rawArgs) + case "spawn_subagent": + return executeSpawnSubagent(rawArgs) + case "get_subagent_status": + return executeGetSubagentStatus(rawArgs) + default: + if strings.HasPrefix(name, "mcp_") { + return CallMCPTool(name, rawArgs) + } + return fmt.Sprintf("Error: Tool '%s' is not registered.", name) + } +} diff --git a/dash/backend/tools/skills.go b/dash/backend/tools/skills.go new file mode 100644 index 0000000..bf64bb9 --- /dev/null +++ b/dash/backend/tools/skills.go @@ -0,0 +1,169 @@ +package tools + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +var SkillsDir = filepath.Join(WorkspaceDir, ".skills") + +func resolveBundledSkillsDir() string { + candidates := []string{ + "skills", // run from backend/ + "backend/skills", // run from repo root + "dash/backend/skills", // run from workspace root containing dash/ + } + + if exePath, err := os.Executable(); err == nil && exePath != "" { + exeDir := filepath.Dir(exePath) + candidates = append(candidates, + filepath.Join(exeDir, "skills"), + filepath.Join(exeDir, "..", "skills"), + filepath.Join(exeDir, "..", "backend", "skills"), + ) + } + + for _, d := range candidates { + if info, err := os.Stat(d); err == nil && info.IsDir() { + return d + } + } + return "" +} + +func copySkillFile(srcPath string, dstPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, src) + return err +} + +// SyncBundledSkills ensures bundled skills are present in the runtime workspace library. +// Existing runtime skill files are preserved and not overwritten. +func SyncBundledSkills() { + _ = os.MkdirAll(SkillsDir, 0755) + + srcDir := resolveBundledSkillsDir() + if srcDir == "" { + return + } + if filepath.Clean(srcDir) == filepath.Clean(SkillsDir) { + return + } + + entries, err := os.ReadDir(srcDir) + if err != nil { + return + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".md") { + continue + } + dstPath := filepath.Join(SkillsDir, e.Name()) + if info, err := os.Stat(dstPath); err == nil && !info.IsDir() { + continue // keep user-updated runtime copy + } + _ = copySkillFile(filepath.Join(srcDir, e.Name()), dstPath) + } +} + +// resolveSkillsDir returns the first skills directory that actually exists. +// Priority: runtime workspace dir (auto-seeded from bundled skills) → local fallback. +func resolveSkillsDir() string { + SyncBundledSkills() + + candidates := []string{ + SkillsDir, + "skills", // relative to cwd — used when binary runs from backend/ + } + for _, d := range candidates { + if info, err := os.Stat(d); err == nil && info.IsDir() { + return d + } + } + return SkillsDir // fallback — will return empty manifest +} + +// GetSkillsManifest returns a compact list of skill names and descriptions for the system prompt +func GetSkillsManifest() string { + dir := resolveSkillsDir() + entries, err := os.ReadDir(dir) + if err != nil { + return "" // Skills dir doesn't exist yet — silently skip + } + var lines []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + // Extract description from frontmatter (line starting with "description:") + desc := "" + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "description:") { + desc = strings.TrimSpace(strings.TrimPrefix(line, "description:")) + break + } + } + name := strings.TrimSuffix(e.Name(), ".md") + if desc != "" { + lines = append(lines, fmt.Sprintf("- %s: %s", name, desc)) + } else { + lines = append(lines, fmt.Sprintf("- %s", name)) + } + } + if len(lines) == 0 { + return "" + } + return "AVAILABLE SKILLS (call load_skill(name) to get full content when needed):\n" + strings.Join(lines, "\n") +} + +// executeLoadSkill reads the full content of a skill file by name +func executeLoadSkill(rawArgs string) string { + var args struct { + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Name == "" { + return "Error: 'name' is required for load_skill." + } + + name := strings.TrimSuffix(strings.TrimSpace(args.Name), ".md") + dir := resolveSkillsDir() + path := filepath.Join(dir, name+".md") + + data, err := os.ReadFile(path) + if err != nil { + entries, _ := os.ReadDir(dir) + var available []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + available = append(available, strings.TrimSuffix(e.Name(), ".md")) + } + } + if len(available) > 0 { + return fmt.Sprintf("Skill '%s' not found. Available skills: %s", name, strings.Join(available, ", ")) + } + return fmt.Sprintf("Skill '%s' not found. No skills are installed yet.", name) + } + + return fmt.Sprintf("=== SKILL: %s ===\n\n%s", name, string(data)) +} diff --git a/dash/backend/tools/subagents.go b/dash/backend/tools/subagents.go new file mode 100644 index 0000000..a571653 --- /dev/null +++ b/dash/backend/tools/subagents.go @@ -0,0 +1,56 @@ +package tools + +import ( + "encoding/json" + "fmt" +) + +// SubagentSpawner is a function variable injected from handlers to avoid circular imports +// Set via tools.SetSubagentFuncs() in main.go +var SubagentSpawner func(sessionID, name, task string) string +var SubagentStatusGetter func(id string) (status, output string) + +// SetSubagentFuncs injects the handler-level subagent functions into the tools package +func SetSubagentFuncs(spawner func(string, string, string) string, getter func(string) (string, string)) { + SubagentSpawner = spawner + SubagentStatusGetter = getter +} + +func executeSpawnSubagent(rawArgs string) string { + var args map[string]interface{} + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: Invalid arguments for spawn_subagent" + } + name, _ := args["name"].(string) + task, _ := args["task"].(string) + sessionID, _ := args["session_id"].(string) + + if name == "" || task == "" { + return "Error: 'name' and 'task' are required for spawn_subagent" + } + if SubagentSpawner == nil { + return "Error: Subagent system is not initialized" + } + id := SubagentSpawner(sessionID, name, task) + return fmt.Sprintf(`{"id": "%s", "status": "running", "message": "Subagent '%s' spawned successfully. Use get_subagent_status('%s') to check progress."}`, id, name, id) +} + +func executeGetSubagentStatus(rawArgs string) string { + var args map[string]interface{} + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { + return "Error: Invalid arguments for get_subagent_status" + } + id, _ := args["id"].(string) + if id == "" { + return "Error: 'id' is required for get_subagent_status" + } + if SubagentStatusGetter == nil { + return "Error: Subagent system is not initialized" + } + status, output := SubagentStatusGetter(id) + if status == "" { + return fmt.Sprintf(`{"error": "Subagent '%s' not found"}`, id) + } + result, _ := json.Marshal(map[string]string{"id": id, "status": status, "output": output}) + return string(result) +} diff --git a/dash/backend/tools/web.go b/dash/backend/tools/web.go new file mode 100644 index 0000000..e0eeb21 --- /dev/null +++ b/dash/backend/tools/web.go @@ -0,0 +1,110 @@ +package tools + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/microcosm-cc/bluemonday" +) + +// Extracts paragraphs of raw text from an arbitrary URL and strips HTML tags +func executeWebScrape(rawArgs string) string { + var args struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.URL == "" { + return "Error: Invalid arguments. 'url' is required." + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(func() *http.Request { + req, _ := http.NewRequest("GET", args.URL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AgentHQ/1.0 Agentic Proxy") + return req + }()) + + if err != nil { + return fmt.Sprintf("Error fetching URL: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Sprintf("Error: Website returned HTTP %d", resp.StatusCode) + } + + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "Error reading response body" + } + + // Use Bluemonday strict policy to strip EVERYTHING except raw text + p := bluemonday.StrictPolicy() + cleanHtml := p.Sanitize(string(bodyBytes)) + + // Some basic whitespace cleanup since stripping tags leaves massive blank gaps + lines := strings.Split(cleanHtml, "\n") + var out []string + for _, l := range lines { + trimmed := strings.TrimSpace(l) + if trimmed != "" { + out = append(out, trimmed) + } + } + + finalText := strings.Join(out, "\n") + if len(finalText) > 15000 { + return finalText[:15000] + "\n\n... [WEBPAGE TRUNCATED FOR LENGTH LIMITS]" + } + + return finalText +} + +// executeWebSearch queries DuckDuckGo Lite for search results (no API key required) +func executeWebSearch(rawArgs string) string { + var args struct { + Query string `json:"query"` + } + if err := json.Unmarshal([]byte(rawArgs), &args); err != nil || args.Query == "" { + return "Error: 'query' is required." + } + + client := &http.Client{Timeout: 15 * time.Second} + searchURL := "https://lite.duckduckgo.com/lite/?q=" + strings.ReplaceAll(strings.TrimSpace(args.Query), " ", "+") + + req, _ := http.NewRequest("GET", searchURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; AgentHQ/1.0)") + req.Header.Set("Accept", "text/html") + + resp, err := client.Do(req) + if err != nil { + return fmt.Sprintf("Error performing search: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := ioutil.ReadAll(resp.Body) + + p := bluemonday.StrictPolicy() + clean := p.Sanitize(string(bodyBytes)) + + // Collect non-empty lines, limit to top results + lines := strings.Split(clean, "\n") + var results []string + for _, l := range lines { + t := strings.TrimSpace(l) + if len(t) > 20 { + results = append(results, t) + } + if len(results) >= 40 { + break + } + } + + if len(results) == 0 { + return "No search results found." + } + return fmt.Sprintf("Search results for: %s\n\n%s", args.Query, strings.Join(results, "\n")) +} diff --git a/dash/deploy.sh b/dash/deploy.sh new file mode 100755 index 0000000..80ab956 --- /dev/null +++ b/dash/deploy.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +echo "=== AgentHQ Deploy ===" + +echo "→ Building frontend..." +cd "$(dirname "$0")/frontend" +npm run build + +echo "→ Syncing frontend..." +rsync -az --delete frontend/dist/ prod:/home/deploy/agenthq/frontend/dist/ + +echo "→ Syncing backend source..." +rsync -az --delete \ + --exclude='*.db' \ + --exclude='agenthq-server' \ + --exclude='.env' \ + --exclude='workspace/' \ + backend/ \ + prod:/home/deploy/agenthq/backend/ + +echo "→ Building binary on server..." +ssh prod "cd /home/deploy/agenthq/backend && CGO_ENABLED=1 /usr/local/go/bin/go build -o agenthq-server ." + +echo "→ Restarting service..." +ssh prod "sudo systemctl restart agenthq && sleep 2 && sudo systemctl status agenthq --no-pager | head -8" + +echo "=== Done: https://agenthq.one ===" diff --git a/dash/frontend/.gitignore b/dash/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/dash/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/dash/frontend/README.md b/dash/frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/dash/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/dash/frontend/eslint.config.js b/dash/frontend/eslint.config.js new file mode 100644 index 0000000..fc20f56 --- /dev/null +++ b/dash/frontend/eslint.config.js @@ -0,0 +1,32 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: { ...globals.browser, ...globals.node }, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { + argsIgnorePattern: '^[A-Z_]', + varsIgnorePattern: '^[A-Z_]', + }], + }, + }, +]) diff --git a/dash/frontend/index.html b/dash/frontend/index.html new file mode 100644 index 0000000..59f70e5 --- /dev/null +++ b/dash/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Apollo + + +
+ + + diff --git a/dash/frontend/package-lock.json b/dash/frontend/package-lock.json new file mode 100644 index 0000000..f7e4b7b --- /dev/null +++ b/dash/frontend/package-lock.json @@ -0,0 +1,4574 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "d3-ease": "^3.0.1", + "d3-path": "^3.1.0", + "lucide-react": "^0.511.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.0.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.407", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", + "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/monaco-editor": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.4.8", + "marked": "14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/dash/frontend/package.json b/dash/frontend/package.json new file mode 100644 index 0000000..3838c10 --- /dev/null +++ b/dash/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "overrides": { + "dompurify": "3.4.13" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "d3-ease": "^3.0.1", + "d3-path": "^3.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "lucide-react": "^0.511.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.0.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^7.3.1" + } +} diff --git a/dash/frontend/public/android-chrome-192x192.png b/dash/frontend/public/android-chrome-192x192.png new file mode 100644 index 0000000..4ae0a87 Binary files /dev/null and b/dash/frontend/public/android-chrome-192x192.png differ diff --git a/dash/frontend/public/android-chrome-512x512.png b/dash/frontend/public/android-chrome-512x512.png new file mode 100644 index 0000000..0486bb5 Binary files /dev/null and b/dash/frontend/public/android-chrome-512x512.png differ diff --git a/dash/frontend/public/apple-touch-icon.png b/dash/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..695689b Binary files /dev/null and b/dash/frontend/public/apple-touch-icon.png differ diff --git a/dash/frontend/public/favicon-16x16.png b/dash/frontend/public/favicon-16x16.png new file mode 100644 index 0000000..2f264fd Binary files /dev/null and b/dash/frontend/public/favicon-16x16.png differ diff --git a/dash/frontend/public/favicon-32x32.png b/dash/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..d335362 Binary files /dev/null and b/dash/frontend/public/favicon-32x32.png differ diff --git a/dash/frontend/public/favicon.ico b/dash/frontend/public/favicon.ico new file mode 100644 index 0000000..abcd629 Binary files /dev/null and b/dash/frontend/public/favicon.ico differ diff --git a/dash/frontend/public/site.webmanifest b/dash/frontend/public/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/dash/frontend/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/dash/frontend/public/vite.svg b/dash/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/dash/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dash/frontend/src/App.jsx b/dash/frontend/src/App.jsx new file mode 100644 index 0000000..774b72a --- /dev/null +++ b/dash/frontend/src/App.jsx @@ -0,0 +1,41 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider } from './lib/auth' +import { useAuth } from './lib/useAuth' +import Landing from './pages/Landing' +import Auth from './pages/Auth' +import Account from './pages/Account' +import Dashboard from './pages/Dashboard' +import Admin from './pages/Admin' + +function ProtectedRoute({ children }) { + const { token } = useAuth() + if (!token) return + return children +} + +function AppRoutes() { + return ( + + } /> + } /> + + } /> + + } /> + } /> + } /> + + ) +} + +export default function App() { + return ( + + + + + + ) +} diff --git a/dash/frontend/src/components/AgentOSShell.jsx b/dash/frontend/src/components/AgentOSShell.jsx new file mode 100644 index 0000000..679451f --- /dev/null +++ b/dash/frontend/src/components/AgentOSShell.jsx @@ -0,0 +1,1549 @@ +import { useEffect, useMemo, useState } from 'react' +import { Activity, Bot, Brain, CalendarDays, CheckCircle2, MessageSquare, Plus, ShieldCheck, Sparkles } from 'lucide-react' + +function isNotFoundError(err) { + const message = String(err?.message || '') + return message.startsWith('404') || message.toLowerCase().includes('404') +} + +async function api(path, options = {}) { + const token = localStorage.getItem('agenthq_token') + const headers = { + ...(options.headers || {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + } + const res = await fetch(path, { ...options, headers }) + if (!res.ok) { + const text = await res.text() + throw new Error(`${res.status} ${text || res.statusText || `HTTP ${res.status}`}`.trim()) + } + return res.json() +} + +async function apiOrDefault(path, fallbackValue) { + try { + return await api(path) + } catch (err) { + if (isNotFoundError(err)) { + return fallbackValue + } + throw new Error(`${path}: ${err.message}`) + } +} + +function EmptyAction({ title, body, action, onClick, disabled = false }) { + return ( +
+
+
+ {title} + {body} +
+ {action && ( + + )} +
+ ) +} + +function OverviewMetric({ icon: Icon, label, value, tone = 'neutral' }) { + return ( +
+
+ {label} + {value} +
+ ) +} + +export default function AgentOSShell({ activeTab, setActiveTab }) { + const [companies, setCompanies] = useState([]) + const [departments, setDepartments] = useState([]) + const [agents, setAgents] = useState([]) + const [tasks, setTasks] = useState([]) + + const [threads, setThreads] = useState([]) + const [messages, setMessages] = useState([]) + const [schedules, setSchedules] = useState([]) + const [events, setEvents] = useState([]) + const [memoryTimeline, setMemoryTimeline] = useState([]) + const [memoryHits, setMemoryHits] = useState([]) + const [policies, setPolicies] = useState([]) + const [approvals, setApprovals] = useState([]) + const [auditVerify, setAuditVerify] = useState(null) + + const [modelProfiles, setModelProfiles] = useState([]) + const [selectedProfileId, setSelectedProfileId] = useState('') + + const [selectedCompanyId, setSelectedCompanyId] = useState('') + const [selectedDepartmentId, setSelectedDepartmentId] = useState('') + const [selectedAgentId, setSelectedAgentId] = useState('') + const [selectedThreadId, setSelectedThreadId] = useState('') + + const [chatInput, setChatInput] = useState('') + const [newThreadTitle, setNewThreadTitle] = useState('') + const [memoryQuery, setMemoryQuery] = useState('') + const [scheduleExpr, setScheduleExpr] = useState('*/30 * * * *') +const [scheduleMode, setScheduleMode] = useState('cron') +const [scheduleOnce, setScheduleOnce] = useState('') +const [scheduleMessage, setScheduleMessage] = useState('') +const [interAgentInbox, setInterAgentInbox] = useState([]) +const [interAgentTo, setInterAgentTo] = useState('') +const [interAgentContent, setInterAgentContent] = useState('') + const [statusMessage, setStatusMessage] = useState('') + const [lastEventId, setLastEventId] = useState(0) + + const [companyWorkspacePath, setCompanyWorkspacePath] = useState('') + const [companyDeployCommand, setCompanyDeployCommand] = useState('') + + const [modalType, setModalType] = useState('') + const [modalCompanyId, setModalCompanyId] = useState('') + const [modalDepartmentId, setModalDepartmentId] = useState('') + + const [newCompanyName, setNewCompanyName] = useState('') + const [newCompanyPath, setNewCompanyPath] = useState('') + const [newCompanyDeployCommand, setNewCompanyDeployCommand] = useState('') + const [newDepartmentName, setNewDepartmentName] = useState('') + const [newAgentName, setNewAgentName] = useState('') + const [newAgentRole, setNewAgentRole] = useState('worker') + + const companyByID = useMemo(() => { + const map = {} + for (const company of companies) { + map[company.id] = company + } + return map + }, [companies]) + + const departmentByID = useMemo(() => { + const map = {} + for (const department of departments) { + map[department.id] = department + } + return map + }, [departments]) + + const agentByID = useMemo(() => { + const map = {} + for (const agent of agents) { + map[agent.id] = agent + } + return map + }, [agents]) + + const selectedCompany = useMemo( + () => companies.find((c) => c.id === selectedCompanyId) || null, + [companies, selectedCompanyId], + ) + + const selectedAgent = useMemo( + () => agents.find((a) => a.id === selectedAgentId) || null, + [agents, selectedAgentId], + ) + + const agentsByDepartment = useMemo(() => { + const grouped = {} + for (const agent of agents) { + if (!grouped[agent.department_id]) { + grouped[agent.department_id] = [] + } + grouped[agent.department_id].push(agent) + } + return grouped + }, [agents]) + + const departmentsByCompany = useMemo(() => { + const grouped = {} + for (const department of departments) { + if (!grouped[department.company_id]) { + grouped[department.company_id] = [] + } + grouped[department.company_id].push(department) + } + return grouped + }, [departments]) + + const agentsByCompany = useMemo(() => { + const grouped = {} + for (const agent of agents) { + if (!grouped[agent.company_id]) { + grouped[agent.company_id] = [] + } + grouped[agent.company_id].push(agent) + } + return grouped + }, [agents]) + + const tasksByCompany = useMemo(() => { + const grouped = {} + for (const task of tasks) { + const companyID = task.company_id || agentByID[task.agent_id]?.company_id || '' + if (!companyID) continue + if (!grouped[companyID]) { + grouped[companyID] = [] + } + grouped[companyID].push(task) + } + return grouped + }, [tasks, agentByID]) + + const tasksByDepartment = useMemo(() => { + const grouped = {} + for (const task of tasks) { + const departmentID = task.department_id || agentByID[task.agent_id]?.department_id || '' + if (!departmentID) continue + if (!grouped[departmentID]) { + grouped[departmentID] = [] + } + grouped[departmentID].push(task) + } + return grouped + }, [tasks, agentByID]) + + const scheduleEvents = useMemo( + () => events.filter((e) => String(e?.event_type || '').toLowerCase().includes('schedule')).slice(-16).reverse(), + [events], + ) + + const runningTasks = useMemo(() => tasks.filter((task) => task.status === 'running'), [tasks]) + const activeSchedules = useMemo(() => schedules.filter((schedule) => schedule.is_active), [schedules]) + const departmentsInModalCompany = useMemo( + () => departments.filter((department) => department.company_id === modalCompanyId), + [departments, modalCompanyId], + ) + + const loadCompanies = async () => { + try { + const data = await api('/api/companies') + setCompanies(data || []) + } catch (err) { + if (!isNotFoundError(err)) { + throw err + } + setCompanies([]) + } + } + + const loadDirectoryData = async () => { + const [deps, ags, tks] = await Promise.all([ + apiOrDefault('/api/departments', []), + apiOrDefault('/api/agents', []), + apiOrDefault('/api/tasks?limit=300', []), + ]) + setDepartments(deps || []) + setAgents(ags || []) + setTasks(tks || []) + } + + const loadModelProfiles = async () => { + try { + const data = await api('/api/model-profiles') + setModelProfiles(data || []) + if (data?.length && !selectedProfileId) { + setSelectedProfileId(data[0].id) + } + } catch (err) { + if (!isNotFoundError(err)) { + throw err + } + setModelProfiles([]) + } + } + + const loadCompanyRuntimeData = async (companyID) => { + if (!companyID) { + setSchedules([]) + setMemoryTimeline([]) + setEvents([]) + setLastEventId(0) + return + } + const [sch, mem, evs] = await Promise.all([ + api(`/api/schedules?company_id=${encodeURIComponent(companyID)}`), + api(`/api/memory/timeline?company_id=${encodeURIComponent(companyID)}&limit=80`), + api(`/api/events?company_id=${encodeURIComponent(companyID)}&since_id=0&limit=120`), + ]) + setSchedules(sch || []) + setMemoryTimeline(mem || []) + setEvents(evs || []) + const maxID = (evs || []).reduce((maxValue, item) => Math.max(maxValue, item?.id || 0), 0) + setLastEventId(maxID) + } + + const loadThreads = async (agentID) => { + if (!agentID) { + setThreads([]) + setSelectedThreadId('') + setMessages([]) + return + } + const data = await api(`/api/threads?agent_id=${encodeURIComponent(agentID)}`) + setThreads(data || []) + if (data?.length) { + setSelectedThreadId((previousID) => (previousID && data.find((item) => item.id === previousID) ? previousID : data[0].id)) + } else { + setSelectedThreadId('') + setMessages([]) + } + } + + const loadMessages = async (threadID) => { + if (!threadID) { + setMessages([]) + return + } + const data = await api(`/api/threads/${encodeURIComponent(threadID)}/messages`) + setMessages(data || []) + } + + const loadGovernance = async () => { + if (!selectedCompanyId) { + setPolicies([]) + setApprovals([]) + setAuditVerify(null) + return + } + const [ps, aps, verify] = await Promise.allSettled([ + api(`/api/policies?company_id=${encodeURIComponent(selectedCompanyId)}`), + api(`/api/approvals?company_id=${encodeURIComponent(selectedCompanyId)}&status=pending`), + api('/api/audit/verify'), + ]) + setPolicies(ps.status === 'fulfilled' ? (ps.value || []) : []) + setApprovals(aps.status === 'fulfilled' ? (aps.value || []) : []) + setAuditVerify(verify.status === 'fulfilled' ? (verify.value || null) : null) + } + + async function loadAgentInbox(agentId, companyId) { + if (!agentId || !companyId) return + const msgs = await api(`/api/inter-agent?agent_id=${encodeURIComponent(agentId)}&company_id=${encodeURIComponent(companyId)}`) + setInterAgentInbox(msgs || []) + } + + useEffect(() => { + Promise.allSettled([loadCompanies(), loadDirectoryData(), loadModelProfiles()]).then((results) => { + const failures = results + .filter((result) => result.status === 'rejected') + .map((result) => result.reason) + if (failures.length === 0) { + return + } + const non404 = failures.filter((err) => !isNotFoundError(err)) + if (non404.length === 0) { + setStatusMessage('Some Agent OS endpoints are not available yet. Rebuild/restart backend to enable full features.') + return + } + setStatusMessage(`Failed to load bootstrap data: ${non404[0]?.message || non404[0]}`) + }) + // Bootstrap helpers intentionally run once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (companies.length === 0) { + if (selectedCompanyId) setSelectedCompanyId('') + if (selectedDepartmentId) setSelectedDepartmentId('') + if (selectedAgentId) setSelectedAgentId('') + return + } + + if (!companies.some((company) => company.id === selectedCompanyId)) { + setSelectedCompanyId(companies[0].id) + return + } + + const scopedDepartments = departments.filter((department) => department.company_id === selectedCompanyId) + if (scopedDepartments.length === 0) { + if (selectedDepartmentId) setSelectedDepartmentId('') + if (selectedAgentId) setSelectedAgentId('') + return + } + + if (!scopedDepartments.some((department) => department.id === selectedDepartmentId)) { + setSelectedDepartmentId(scopedDepartments[0].id) + return + } + + const scopedAgents = agents.filter((agent) => agent.department_id === selectedDepartmentId) + if (scopedAgents.length === 0) { + if (selectedAgentId) setSelectedAgentId('') + return + } + + if (!scopedAgents.some((agent) => agent.id === selectedAgentId)) { + setSelectedAgentId(scopedAgents[0].id) + } + }, [companies, departments, agents, selectedCompanyId, selectedDepartmentId, selectedAgentId]) + + useEffect(() => { + loadCompanyRuntimeData(selectedCompanyId).catch((err) => setStatusMessage(`Failed to load company data: ${err.message}`)) + }, [selectedCompanyId]) + + useEffect(() => { + setCompanyWorkspacePath(selectedCompany?.workspace_path || '') + setCompanyDeployCommand(selectedCompany?.deploy_command || '') + }, [selectedCompany?.id, selectedCompany?.workspace_path, selectedCompany?.deploy_command]) + + useEffect(() => { + loadThreads(selectedAgentId).catch((err) => setStatusMessage(`Failed to load threads: ${err.message}`)) + loadAgentInbox(selectedAgentId, selectedCompanyId).catch(() => {}) + }, [selectedAgentId, selectedCompanyId]) + + useEffect(() => { + loadMessages(selectedThreadId).catch((err) => setStatusMessage(`Failed to load messages: ${err.message}`)) + }, [selectedThreadId]) + + useEffect(() => { + if (activeTab !== 'governance') return + loadGovernance().catch((err) => setStatusMessage(`Failed to load governance data: ${err.message}`)) + // selected inputs control refresh; helper is recreated on render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, selectedCompanyId]) + + useEffect(() => { + if (!selectedThreadId) return + const timer = setInterval(() => { + loadMessages(selectedThreadId).catch(() => {}) + }, 1800) + return () => clearInterval(timer) + }, [selectedThreadId]) + + useEffect(() => { + if (!selectedCompanyId) return + + const source = new EventSource(`/api/events/stream?company_id=${encodeURIComponent(selectedCompanyId)}`) + + source.addEventListener('event', (event) => { + try { + const payload = JSON.parse(event.data) + setEvents((previous) => [...previous, payload].slice(-240)) + setLastEventId((previous) => Math.max(previous, payload?.id || 0)) + + const eventType = String(payload?.event_type || '').toLowerCase() + if (eventType.includes('company_') || eventType.includes('department_') || eventType.includes('agent_')) { + loadCompanies().catch(() => {}) + loadDirectoryData().catch(() => {}) + } + if (payload?.task_id) { + api('/api/tasks?limit=300').then((items) => setTasks(items || [])).catch(() => {}) + } + if (payload?.thread_id) { + loadMessages(payload.thread_id).catch(() => {}) + } + } catch { + // ignore parse errors + } + }) + + source.addEventListener('error', () => { + source.close() + setTimeout(() => { + api(`/api/events?company_id=${encodeURIComponent(selectedCompanyId)}&since_id=${lastEventId}&limit=100`) + .then((items) => { + if (!Array.isArray(items) || items.length === 0) return + setEvents((previous) => [...previous, ...items].slice(-240)) + const maxID = items.reduce((maxValue, item) => Math.max(maxValue, item?.id || 0), 0) + setLastEventId((previous) => Math.max(previous, maxID)) + }) + .catch(() => {}) + }, 2000) + }) + + return () => source.close() + }, [selectedCompanyId, lastEventId]) + + useEffect(() => { + if (modalType === 'agent' && departmentsInModalCompany.length > 0) { + if (!departmentsInModalCompany.some((department) => department.id === modalDepartmentId)) { + setModalDepartmentId(departmentsInModalCompany[0].id) + } + } + }, [modalType, departmentsInModalCompany, modalDepartmentId]) + + const closeModal = () => { + setModalType('') + setModalCompanyId('') + setModalDepartmentId('') + } + + const openCompanyModal = () => { + setNewCompanyName('') + setNewCompanyPath('') + setNewCompanyDeployCommand('') + setModalType('company') + } + + const openDepartmentModal = (companyID = '') => { + if (companies.length === 0) return + const targetCompanyID = companyID || selectedCompanyId || companies[0]?.id || '' + if (!targetCompanyID) return + setNewDepartmentName('') + setModalCompanyId(targetCompanyID) + setModalType('department') + } + + const openAgentModal = (departmentID = '') => { + if (departments.length === 0) return + const targetDepartmentID = departmentID || selectedDepartmentId || departments[0]?.id || '' + const targetDepartment = departments.find((department) => department.id === targetDepartmentID) + if (!targetDepartment) return + setNewAgentName('') + setNewAgentRole('worker') + setModalCompanyId(targetDepartment.company_id) + setModalDepartmentId(targetDepartmentID) + setModalType('agent') + } + + const createCompany = async () => { + const name = newCompanyName.trim() + if (!name) return + await api('/api/companies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + description: 'Agent OS company', + workspace_path: newCompanyPath.trim(), + deploy_command: newCompanyDeployCommand.trim(), + }), + }) + await loadCompanies() + await loadDirectoryData() + closeModal() + } + + const createDepartment = async () => { + const companyID = modalCompanyId || selectedCompanyId + const name = newDepartmentName.trim() + if (!companyID || !name) return + await api('/api/departments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: companyID, name, type: 'general' }), + }) + await loadDirectoryData() + if (selectedCompanyId === companyID) { + await loadCompanyRuntimeData(companyID) + } + closeModal() + } + + const createAgent = async () => { + const companyID = modalCompanyId || selectedCompanyId + const departmentID = modalDepartmentId || selectedDepartmentId + const name = newAgentName.trim() + if (!companyID || !departmentID || !name) return + await api('/api/agents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + company_id: companyID, + department_id: departmentID, + name, + role_type: newAgentRole, + identity_prompt: `You are ${name}, operating as part of Agent OS.`, + }), + }) + await loadDirectoryData() + closeModal() + } + + const updateCompanyWorkspace = async () => { + if (!selectedCompanyId) return + await api(`/api/companies/${encodeURIComponent(selectedCompanyId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspace_path: companyWorkspacePath.trim(), + deploy_command: companyDeployCommand.trim(), + }), + }) + await loadCompanies() + setStatusMessage('Company workspace mapping updated.') + } + + const bindAgentModel = async () => { + if (!selectedAgentId || !selectedProfileId) return + await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/model-bind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + primary_profile_id: selectedProfileId, + temperature: 0.2, + max_tokens: 1400, + reasoning_effort: 'standard', + }), + }) + setStatusMessage('Model profile was bound to selected agent.') + } + + const createThread = async () => { + if (!selectedCompanyId || !selectedAgentId) return + const created = await api('/api/threads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + company_id: selectedCompanyId, + department_id: selectedDepartmentId, + agent_id: selectedAgentId, + title: newThreadTitle.trim() || 'New Agent Thread', + }), + }) + setNewThreadTitle('') + await loadThreads(selectedAgentId) + if (created?.id) { + setSelectedThreadId(created.id) + await loadMessages(created.id) + } + } + + const sendChatMessage = async () => { + const value = chatInput.trim() + if (!value || !selectedAgentId || !selectedCompanyId) return + + let targetThreadID = selectedThreadId + if (!targetThreadID) { + const created = await api('/api/threads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + company_id: selectedCompanyId, + department_id: selectedDepartmentId, + agent_id: selectedAgentId, + title: 'Autocreated Thread', + }), + }) + targetThreadID = created.id + setSelectedThreadId(created.id) + await loadThreads(selectedAgentId) + } + + setChatInput('') + await api(`/api/threads/${encodeURIComponent(targetThreadID)}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'user', content: value }), + }) + await loadMessages(targetThreadID) + } + + const createSchedule = async () => { + if (!selectedCompanyId || !selectedAgentId) return + const payload = { prompt: scheduleMessage || 'Scheduled task' } + if (scheduleMode === 'cron') { + if (!scheduleExpr.trim()) return + await api('/api/schedules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + company_id: selectedCompanyId, + department_id: selectedDepartmentId || '', + target_agent_id: selectedAgentId, + schedule_type: 'cron', + cron_expr: scheduleExpr.trim(), + timezone: 'UTC', + payload_json: JSON.stringify(payload), + is_active: true, + }), + }) + } else { + if (!scheduleOnce) return + const dt = new Date(scheduleOnce).toISOString() + await api('/api/schedules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + company_id: selectedCompanyId, + department_id: selectedDepartmentId || '', + target_agent_id: selectedAgentId, + schedule_type: 'once', + start_at: dt, + timezone: 'UTC', + payload_json: JSON.stringify(payload), + is_active: true, + }), + }) + } + await loadCompanyRuntimeData(selectedCompanyId) + } + + const sendInterAgentMessage = async () => { + if (!interAgentTo || !interAgentContent.trim() || !selectedAgentId || !selectedCompanyId) return + await api('/api/inter-agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from_agent_id: selectedAgentId, + to_agent_id: interAgentTo, + content: interAgentContent, + company_id: selectedCompanyId, + }), + }) + setInterAgentContent('') + await loadAgentInbox(selectedAgentId, selectedCompanyId) + } + + const queryMemory = async () => { + if (!selectedCompanyId) return + const items = await api(`/api/memory/query?company_id=${encodeURIComponent(selectedCompanyId)}&department_id=${encodeURIComponent(selectedDepartmentId || '')}&agent_id=${encodeURIComponent(selectedAgentId || '')}&query=${encodeURIComponent(memoryQuery)}&limit=40`) + setMemoryHits(items || []) + } + + const resolveApproval = async (approvalID, decision) => { + await api('/api/approvals/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approval_id: approvalID, decision, actor: 'owner' }), + }) + await loadGovernance() + } + + const onboardingSteps = [ + { label: 'Company', done: companies.length > 0, action: openCompanyModal }, + { label: 'Department', done: departments.length > 0, action: () => openDepartmentModal() }, + { label: 'Agent', done: agents.length > 0, action: () => openAgentModal() }, + { label: 'Model', done: modelProfiles.length > 0, action: null }, + ] + + const renderOrganizationTree = () => { + if (companies.length === 0) { + return ( +
+ +
+ ) + } + + return ( + <> +
+

Organization

+ +
+ +
+ {companies.map((company) => { + const isCompanyActive = selectedCompanyId === company.id + const scopedDepartments = departmentsByCompany[company.id] || [] + return ( +
+ + + {isCompanyActive && ( +
+ {scopedDepartments.length === 0 && ( +
+
No departments
+ +
+ )} + + {scopedDepartments.map((department) => { + const scopedAgents = agentsByDepartment[department.id] || [] + const isDepartmentActive = selectedDepartmentId === department.id + return ( +
+ + +
+ {scopedAgents.length === 0 && ( +
+
No agents
+ +
+ )} + + {scopedAgents.map((agent) => ( + + ))} +
+
+ ) + })} +
+ )} +
+ ) + })} +
+ + ) + } + + return ( +
+ + +
+
+
+

{selectedCompany?.name || 'No Company Selected'}

+ + {selectedAgent + ? `${selectedAgent.name} · ${selectedAgent.role_type}` + : 'Select an agent in the left tree to open chat'} + +
+
+ + {statusMessage &&
{statusMessage}
} + + {activeTab === 'companies' && ( +
+
+
+

AgentHQ Command Center

+

Operate companies, agents, schedules, memory, and governance from one workspace.

+
+ +
+ +
+
+
+
+

Today at a glance

+ {selectedCompany?.name || 'No company selected'} +
+ +
+ +
+ Schedules + Tasks + Agents +
+
+ +
+
+
+

Setup path

+ First useful agent company +
+ +
+
+ {onboardingSteps.map((step) => ( +
+
+ {onboardingSteps.map((step) => ( + + ))} +
+
+
+ +
+ + + + +
+ + {companies.length === 0 && ( + + )} + +
+ + + + + + + + + + + + {companies.length === 0 && ( + + + + )} + {companies.map((company) => { + const companyTasks = tasksByCompany[company.id] || [] + return ( + + + + + + + + ) + })} + +
NameDepartmentsAgentsRunning TasksWorkspace
No companies yet.
+ + {(departmentsByCompany[company.id] || []).length}{(agentsByCompany[company.id] || []).length}{companyTasks.filter((task) => task.status === 'running').length}{company.workspace_path || '-'}
+
+
+ )} + + {activeTab === 'departments' && ( +
+
+

Departments

+ +
+ {companies.length === 0 &&
Create a company first.
} + +
+ + + + + + + + + + + + + {departments.length === 0 && ( + + + + )} + {departments.map((department) => { + const departmentAgentsList = agentsByDepartment[department.id] || [] + const departmentTasks = tasksByDepartment[department.id] || [] + return ( + + + + + + + + + ) + })} + +
DepartmentCompanyAgentsManagersRunning TasksTotal Tasks
No departments yet.
+ + {companyByID[department.company_id]?.name || '-'}{departmentAgentsList.length}{departmentAgentsList.filter((agent) => agent.role_type === 'manager').length}{departmentTasks.filter((task) => task.status === 'running').length}{departmentTasks.length}
+
+
+ )} + + {activeTab === 'agents' && ( +
+
+

Agents

+
+ + + +
+
+ {departments.length === 0 && ( + openDepartmentModal()} + disabled={companies.length === 0} + /> + )} + +
+ + + + + + + + + + + + + + {agents.length === 0 && ( + + + + )} + {agents.map((agent) => { + const agentTasks = tasks.filter((task) => task.agent_id === agent.id) + return ( + + + + + + + + + + ) + })} + +
AgentRoleDepartmentCompanyStatusTasksSelect
No agents yet. Add a manager first, then workers.
{agent.name}{agent.role_type}{departmentByID[agent.department_id]?.name || '-'}{companyByID[agent.company_id]?.name || '-'} + + {agent.status || 'idle'} + + {agentTasks.length} + +
+
+ + {selectedAgentId && ( +
+

Inter-agent Messages

+
+ +
+
+