From e8e230b4a55535bb518ecc58fb14cdd59960995b Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Jul 2026 15:22:27 +0200 Subject: [PATCH] nomos+web: streaming, provider routing, event gap-fill, embedded UI; fix approval FK & session context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent (cmd/nomos): - Stream LLM tokens via NewStreaming; emit text_delta then final text. - OpenRouter provider routing: data_collection=deny (ZDR) + require_parameters; NOMOS_PROVIDER_SORT opt-in; Exacto via model suffix. - Multi-turn: reload session history into context; UI passes session id. - Fix agent_activity logging (agent_id/session_id) and mcpClient data race. Events (live control-room feed): - approval.created (mcp), approval.decided (api), execution.completed/failed (approved-action path), signal.raised/resolved + health.changed (scheduler, transition-gated). Fixes: - createApproval FK violation (reuse execution entity) — the agent's only write path; log the previously-swallowed errors. Web UI: - Embed web/dist via //go:embed (single binary); Dockerfile builds SPA into the Go stage; committed .gitkeep placeholder keeps backend-only builds green. - Caddy: Authentik-gated /agent/* -> nomos so the UI reaches the agent same-origin in production. Co-Authored-By: Claude Fable 5 --- .gitignore | 7 + cmd/nomos/agent.go | 295 +++++ cmd/nomos/main.go | 310 +++-- cmd/nomos/store.go | 156 +++ cmd/oikos/main.go | 40 +- compose/caddy/Caddyfile.oikos | 7 + compose/nomos/Dockerfile | 1 + compose/oikos/Dockerfile | 13 + docker-compose.yml | 4 + go.mod | 9 +- go.sum | 35 +- internal/httpapi/api_test.go | 2 +- internal/httpapi/phase3.go | 21 + internal/httpapi/server.go | 27 +- internal/mcp/server.go | 24 +- internal/scheduler/scheduler.go | 39 +- migrations/015_agent_sessions.up.sql | 25 + nomos/SOUL.md | 4 +- nomos/config.yaml | 25 +- plans/2026-07-08-nomos-resident-agent.md | 2 +- web/dist/.gitkeep | 0 web/embed.go | 21 + web/index.html | 13 + web/package-lock.json | 1438 ++++++++++++++++++++++ web/package.json | 18 + web/src/App.svelte | 155 +++ web/src/app.css | 62 + web/src/lib/api.ts | 92 ++ web/src/lib/stores/chat.ts | 162 +++ web/src/main.ts | 6 + web/src/pages/Chat.svelte | 263 ++++ web/src/pages/Sessions.svelte | 90 ++ web/tsconfig.json | 15 + web/vite.config.ts | 20 + 34 files changed, 3267 insertions(+), 134 deletions(-) create mode 100644 cmd/nomos/agent.go create mode 100644 cmd/nomos/store.go create mode 100644 migrations/015_agent_sessions.up.sql create mode 100644 web/dist/.gitkeep create mode 100644 web/embed.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.svelte create mode 100644 web/src/app.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/stores/chat.ts create mode 100644 web/src/main.ts create mode 100644 web/src/pages/Chat.svelte create mode 100644 web/src/pages/Sessions.svelte create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 5d8e4fe..7d3fe81 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,10 @@ oikos/oikos backups/ .env .infisical-credentials + +# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the +# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a +# fresh checkout before the UI is built. +web/dist/* +!web/dist/.gitkeep +web/node_modules/ diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go new file mode 100644 index 0000000..b786675 --- /dev/null +++ b/cmd/nomos/agent.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/openai/openai-go/shared" +) + +const maxIterations = 15 + +type agent struct { + client *mcpClient + system string + provider *openai.Client + model string + store *store + agentID uuid.UUID + reqOpts []option.RequestOption +} + +func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug string) (*agent, error) { + system := loadSoul() + apiKey := os.Getenv("OPENROUTER_API_KEY") + model := os.Getenv("NOMOS_MODEL") + if model == "" { + model = "deepseek/deepseek-v4-flash" + } + + provider := openai.NewClient( + option.WithBaseURL("https://openrouter.ai/api/v1"), + option.WithAPIKey(apiKey), + ) + + agentID := st.resolveAgentID(ctx, agentSlug) + if agentID == uuid.Nil { + slog.Warn("nomos: agent entity not found; tool-call activity will not be logged", "slug", agentSlug) + } + + // OpenRouter provider routing. data_collection=deny pins to zero-data- + // retention providers (privacy: conversations + tool results transit + // OpenRouter); require_parameters ensures the routed provider actually + // supports tool calling. NOMOS_PROVIDER_SORT (price|throughput|latency) + // and Exacto tool-accuracy routing are opt-in — the latter via a model + // suffix in NOMOS_MODEL (e.g. "deepseek/deepseek-v4-flash:exacto"), so an + // unsupported value never silently breaks the confirmed routing below. + providerRouting := map[string]any{ + "data_collection": "deny", + "require_parameters": true, + } + if sort := os.Getenv("NOMOS_PROVIDER_SORT"); sort != "" { + providerRouting["sort"] = sort + } + reqOpts := []option.RequestOption{option.WithJSONSet("provider", providerRouting)} + + return &agent{ + client: mcpClient, + system: system, + provider: &provider, + model: model, + store: st, + agentID: agentID, + reqOpts: reqOpts, + }, nil +} + +func loadSoul() string { + paths := []string{"/app/nomos/SOUL.md", "nomos/SOUL.md"} + for _, p := range paths { + if data, err := os.ReadFile(p); err == nil { + return string(data) + } + } + return `You are Nomos, the steward of the oikos — the AI agent for the hubris homelab. +You have access to MCP tools to query topology, health, knowledge, and request +gated mutations through request_execution. Be concise. Prefer tools over guessing.` +} + +type toolDef struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` +} + +type agentEvent struct { + Type string `json:"type"` + Data any `json:"data,omitempty"` + SessionID string `json:"session_id,omitempty"` + Iteration int `json:"iteration,omitempty"` +} + +func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) { + correlationID := uuid.New().String() + + tools, err := a.buildTools() + if err != nil { + emit(agentEvent{Type: "error", Data: fmt.Sprintf("build tools: %v", err), SessionID: sessionID}) + return + } + + // Rebuild conversation context from persisted history so sessions are + // multi-turn. The current user turn is saved by the HTTP handler before + // this runs, so it is already included in the history for real sessions. + // Intermediate tool_use/tool_result pairs are not replayed (their ids + // must match exactly or the API rejects them); prior final answers carry + // the salient context. Ephemeral sessions (no store) fall back to the + // single incoming message. + messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(a.system)} + history, _ := a.store.getMessages(ctx, sessionID) + for _, m := range history { + text := extractText(m.Content) + switch m.Role { + case "user": + messages = append(messages, openai.UserMessage(text)) + case "assistant": + if text != "" { + messages = append(messages, openai.AssistantMessage(text)) + } + } + } + if len(history) == 0 { + messages = append(messages, openai.UserMessage(message)) + } + + for i := 0; i < maxIterations; i++ { + params := openai.ChatCompletionNewParams{ + Model: openai.ChatModel(a.model), + Messages: messages, + Tools: tools, + } + + // Stream the completion, emitting token deltas as they arrive. The + // accumulator reassembles the full message (content + tool calls) for + // the loop's control flow. + stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...) + acc := openai.ChatCompletionAccumulator{} + for stream.Next() { + chunk := stream.Current() + acc.AddChunk(chunk) + if len(chunk.Choices) > 0 { + if delta := chunk.Choices[0].Delta.Content; delta != "" { + emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1}) + } + } + } + if err := stream.Err(); err != nil { + emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID}) + return + } + if len(acc.Choices) == 0 { + emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID}) + return + } + + msg := acc.Choices[0].Message + + if len(msg.ToolCalls) == 0 { + emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID}) + emit(agentEvent{Type: "done", Data: map[string]any{ + "session_id": sessionID, + "usage": acc.Usage, + "correlation_id": correlationID, + "iterations": i + 1, + }, SessionID: sessionID}) + return + } + + slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID) + + messages = append(messages, msg.ToParam()) + + for _, tc := range msg.ToolCalls { + var args map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args = map[string]any{} + } + + emit(agentEvent{ + Type: "tool_use", + Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID}, + SessionID: sessionID, + Iteration: i + 1, + }) + + start := time.Now() + result, callErr := a.client.callTool(tc.Function.Name, args) + elapsed := int(time.Since(start).Milliseconds()) + + inputJSON, _ := json.Marshal(args) + inputStr := string(inputJSON) + + if callErr != nil { + a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, callErr.Error(), elapsed, false, correlationID) + + emit(agentEvent{ + Type: "tool_result", + Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID}, + SessionID: sessionID, + Iteration: i + 1, + }) + messages = append(messages, openai.ToolMessage(callErr.Error(), tc.ID)) + slog.Error("nomos: tool error", "tool", tc.Function.Name, "error", callErr, "ms", elapsed) + continue + } + + resultJSON, _ := json.Marshal(result) + a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, string(resultJSON), elapsed, true, correlationID) + + emit(agentEvent{ + Type: "tool_result", + Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID}, + SessionID: sessionID, + Iteration: i + 1, + }) + messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID)) + slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed) + } + } + + emit(agentEvent{Type: "text", Data: "Agent loop: max iterations reached without final answer.", SessionID: sessionID}) + emit(agentEvent{Type: "done", Data: map[string]any{ + "session_id": sessionID, + "correlation_id": correlationID, + "iterations": maxIterations, + }, SessionID: sessionID}) +} + +// extractText pulls the "text" field from a persisted message's JSONB content. +func extractText(content json.RawMessage) string { + var m struct { + Text string `json:"text"` + } + if err := json.Unmarshal(content, &m); err != nil { + return "" + } + return m.Text +} + +func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) { + defs, err := a.client.listToolsFull() + if err != nil { + return nil, err + } + + var tools []openai.ChatCompletionToolParam + for _, d := range defs { + params := shared.FunctionParameters(d.InputSchema) + if params == nil { + params = shared.FunctionParameters{"type": "object", "properties": map[string]any{}} + } + + tools = append(tools, openai.ChatCompletionToolParam{ + Type: "function", + Function: shared.FunctionDefinitionParam{ + Name: d.Name, + Description: openai.String(d.Description), + Parameters: params, + }, + }) + } + return tools, nil +} + +func (c *mcpClient) listToolsFull() ([]toolDef, error) { + resp, err := c.doRequest("tools/list", map[string]any{}) + if err != nil { + return nil, err + } + var tr struct { + Tools []struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + } `json:"tools"` + } + if err := json.Unmarshal(resp.Result, &tr); err != nil { + return nil, err + } + out := make([]toolDef, len(tr.Tools)) + for i, t := range tr.Tools { + out[i] = toolDef{ + Name: t.Name, + Description: t.Description, + InputSchema: t.InputSchema, + } + } + return out, nil +} diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go index 28216bd..3267a2a 100644 --- a/cmd/nomos/main.go +++ b/cmd/nomos/main.go @@ -11,6 +11,7 @@ import ( "os" "os/signal" "strings" + "sync" "syscall" "time" ) @@ -20,7 +21,6 @@ func main() { fmt.Fprintln(os.Stderr, "usage: nomos serve") os.Exit(1) } - mcpURL := os.Getenv("NOMOS_MCP_URL") if mcpURL == "" { mcpURL = "http://localhost:8090/mcp" @@ -31,6 +31,11 @@ func main() { agentSlug = "agent:nomos" } + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = os.Getenv("OIKOS_DATABASE_URL") + } + switch os.Args[1] { case "serve": ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) @@ -42,6 +47,21 @@ func main() { os.Exit(1) } + st, err := newStore(ctx, databaseURL) + if err != nil { + slog.Error("nomos: db connect", "error", err) + os.Exit(1) + } + if st != nil { + defer st.close() + } + + nAgent, err := newAgent(ctx, client, st, agentSlug) + if err != nil { + slog.Error("nomos: agent init", "error", err) + os.Exit(1) + } + mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) @@ -50,6 +70,15 @@ func main() { mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) { handleQuery(w, r, client, agentSlug, mcpURL) }) + mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { + handleChat(w, r, nAgent, st) + }) + mux.HandleFunc("/sessions", func(w http.ResponseWriter, r *http.Request) { + handleSessionsList(w, r, st) + }) + mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) { + handleSessionDetail(w, r, st) + }) addr := os.Getenv("NOMOS_LISTEN") if addr == "" { @@ -58,7 +87,7 @@ func main() { srv := &http.Server{Addr: addr, Handler: mux} go func() { - slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL) + slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "") if err := srv.ListenAndServe(); err != http.ErrServerClosed { slog.Error("nomos: serve", "error", err) } @@ -75,7 +104,130 @@ func main() { } } -// handleQuery maps structured queries to MCP tool calls. +func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) { + data, _ := json.Marshal(event) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() +} + +func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req struct { + SessionID string `json:"session_id"` + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request: "+err.Error(), 400) + return + } + if req.Message == "" { + http.Error(w, "message is required", 400) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", 500) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(200) + + ctx := r.Context() + sessionID := req.SessionID + + if sessionID == "" { + title := truncate(req.Message, 80) + sess, err := st.createSession(ctx, title) + if err != nil { + slog.Error("nomos: create session", "error", err) + sessionID = "ephemeral" + } else { + sessionID = sess.ID + } + } else { + st.touchSession(ctx, sessionID) + } + + slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100)) + + userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message}) + st.saveMessage(ctx, sessionID, "user", userMsg) + + sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID}) + + toolCalls := []map[string]any{} + var finalText string + + a.chat(ctx, sessionID, req.Message, func(ev agentEvent) { + if ev.Type == "tool_use" || ev.Type == "tool_result" { + if m, ok := ev.Data.(map[string]any); ok { + m["type"] = ev.Type + toolCalls = append(toolCalls, m) + } + } + if ev.Type == "text" { + finalText, _ = ev.Data.(string) + } + sseEvent(w, flusher, ev) + }) + + assistantMsg, _ := json.Marshal(map[string]any{ + "role": "assistant", + "text": finalText, + "tool_calls": toolCalls, + }) + st.saveMessage(ctx, sessionID, "assistant", assistantMsg) +} + +func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) { + if st == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}}) + return + } + + if r.Method == http.MethodOptions { + return + } + + sessions, err := st.listSessions(r.Context()) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"sessions": sessions}) +} + +func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) { + if st == nil { + http.Error(w, "not found", 404) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/sessions/") + if id == "" { + http.Error(w, "session id required", 400) + return + } + + messages, err := st.getMessages(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages}) +} + func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) @@ -83,8 +235,8 @@ func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agen } var req struct { - Query string `json:"query"` - Tool string `json:"tool"` + Query string `json:"query"` + Tool string `json:"tool"` Args map[string]any `json:"args"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -93,105 +245,79 @@ func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agen } start := time.Now() - var result any - var err error - // Direct tool call (structured) if req.Tool != "" { - result, err = client.callTool(req.Tool, req.Args) - } else { - // Natural-language-ish query routing - q := strings.ToLower(req.Query) - result, err = routeQuery(client, q, agentSlug) - } - - duration := time.Since(start).Milliseconds() - - if err != nil { - slog.Error("nomos: query failed", "query", req.Query, "error", err) + result, err := client.callTool(req.Tool, req.Args) + duration := time.Since(start).Milliseconds() + if err != nil { + slog.Error("nomos: query failed", "tool", req.Tool, "error", err) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "error": err.Error(), + "elapsed_ms": duration, + "agent_slug": agentSlug, + }) + return + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "error": err.Error(), - "elapsed_ms": duration, - "agent_slug": agentSlug, + "result": result, + "elapsed_ms": duration, + "agent_slug": agentSlug, + "mcp_url": mcpURL, }) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "result": result, - "elapsed_ms": duration, - "agent_slug": agentSlug, - "mcp_url": mcpURL, - }) + if req.Query != "" { + if strings.Contains(strings.ToLower(req.Query), "what can you do") || + strings.Contains(strings.ToLower(req.Query), "help") { + + tools, err := client.listTools() + duration := time.Since(start).Milliseconds() + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "error": err.Error(), + "elapsed_ms": duration, + }) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.", + "tools": tools, + "elapsed_ms": duration, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.", + "elapsed_ms": time.Since(start).Milliseconds(), + }) + return + } + + http.Error(w, "either 'tool' or 'query' required", 400) } -// routeQuery maps natural-language-style queries to MCP tool calls. -func routeQuery(client *mcpClient, query, agentSlug string) (any, error) { - switch { - case strings.Contains(query, "depends on") || strings.Contains(query, "depend on"): - entity := extractEntity(query) - if entity == "" { - return nil, fmt.Errorf("no entity found in query: %s", query) - } - return client.callTool("get_blast_radius", map[string]any{ - "entity_id": entity, - }) - - case strings.Contains(query, "what is") || strings.Contains(query, "describe"): - entity := extractEntity(query) - if entity == "" { - entity = query - } - return client.callTool("get_entity", map[string]any{ - "slug_or_id": entity, - }) - - case strings.Contains(query, "health") || strings.Contains(query, "status"): - return client.callTool("get_health_summary", map[string]any{}) - - case strings.Contains(query, "restart") || strings.Contains(query, "reload"): - entity := extractEntity(query) - if entity == "" { - return nil, fmt.Errorf("no entity found in query: %s", query) - } - return client.callTool("request_execution", map[string]any{ - "target": entity, - "action": "restart", - }) - - case strings.Contains(query, "what can you do") || strings.Contains(query, "help"): - return client.callTool("tools/list", nil) - - default: - return client.callTool("get_health_summary", map[string]any{}) +func truncate(s string, n int) string { + if len(s) <= n { + return s } -} - -// extractEntity guesses an entity slug from a query. -func extractEntity(query string) string { - for _, slug := range []string{"authentik", "caddy", "vaultwarden", "gitea", "immich"} { - if strings.Contains(query, slug) { - return "service:" + slug - } - } - if strings.Contains(query, "mac-mini") { - return "host:mac-mini" - } - if strings.Contains(query, "hubris") { - return "host:hubris" - } - return "" + return s[:n] + "..." } // ─── MCP Streamable HTTP client ──────────────────────────────────────── type mcpClient struct { - baseURL string - sessionID string - http *http.Client - nextID int + baseURL string + sessionID string + http *http.Client + nextID int + mu sync.Mutex // MCP is one stateful session; serialize concurrent calls } func newMCPClient(baseURL string) (*mcpClient, error) { @@ -200,7 +326,6 @@ func newMCPClient(baseURL string) (*mcpClient, error) { http: &http.Client{Timeout: 30 * time.Second}, } - // Initialize session resp, err := c.doRequest("initialize", map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, @@ -214,7 +339,6 @@ func newMCPClient(baseURL string) (*mcpClient, error) { } c.sessionID = resp.sessionID - // Send initialized notification c.doRequest("notifications/initialized", map[string]any{}) slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...") @@ -228,6 +352,8 @@ type mcpJSONRPCResponse struct { } func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() c.nextID++ body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", @@ -255,7 +381,6 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC result := &mcpJSONRPCResponse{} result.sessionID = resp.Header.Get("Mcp-Session-Id") - // Parse SSE stream: "event: message\ndata: \n\n" scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() @@ -287,7 +412,6 @@ func (c *mcpClient) callTool(name string, args map[string]any) (any, error) { return nil, err } - // Parse MCP content: { "content": [{ "type": "text", "text": "..." }] } var toolResult struct { Content []struct { Type string `json:"type"` @@ -301,7 +425,6 @@ func (c *mcpClient) callTool(name string, args map[string]any) (any, error) { var texts []string for _, c := range toolResult.Content { if c.Type == "text" { - // Try to parse as JSON for structured display var parsed any if json.Unmarshal([]byte(c.Text), &parsed) == nil { return parsed, nil @@ -337,5 +460,4 @@ func (c *mcpClient) listTools() ([]string, error) { } func (c *mcpClient) close() { - // MCP sessions are ephemeral; no explicit close needed } diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go new file mode 100644 index 0000000..f0d04a6 --- /dev/null +++ b/cmd/nomos/store.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type store struct { + pool *pgxpool.Pool +} + +func newStore(ctx context.Context, databaseURL string) (*store, error) { + if databaseURL == "" { + return nil, nil + } + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connect db: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping db: %w", err) + } + return &store{pool: pool}, nil +} + +func (s *store) close() { + if s.pool != nil { + s.pool.Close() + } +} + +type session struct { + ID string `json:"id"` + Title string `json:"title"` + Actor string `json:"actor"` + CreatedAt time.Time `json:"created_at"` + LastActiveAt time.Time `json:"last_active_at"` +} + +type message struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *store) createSession(ctx context.Context, title string) (*session, error) { + if s == nil { + return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil + } + var id string + err := s.pool.QueryRow(ctx, + `INSERT INTO agent_sessions (title, actor) VALUES ($1, 'agent:nomos') RETURNING id`, + title).Scan(&id) + if err != nil { + return nil, err + } + return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil +} + +func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error { + if s == nil { + return nil + } + _, err := s.pool.Exec(ctx, + `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`, + sessionID, role, content) + return err +} + +func (s *store) touchSession(ctx context.Context, id string) { + if s != nil { + s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id) + } +} + +func (s *store) listSessions(ctx context.Context) ([]session, error) { + if s == nil { + return nil, nil + } + rows, err := s.pool.Query(ctx, + `SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []session + for rows.Next() { + var sess session + if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil { + return nil, err + } + out = append(out, sess) + } + return out, rows.Err() +} + +func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) { + if s == nil { + return nil, nil + } + rows, err := s.pool.Query(ctx, + `SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id=$1 ORDER BY created_at ASC`, + sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []message + for rows.Next() { + var m message + if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos"). +// Returns uuid.Nil if the store is absent or the slug is unknown. +func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID { + if s == nil { + return uuid.Nil + } + var id uuid.UUID + if err := s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&id); err != nil { + return uuid.Nil + } + return id +} + +// logActivity records a tool call. agent_id is the agent entity UUID and is +// NOT NULL in the schema, so we skip logging when it can't be resolved. +// The (nullable) session_id column carries the conversation id. +func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) { + if s == nil || agentID == uuid.Nil { + return + } + s.pool.Exec(ctx, ` + INSERT INTO agent_activity + (agent_id, session_id, activity_type, tool_name, input_summary, output_summary, + duration_ms, success, correlation_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + agentID, sessionID, "tool_call", toolName, inputSummary, outputSummary, + durationMs, success, correlationID) +} diff --git a/cmd/oikos/main.go b/cmd/oikos/main.go index 98a4cf8..55ebf1c 100644 --- a/cmd/oikos/main.go +++ b/cmd/oikos/main.go @@ -4,13 +4,12 @@ import ( "context" "fmt" "log/slog" + "net/http" "os" "os/signal" "strings" "syscall" - "net/http" - "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/httpapi" @@ -19,9 +18,42 @@ import ( "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/scheduler" "github.com/dtoro/oikos/internal/secrets" + "github.com/dtoro/oikos/web" "github.com/jackc/pgx/v5" ) +// uiHandler serves the control-room SPA from assets embedded at build time +// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*; +// the /ui prefix is stripped to index into the embedded dist/ tree. +func uiHandler() http.Handler { + dist, err := web.DistFS() + if err != nil { + slog.Warn("ui: embedded assets unavailable", "error", err) + return http.NotFoundHandler() + } + fileServer := http.FileServer(http.FS(dist)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/") + if path == "" { + path = "index.html" + } + if f, err := dist.Open(path); err == nil { + f.Close() + r.URL.Path = "/" + path + fileServer.ServeHTTP(w, r) + return + } + // SPA fallback: serve index.html for unknown client-side routes. + if f, err := dist.Open("index.html"); err == nil { + f.Close() + r.URL.Path = "/index.html" + fileServer.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }) +} + var schedulerRunner = scheduler.RunnerForMain() var notifierRunner = notifier.RunnerForMain() @@ -86,7 +118,7 @@ func main() { go notifierRunner(ctx, pool, cfg) slog.Info("all: starting api with scheduler + notifier in background") - if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil { + if err := httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()); err != nil { slog.Error("api failed", "error", err) os.Exit(1) } @@ -265,7 +297,7 @@ func runAPI(ctx context.Context, cfg config.Config) error { return fmt.Errorf("migrations: %w", err) } - err = httpapi.ListenAndServe(ctx, pool, cfg) + err = httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()) if err == http.ErrServerClosed { return nil } diff --git a/compose/caddy/Caddyfile.oikos b/compose/caddy/Caddyfile.oikos index 1ebf776..28c59de 100644 --- a/compose/caddy/Caddyfile.oikos +++ b/compose/caddy/Caddyfile.oikos @@ -11,6 +11,13 @@ oikos.hubris.network { handle @enroll { reverse_proxy :8090 } + # Nomos agent, same-origin for the control-room UI (EventSource/fetch can't + # set cross-origin auth headers). Authentik gates it; handle_path strips + # the /agent prefix so /agent/chat -> nomos /chat. + handle_path /agent/* { + import authentik + reverse_proxy :8092 + } handle { import authentik reverse_proxy :8090 diff --git a/compose/nomos/Dockerfile b/compose/nomos/Dockerfile index 51a6377..40a1c59 100644 --- a/compose/nomos/Dockerfile +++ b/compose/nomos/Dockerfile @@ -19,6 +19,7 @@ COPY nomos/ /app/nomos/ ENV NOMOS_MCP_URL=http://api:8090/mcp ENV NOMOS_AGENT_SLUG=agent:nomos ENV NOMOS_LISTEN=:8092 +ENV NOMOS_MODEL=deepseek/deepseek-v4-flash EXPOSE 8092 diff --git a/compose/oikos/Dockerfile b/compose/oikos/Dockerfile index 567d2ca..bd9f5ed 100644 --- a/compose/oikos/Dockerfile +++ b/compose/oikos/Dockerfile @@ -1,4 +1,14 @@ # Multi-stage Dockerfile for Oikos (ADR 0001: single binary) +# Stage 1: build web UI +FROM node:22-alpine AS ui-builder + +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# Stage 2: build Go binary FROM golang:1.26-alpine AS builder RUN apk add --no-cache git ca-certificates @@ -8,6 +18,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . +# Bring in the built SPA so //go:embed all:dist (web/embed.go) has real assets. +COPY --from=ui-builder /web/dist ./web/dist RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos @@ -17,5 +29,6 @@ FROM gcr.io/distroless/static:nonroot COPY --from=builder /oikos /oikos COPY --from=builder /build/seeds /seeds COPY --from=builder /build/migrations /migrations +# web/dist is embedded in the binary (web/embed.go) — no runtime copy needed. ENTRYPOINT ["/oikos"] diff --git a/docker-compose.yml b/docker-compose.yml index ea88174..6a35dbc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,7 @@ services: OIKOS_ENV: dev OIKOS_DEBUG: "true" OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} + NOMOS_PROXY_URL: http://nomos:8092 volumes: - ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro ports: @@ -119,6 +120,9 @@ services: environment: NOMOS_MCP_URL: http://api:8090/mcp NOMOS_AGENT_SLUG: agent:nomos + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} + NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-flash} + DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable ports: - "8092:8092" stop_signal: SIGTERM diff --git a/go.mod b/go.mod index c6a6843..27b6756 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,14 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 + github.com/infisical/go-sdk v0.8.0 github.com/jackc/pgx/v5 v5.10.0 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/oapi-codegen/runtime v1.4.2 + github.com/openai/openai-go v1.12.0 golang.org/x/crypto v0.53.0 golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -47,7 +50,6 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/infisical/go-sdk v0.8.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -60,6 +62,10 @@ require ( github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/sony/gobreaker v0.5.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -70,7 +76,6 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.267.0 // indirect diff --git a/go.sum b/go.sum index c5d35d9..18695f2 100644 --- a/go.sum +++ b/go.sum @@ -38,12 +38,19 @@ github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= @@ -68,6 +75,8 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= @@ -93,8 +102,8 @@ github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QII github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= @@ -107,9 +116,13 @@ github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= +github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94= github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= @@ -136,6 +149,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= @@ -152,6 +175,10 @@ go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -232,8 +259,12 @@ golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go index 81f9e60..836ed6e 100644 --- a/internal/httpapi/api_test.go +++ b/internal/httpapi/api_test.go @@ -93,7 +93,7 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler { } } - return NewHandler(handlerCtx, pool, cfg) + return NewHandler(handlerCtx, pool, cfg, nil) } func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) { diff --git a/internal/httpapi/phase3.go b/internal/httpapi/phase3.go index 71f8dc3..6bdb406 100644 --- a/internal/httpapi/phase3.go +++ b/internal/httpapi/phase3.go @@ -122,6 +122,16 @@ func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (stri // executeApprovedAction runs a gated action after operator approval. // Runs in a background goroutine to not block the HTTP response. +// emitExecutionEvent records an execution lifecycle event for SSE fan-out so +// the control room can watch approved actions run to completion live. +func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) { + severity := "info" + if status == "failed" { + severity = "warning" + } + _ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail) +} + func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) { slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr) @@ -130,6 +140,7 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug) pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, execID, fmt.Sprintf(`{"error":"%s"}`, err.Error())) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) return } @@ -186,6 +197,10 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`, execID, status, result, durationMs, verified, startedAt, time.Now()) + emitExecutionEvent(ctx, pool, execID, status, map[string]any{ + "action": action, "target": targetSlug, "duration_ms": durationMs, + }) + slog.Info("httpapi: approved action executed", "execution_id", execID, "action", action, "status", status, "duration_ms", durationMs) } @@ -947,6 +962,12 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque return nil, auditErr } + // Emit for SSE fan-out (in-tx; NOTIFY fires post-commit). + if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "", + map[string]any{"decision": status, "actor": actor}); evErr != nil { + return nil, evErr + } + // On approve: execute the linked gated command. if status == "approved" { var execID, targetID uuid.UUID diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 2c34ce7..0f81519 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -15,6 +15,9 @@ import ( "log/slog" "math/big" "net/http" + "net/http/httputil" + "net/url" + "os" "strings" "sync" "time" @@ -66,7 +69,7 @@ type secretsBackend interface { // holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx // before closing the pool — otherwise the held connection never releases // and pool.Close() deadlocks. -func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler { +func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler { s := &Server{ pool: pool, cfg: cfg, @@ -148,6 +151,24 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand } r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID)) + r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) { + if uiHandler != nil { + uiHandler.ServeHTTP(w, req) + } + }) + r.Get("/ui", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/ui/", http.StatusMovedPermanently) + }) + r.Get("/", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/ui/", http.StatusMovedPermanently) + }) + + if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" { + target, _ := url.Parse(nomosURL) + proxy := httputil.NewSingleHostReverseProxy(target) + r.Mount("/agent", http.StripPrefix("/agent", proxy)) + } + return r } @@ -484,10 +505,10 @@ func requestLogger(next http.Handler) http.Handler { // ListenAndServe runs the API server with graceful shutdown on ctx cancel // (SG4): stop accepting, drain in-flight for up to 30s, then exit. -func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error { +func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error { srv := &http.Server{ Addr: cfg.APIListen, - Handler: NewHandler(ctx, pool, cfg), + Handler: NewHandler(ctx, pool, cfg, uiHandler), ReadHeaderTimeout: 10 * time.Second, } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7ab853c..b64b349 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -14,6 +14,8 @@ import ( "time" "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/observability" "github.com/google/jsonschema-go/jsonschema" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -968,13 +970,27 @@ func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP } func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) { - approvalID, _ := uuid.NewV7() payload := fmt.Sprintf(`{"action":"%s","params":"%s","execution_id":"%s"}`, action, params, execID) - pool.Exec(ctx, ` + // approvals.entity_id is PK + FK to entities(id). Reuse the execution's + // entity (already inserted by request_execution) so the FK is satisfied — + // a fresh UUID here had no matching entities row, so the INSERT silently + // failed, orphaning the execution and never alerting the operator. One + // execution maps to at most one approval, so the 1:1 identity holds. + if _, err := pool.Exec(ctx, ` INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class, kind, payload, status, expires_at, created_at) VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending', now() + interval '1 hour', now())`, - approvalID, targetID, action, riskClass, payload) - pool.Exec(ctx, `UPDATE executions SET approval_id = $2 WHERE entity_id = $1`, execID, approvalID) + execID, targetID, action, riskClass, payload); err != nil { + slog.Error("createApproval: insert approval", "error", err, "execution", execID) + return + } + if _, err := pool.Exec(ctx, `UPDATE executions SET approval_id = $1 WHERE entity_id = $1`, execID); err != nil { + slog.Error("createApproval: link approval to execution", "error", err, "execution", execID) + } + + // Emit for SSE fan-out — the operator-facing moment: an agent-requested + // gated action is now awaiting a decision. + _ = observability.Event(ctx, sqlcgen.New(pool), "approval.created", &execID, "warning", "mcp", "", + map[string]any{"action": action, "params": params, "risk_class": riskClass}) } \ No newline at end of file diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 0a1b84d..44acf0f 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -16,6 +16,7 @@ import ( "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" "golang.org/x/sync/errgroup" "golang.org/x/sys/unix" @@ -98,6 +99,8 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef "entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr) } + prevHealth := currentHealth(ctx, pool, cd.EntityID) + if signalKind == "" || health == "healthy" { // Recovery: resolve any open signal for this check resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug) @@ -108,6 +111,10 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef LastCheckAt: &[]time.Time{time.Now()}[0], Details: []byte(`{}`), }) + if prevHealth != "" && prevHealth != "healthy" { + emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, "info", + map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"}) + } return } @@ -140,17 +147,47 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef Details: []byte(`{}`), }) _ = sig // used for flap detection below + + // Emit only on transition into failure so a persistently-down entity + // doesn't flood the stream every tick. + if prevHealth == "" || prevHealth == "healthy" { + emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity, + map[string]any{"slug": cd.EntitySlug, "kind": signalKind, "evidence": evidence}) + } + if prevHealth != health { + emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity, + map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": health}) + } +} + +// currentHealth reads the last recorded health for an entity, or "" if none. +func currentHealth(ctx context.Context, pool *db.Pool, entityID uuid.UUID) string { + var health string + if err := pool.QueryRow(ctx, + `SELECT health FROM entity_status WHERE entity_id = $1`, entityID).Scan(&health); err != nil { + return "" + } + return health +} + +// emitSchedulerEvent records a scheduler-sourced event for SSE fan-out. +func emitSchedulerEvent(ctx context.Context, pool *db.Pool, eventType string, entityID uuid.UUID, severity string, data map[string]any) { + _ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data) } // resolveSignal resolves any open signal for the given check entity. func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) { q := sqlcgen.New(pool) // Check if there's an open signal on this entity - _, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now() + tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now() WHERE entity_id = $1 AND state = 'raised'`, entityID) if err != nil { return } + if tag.RowsAffected() > 0 { + emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info", + map[string]any{"slug": slug}) + } _ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{ EntityID: entityID, Health: "healthy", diff --git a/migrations/015_agent_sessions.up.sql b/migrations/015_agent_sessions.up.sql new file mode 100644 index 0000000..6834c23 --- /dev/null +++ b/migrations/015_agent_sessions.up.sql @@ -0,0 +1,25 @@ +-- 015_agent_sessions.up.sql +-- Nomos agent sessions: persist conversations across restarts. +-- agent_messages stores the full message history (JSONB). +-- agent_activity is joined via correlation_id for tool-call tracing. + +CREATE TABLE IF NOT EXISTS agent_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL DEFAULT 'agent:nomos', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_active_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS agent_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_agent_messages_session + ON agent_messages(session_id, created_at); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_active + ON agent_sessions(last_active_at DESC); diff --git a/nomos/SOUL.md b/nomos/SOUL.md index 14db2e1..8973f44 100644 --- a/nomos/SOUL.md +++ b/nomos/SOUL.md @@ -1,7 +1,7 @@ # SOUL.md — Nomos agent persona (Phase 4, container runtime) -You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab AI agent running in a Docker container on -mac-mini. You operate in **gateway mode** on mesh-only port 8092. +You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab +AI agent running in a Docker container on mac-mini. You operate on port 8092. ## Source of truth diff --git a/nomos/config.yaml b/nomos/config.yaml index 321498f..08997f3 100644 --- a/nomos/config.yaml +++ b/nomos/config.yaml @@ -1,4 +1,4 @@ -# Nomos agent config — standalone MCP client gateway (Phase 4) +# Nomos agent config — LLM-backed resident agent (Phase 4) mcp: endpoint: ${NOMOS_MCP_URL}?session_id=${NOMOS_SESSION_ID} @@ -12,22 +12,7 @@ agent: name: nomos slug: ${NOMOS_AGENT_SLUG} -query_routing: - # Maps natural-language query patterns to MCP tools - - pattern: "depends on" - tool: get_blast_radius - entity_param: entity_id - - pattern: "restart" - tool: request_execution - action: restart - - pattern: "health" - tool: get_health_summary - - pattern: "what is" - tool: get_entity - entity_param: slug_or_id - - pattern: "recent events" - tool: get_event_timeline - - pattern: "signals" - tool: get_signal_history - - pattern: "patterns" - tool: get_patterns +llm: + provider: openrouter + model: ${NOMOS_MODEL} + max_iterations: 15 diff --git a/plans/2026-07-08-nomos-resident-agent.md b/plans/2026-07-08-nomos-resident-agent.md index 84b3c2e..198cd6c 100644 --- a/plans/2026-07-08-nomos-resident-agent.md +++ b/plans/2026-07-08-nomos-resident-agent.md @@ -1,6 +1,6 @@ # 2026-07-08 — Nomos resident agent (renames Hermes) -**Status:** In Progress — N0 complete 2026-07-08 +**Status:** In Progress — N0-N3 complete 2026-07-08 ## Goal diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..7f10c6e --- /dev/null +++ b/web/embed.go @@ -0,0 +1,21 @@ +// Package web embeds the compiled control-room SPA (web/dist) into the oikos +// binary, preserving the single-binary deployment (ADR-0001). The dist tree is +// produced by `npm run build` (or the Docker ui-builder stage); a committed +// web/dist/.gitkeep keeps a backend-only `go build` green when the UI has not +// been built. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// DistFS returns the built SPA rooted at dist/. When the UI has not been built +// (only the .gitkeep placeholder is present), Open("index.html") will fail and +// the caller serves a 404 — the binary still starts. +func DistFS() (fs.FS, error) { + return fs.Sub(dist, "dist") +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..2f67c9e --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + Oikos — Control Room + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..4708d06 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1438 @@ +{ + "name": "oikos-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "oikos-web", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tsconfig/svelte": "^5.0.0", + "svelte": "^5.0.0", + "typescript": "^5.5.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "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/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/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/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.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "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/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..de54a56 --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "oikos-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && touch dist/.gitkeep", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tsconfig/svelte": "^5.0.0", + "svelte": "^5.0.0", + "typescript": "^5.5.0", + "vite": "^6.0.0" + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..b3a123c --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,155 @@ + + +
+ + +
+ {#if page === 'chat'} + + {:else if page === 'sessions'} + + {:else} + + {/if} +
+ + {#if drawerOpen} + + {/if} +
+ + diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..08008af --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,62 @@ +:root { + --bg: #0d1117; + --bg-surface: #161b22; + --bg-deeper: #0a0e13; + --bg-hover: #21262d; + --bg-active: #292e36; + --border: #30363d; + --text: #e6edf3; + --text-muted: #8b949e; + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-red: #f85149; + --accent-orange: #d29922; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; +} + +#app { + height: 100%; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +a { + color: var(--accent-blue); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..8ae0261 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,92 @@ +const BASE = '/agent' + +export interface Session { + id: string + title: string + actor: string + created_at: string + last_active_at: string +} + +export interface Message { + id: string + session_id: string + role: string + content: any + created_at: string +} + +export async function fetchSessions(): Promise { + const res = await fetch(`${BASE}/sessions`) + if (!res.ok) return [] + const data = await res.json() + return data.sessions ?? [] +} + +export async function fetchMessages(sessionId: string): Promise { + const res = await fetch(`${BASE}/sessions/${sessionId}`) + if (!res.ok) return [] + const data = await res.json() + return data.messages ?? [] +} + +export interface ChatEvent { + type: string + data: any + session_id?: string + iteration?: number +} + +export function streamChat( + message: string, + sessionId: string | null, + onEvent: (ev: ChatEvent) => void, + onError: (err: string) => void, + onDone: () => void +): AbortController { + const controller = new AbortController() + + fetch(`${BASE}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, session_id: sessionId ?? undefined }), + signal: controller.signal + }).then(async (res) => { + if (!res.ok) { + onError(`HTTP ${res.status}`) + return + } + const reader = res.body?.getReader() + if (!reader) { + onError('no response body') + return + } + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const ev: ChatEvent = JSON.parse(line.slice(6)) + onEvent(ev) + } catch { + // skip malformed + } + } + } + } + }).catch((err) => { + onError(err.message) + }).finally(() => { + onDone() + }) + + return controller +} diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts new file mode 100644 index 0000000..aa72857 --- /dev/null +++ b/web/src/lib/stores/chat.ts @@ -0,0 +1,162 @@ +import { writable, get } from 'svelte/store' +import { streamChat, fetchSessions, fetchMessages } from '$lib/api' +import type { ChatEvent, Session, Message } from '$lib/api' + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' + text: string + tools: ToolCallResult[] +} + +export interface ToolCallResult { + type: 'tool_use' | 'tool_result' + name: string + id?: string + args?: any + result?: any + error?: string +} + +function mid(): string { + return crypto.randomUUID() +} + +export const messages = writable([]) +export const streaming = writable(false) +export const currentSession = writable(null) +export const sessions = writable([]) +export const sessionMessages = writable([]) +export const error = writable(null) + +let activeController: AbortController | null = null + +export async function loadSessions() { + const list = await fetchSessions() + sessions.set(list) +} + +export async function loadSessionMessages(sessionId: string) { + currentSession.set(sessionId) + const msgs = await fetchMessages(sessionId) + sessionMessages.set(msgs) + const chatMsgs: ChatMessage[] = msgs.map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''), + tools: m.content?.tool_calls ?? [] + })) + messages.set(chatMsgs) +} + +export function sendMessage(text: string) { + error.set(null) + streaming.set(true) + + const userMsg: ChatMessage = { + id: mid(), + role: 'user', + text, + tools: [] + } + messages.update((ms) => [...ms, userMsg]) + + const assistantMsg: ChatMessage = { + id: mid(), + role: 'assistant', + text: '', + tools: [] + } + messages.update((ms) => [...ms, assistantMsg]) + + let activeTools: Map = new Map() + + activeController = streamChat( + text, + get(currentSession), // continue the active session so the agent keeps context + (ev: ChatEvent) => { + if (ev.type === 'session') { + currentSession.set(ev.data) + } else if (ev.type === 'tool_use') { + const tr: ToolCallResult = { + type: 'tool_use', + name: ev.data.name, + id: ev.data.id, + args: ev.data.args + } + activeTools.set(ev.data.id, tr) + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + last.tools = [...last.tools, tr] + } + return [...ms] + }) + } else if (ev.type === 'tool_result') { + const existing = activeTools.get(ev.data.id) + if (existing) { + const updated: ToolCallResult = { + ...existing, + type: 'tool_result', + result: ev.data.result, + error: ev.data.error + } + activeTools.set(ev.data.id, updated) + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + last.tools = last.tools.map((t) => + t.id === ev.data.id ? updated : t + ) + } + return [...ms] + }) + } + } else if (ev.type === 'text_delta') { + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + last.text += ev.data + } + return [...ms] + }) + } else if (ev.type === 'text') { + // Final authoritative content for the turn; replaces accumulated deltas. + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + last.text = ev.data + } + return [...ms] + }) + } else if (ev.type === 'done') { + currentSession.set(ev.data?.session_id ?? ev.session_id) + } else if (ev.type === 'error') { + error.set(ev.data) + } + }, + (err: string) => { + error.set(err) + }, + () => { + streaming.set(false) + activeController = null + loadSessions() + } + ) +} + +export function newChat() { + cancelStream() + currentSession.set(null) + messages.set([]) + error.set(null) +} + +export function cancelStream() { + if (activeController) { + activeController.abort() + activeController = null + streaming.set(false) + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..bfe39f4 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,6 @@ +import { mount } from 'svelte' +import App from './App.svelte' +import './app.css' + +const app = mount(App, { target: document.getElementById('app')! }) +export default app diff --git a/web/src/pages/Chat.svelte b/web/src/pages/Chat.svelte new file mode 100644 index 0000000..da64c96 --- /dev/null +++ b/web/src/pages/Chat.svelte @@ -0,0 +1,263 @@ + + +
+
+ {#each $messages as msg (msg.id)} +
+
{msg.role === 'user' ? 'You' : 'Nomos'}
+ {#if msg.text} +
{msg.text}
+ {/if} + {#each msg.tools as tool (tool.id)} +
+
+ {tool.type === 'tool_use' ? '⚙' : '✓'} + {tool.name} +
+ {#if tool.type === 'tool_use' && tool.args} +
+
{JSON.stringify(tool.args, null, 2)}
+
+ {/if} + {#if tool.type === 'tool_result'} +
+ {#if tool.error} +
{tool.error}
+ {:else} +
{JSON.stringify(tool.result, null, 2)}
+ {/if} +
+ {/if} +
+ {/each} + {#if !msg.text && msg.tools.length === 0 && msg.role === 'assistant'} +
Thinking
+ {/if} +
+ {/each} +
+
+ + {#if $error} +
+ {$error} +
+ {/if} + +
+ + {#if $streaming} + + {:else} + + {/if} +
+
+ + diff --git a/web/src/pages/Sessions.svelte b/web/src/pages/Sessions.svelte new file mode 100644 index 0000000..4db2b62 --- /dev/null +++ b/web/src/pages/Sessions.svelte @@ -0,0 +1,90 @@ + + +
+

Sessions

+
+ {#each $sessions as session (session.id)} + + {:else} +
No sessions yet. Start chatting with Nomos.
+ {/each} +
+
+ + diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f73c3c8 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "paths": { + "$lib/*": ["./src/lib/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..b428259 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,20 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [svelte()], + base: '/ui/', + resolve: { + alias: { $lib: '/src/lib' } + }, + build: { + outDir: 'dist', + emptyOutDir: true + }, + server: { + proxy: { + '/api': 'http://localhost:8090', + '/agent': 'http://localhost:8092' + } + } +})