diff --git a/.github/workflows/router-binaries.yml b/.github/workflows/cli-binaries.yml similarity index 76% rename from .github/workflows/router-binaries.yml rename to .github/workflows/cli-binaries.yml index acbe0d16..771bd44c 100644 --- a/.github/workflows/router-binaries.yml +++ b/.github/workflows/cli-binaries.yml @@ -1,13 +1,13 @@ -name: Router Binaries +name: CLI Binaries on: push: tags: - - "router-v*" + - "cli-v*" workflow_dispatch: inputs: tag: - description: "Release tag to publish (e.g. router-v0.1.0)" + description: "Release tag to publish (e.g. cli-v0.1.0)" required: true type: string @@ -47,7 +47,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version-file: router/go.mod + go-version-file: cli/go.mod - name: Build binary shell: bash @@ -59,17 +59,17 @@ jobs: set -euo pipefail mkdir -p dist - binary_name="agentation-router" + binary_name="agentation" if [[ "$GOOS" == "windows" ]]; then binary_name="${binary_name}.exe" fi - target_dir="dist/agentation-router-${GOOS}-${GOARCH}" + target_dir="dist/agentation-${GOOS}-${GOARCH}" mkdir -p "$target_dir" - go build -trimpath -ldflags="-s -w" -o "$target_dir/$binary_name" ./router/cmd/agentation-router + go build -trimpath -ldflags="-s -w" -o "$target_dir/$binary_name" ./cli/cmd/agentation - cp router/README.md "$target_dir/README.md" + cp cli/README.md "$target_dir/README.md" cp LICENSE "$target_dir/LICENSE" - name: Archive build @@ -80,7 +80,7 @@ jobs: ARCHIVE_EXT: ${{ matrix.archive_ext }} run: | set -euo pipefail - base="agentation-router-${GOOS}-${GOARCH}" + base="agentation-${GOOS}-${GOARCH}" pushd dist >/dev/null if [[ "$ARCHIVE_EXT" == "zip" ]]; then @@ -94,10 +94,10 @@ jobs: - name: Upload archive artifact uses: actions/upload-artifact@v4 with: - name: router-${{ matrix.goos }}-${{ matrix.goarch }} + name: cli-${{ matrix.goos }}-${{ matrix.goarch }} path: | - dist/agentation-router-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - dist/agentation-router-${{ matrix.goos }}-${{ matrix.goarch }}.zip + dist/agentation-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + dist/agentation-${{ matrix.goos }}-${{ matrix.goarch }}.zip if-no-files-found: ignore publish: @@ -108,7 +108,7 @@ jobs: uses: actions/download-artifact@v4 with: path: dist - pattern: router-* + pattern: cli-* merge-multiple: true - name: Generate checksums @@ -116,14 +116,14 @@ jobs: run: | set -euo pipefail cd dist - sha256sum agentation-router-* > checksums.txt + sha256sum agentation-* > checksums.txt - name: Publish release assets uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} files: | - dist/agentation-router-*.tar.gz - dist/agentation-router-*.zip + dist/agentation-*.tar.gz + dist/agentation-*.zip dist/checksums.txt generate_release_notes: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8ec0edc7..1a910762 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,25 +14,25 @@ jobs: - uses: actions/setup-go@v5 with: - go-version-file: router/go.mod + go-version-file: cli/go.mod - - name: Router tests - working-directory: router + - name: CLI tests + working-directory: cli run: go test ./... - - name: Router cross-build smoke - working-directory: router + - name: CLI cross-build smoke + working-directory: cli env: CGO_ENABLED: "0" run: | set -euo pipefail for goos in linux darwin windows; do for goarch in amd64 arm64; do - output="../tmp/router-ci/agentation-router-${goos}-${goarch}" + output="../tmp/cli-ci/agentation-${goos}-${goarch}" if [[ "$goos" == "windows" ]]; then output="${output}.exe" fi - GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" ./cmd/agentation-router + GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" ./cmd/agentation done done diff --git a/README.md b/README.md index b9c5d425..13dbd338 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ - Deep select for piercing overlays - Alt hold-to-select mode with a crosshair cursor - Review queue for resolved annotations -- MCP support for surfacing human thread replies +- Local CLI/server support for surfacing human thread replies - Storybook plus expanded automated test coverage -- Neovim integration ([`nvim/README.md`](nvim/README.md)) and a local router daemon ([`router/README.md`](router/README.md)) for multi-project Neovim routing +- Neovim integration ([`nvim/README.md`](nvim/README.md)) and a local router daemon managed by the Go CLI for multi-project Neovim routing --- @@ -80,7 +80,7 @@ pnpm build pnpm test ``` -Router binaries are published via GitHub Actions on tags matching `router-v*`. +CLI binaries are published via GitHub Actions on tags matching `cli-v*`. ## License diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..56aa8e80 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,105 @@ +# agentation + +Go-based CLI companion for the Agentation HTTP server. + +## Build + +```bash +cd cli +go build ./cmd/agentation +``` + +Or with just from this directory: + +```bash +cd cli +just build +``` + +## Usage + +```bash +agentation +``` + +Commands: + +- `sessions [--base-url ]` +- `session [--base-url ] ` +- `pending [--base-url ] [--session ]` +- `ack [--base-url ] ` +- `resolve [--base-url ] [--summary "..."]` +- `dismiss [--base-url ] --reason "..."` +- `reply [--base-url ] --message "..."` +- `watch [--base-url ] [--session ] [--batch-window 10] [--timeout 120]` +- `start [--server-addr host:port|0] [--router-addr host:port|0] [--foreground|--background]` +- `stop` +- `status` + +Add `--json` to API/data commands for machine-readable output. + +You can set a default API endpoint with: + +```bash +AGENTATION_BASE_URL=http://127.0.0.1:4747 agentation pending --json +``` + +## Lifecycle management + +```bash +# Start both services (default) +agentation start + +# Start with explicit addresses +agentation start --server-addr 127.0.0.1:4747 --router-addr 127.0.0.1:8787 + +# Disable one service by setting address to 0 +AGENTATION_SERVER_ADDR=0 agentation start +AGENTATION_ROUTER_ADDR=0 agentation start + +agentation status +agentation stop +``` + +Notes: + +- `start` runs as a **single PID** that manages both server and router. +- By default, both services start. +- Set `AGENTATION_SERVER_ADDR=0` to disable server, or `AGENTATION_ROUTER_ADDR=0` to disable router. +- `--server-addr` / `--router-addr` override environment values. +- `--foreground` runs in current shell; `--background` daemonizes. + +## Environment variables + +- `AGENTATION_BASE_URL` (default base URL for API commands: `http://localhost:4747`) +- `AGENTATION_STORE` (`sqlite` by default, set to `memory` for in-memory mode) +- `AGENTATION_DB_PATH` (explicit SQLite DB path override) +- `XDG_DATA_HOME` (used for default SQLite location when `AGENTATION_DB_PATH` is unset) +- `AGENTATION_SERVER_ADDR` (default server address for `agentation start`; use `0` to disable) +- `AGENTATION_ROUTER_ADDR` (default router address for `agentation start`; use `0` to disable) +- `AGENTATION_PID_FILE` (override single PID file for stack lifecycle) +- `AGENTATION_LOG_FILE` (override stack supervisor log file for background mode) +- `AGENTATION_SERVER_LOG_FILE` (override server log file) +- `AGENTATION_ROUTER_LOG_FILE` (override router log file) +- `AGENTATION_ROUTER_ADDRESS` (legacy fallback router address if `AGENTATION_ROUTER_ADDR` is unset) +- `AGENTATION_ROUTER_TOKEN` (optional auth token for mutating router endpoints) +- `AGENTATION_ROUTER_BODY_LIMIT` (max router request body size) +- `AGENTATION_ROUTER_FORWARD_TIMEOUT` (router forward timeout) +- `AGENTATION_ROUTER_READ_TIMEOUT` / `AGENTATION_ROUTER_WRITE_TIMEOUT` +- `AGENTATION_ROUTER_READ_HEADER_TIMEOUT` / `AGENTATION_ROUTER_IDLE_TIMEOUT` +- `AGENTATION_ROUTER_SESSION_STALE_AFTER` +- `AGENTATION_ROUTER_ALLOW_ABSOLUTE_PATHS` +- `AGENTATION_ROUTER_ENFORCE_ROOT_BOUNDS` + +## SQLite storage location + +By default, data is stored in SQLite at: + +- `$XDG_DATA_HOME/agentation/store.db` (if `XDG_DATA_HOME` is set) +- otherwise `~/.local/share/agentation/store.db` + +You can override the DB file completely with: + +```bash +AGENTATION_DB_PATH=/absolute/path/store.db agentation start --foreground +``` diff --git a/cli/cmd/agentation/main.go b/cli/cmd/agentation/main.go new file mode 100644 index 00000000..6c5e778c --- /dev/null +++ b/cli/cmd/agentation/main.go @@ -0,0 +1,395 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/benjitaylor/agentation/cli/internal/api" + "github.com/benjitaylor/agentation/cli/internal/lifecycle" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stdout) + return 0 + } + + command := args[0] + commandArgs := args[1:] + ctx := context.Background() + + switch command { + case "sessions": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runSessions) + case "session": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runSession) + case "pending": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runPending) + case "ack": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runAcknowledge) + case "resolve": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runResolve) + case "dismiss": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runDismiss) + case "reply": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runReply) + case "watch": + return runWithAPICommand(ctx, commandArgs, stdout, stderr, runWatch) + case "start": + return lifecycle.RunStart(commandArgs, stdout, stderr) + case "stop": + return lifecycle.RunStop(commandArgs, stdout, stderr) + case "status": + return lifecycle.RunStatus(commandArgs, stdout, stderr) + case "__serve-stack": + return lifecycle.RunServe(commandArgs, stdout, stderr) + case "help", "--help", "-h": + printUsage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "error: unknown command %q\n\n", command) + printUsage(stderr) + return 1 + } +} + +type apiCommandRunner func(context.Context, *api.Client, []string, io.Writer, io.Writer) error + +func runWithAPICommand(ctx context.Context, args []string, stdout, stderr io.Writer, runner apiCommandRunner) int { + baseURL, remainingArgs, err := extractBaseURL(args) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + client := api.NewClient(baseURL) + if err := runner(ctx, client, remainingArgs, stdout, stderr); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + return 0 +} + +func extractBaseURL(args []string) (string, []string, error) { + baseURL := strings.TrimSpace(os.Getenv("AGENTATION_BASE_URL")) + if baseURL == "" { + baseURL = "http://localhost:4747" + } + + remaining := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--base-url" { + if i+1 >= len(args) { + return "", nil, fmt.Errorf("--base-url requires a value") + } + i++ + value := strings.TrimSpace(args[i]) + if value == "" || strings.HasPrefix(value, "-") { + return "", nil, fmt.Errorf("--base-url requires a valid URL value") + } + baseURL = value + continue + } + + if after, ok := strings.CutPrefix(arg, "--base-url="); ok { + value := strings.TrimSpace(after) + if value == "" { + return "", nil, fmt.Errorf("--base-url requires a value") + } + baseURL = value + continue + } + + remaining = append(remaining, arg) + } + + return baseURL, remaining, nil +} + +func runSessions(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("sessions", flag.ContinueOnError) + flags.SetOutput(stderr) + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + + sessions, err := client.ListSessions(ctx) + if err != nil { + return err + } + + if *asJSON { + return writeJSON(stdout, sessions) + } + + if len(sessions) == 0 { + fmt.Fprintln(stdout, "No active sessions.") + return nil + } + + for _, session := range sessions { + fmt.Fprintf(stdout, "%s\t%s\t%s\n", session.ID, session.Status, session.URL) + } + + return nil +} + +func runSession(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("session", flag.ContinueOnError) + flags.SetOutput(stderr) + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + + if flags.NArg() != 1 { + return fmt.Errorf("usage: session [--json] ") + } + + sessionID := flags.Arg(0) + session, err := client.GetSession(ctx, sessionID) + if err != nil { + return err + } + + if *asJSON { + return writeJSON(stdout, session) + } + + fmt.Fprintf(stdout, "Session: %s\n", session.ID) + fmt.Fprintf(stdout, "URL: %s\n", session.URL) + fmt.Fprintf(stdout, "Status: %s\n", session.Status) + fmt.Fprintf(stdout, "Annotations: %d\n", len(session.Annotations)) + return nil +} + +func runPending(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("pending", flag.ContinueOnError) + flags.SetOutput(stderr) + sessionID := flags.String("session", "", "Filter by session ID") + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + + pending, err := client.GetPending(ctx, strings.TrimSpace(*sessionID)) + if err != nil { + return err + } + + if *asJSON { + return writeJSON(stdout, pending) + } + + fmt.Fprintf(stdout, "Pending annotations: %d\n", pending.Count) + for idx, ann := range pending.Annotations { + fmt.Fprintf(stdout, "[%d] %s\n", idx+1, ann.ID) + fmt.Fprintf(stdout, " %s\n", ann.Comment) + if ann.Element != "" { + fmt.Fprintf(stdout, " Element: %s\n", ann.Element) + } + } + + return nil +} + +func runAcknowledge(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("ack", flag.ContinueOnError) + flags.SetOutput(stderr) + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 1 { + return fmt.Errorf("usage: ack [--json] ") + } + + annotationID := flags.Arg(0) + if err := client.Acknowledge(ctx, annotationID); err != nil { + return err + } + + result := map[string]any{"acknowledged": true, "annotationId": annotationID} + if *asJSON { + return writeJSON(stdout, result) + } + + fmt.Fprintf(stdout, "Acknowledged %s\n", annotationID) + return nil +} + +func runResolve(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("resolve", flag.ContinueOnError) + flags.SetOutput(stderr) + summary := flags.String("summary", "", "Optional resolution summary") + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 1 { + return fmt.Errorf("usage: resolve [--summary text] [--json] ") + } + + annotationID := flags.Arg(0) + if err := client.Resolve(ctx, annotationID, *summary); err != nil { + return err + } + + result := map[string]any{"resolved": true, "annotationId": annotationID} + if strings.TrimSpace(*summary) != "" { + result["summary"] = strings.TrimSpace(*summary) + } + + if *asJSON { + return writeJSON(stdout, result) + } + + fmt.Fprintf(stdout, "Resolved %s\n", annotationID) + return nil +} + +func runDismiss(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("dismiss", flag.ContinueOnError) + flags.SetOutput(stderr) + reason := flags.String("reason", "", "Dismissal reason (required)") + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 1 { + return fmt.Errorf("usage: dismiss --reason text [--json] ") + } + if strings.TrimSpace(*reason) == "" { + return fmt.Errorf("dismiss requires --reason") + } + + annotationID := flags.Arg(0) + if err := client.Dismiss(ctx, annotationID, *reason); err != nil { + return err + } + + result := map[string]any{"dismissed": true, "annotationId": annotationID, "reason": strings.TrimSpace(*reason)} + if *asJSON { + return writeJSON(stdout, result) + } + + fmt.Fprintf(stdout, "Dismissed %s\n", annotationID) + return nil +} + +func runReply(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("reply", flag.ContinueOnError) + flags.SetOutput(stderr) + message := flags.String("message", "", "Reply message (required)") + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 1 { + return fmt.Errorf("usage: reply --message text [--json] ") + } + if strings.TrimSpace(*message) == "" { + return fmt.Errorf("reply requires --message") + } + + annotationID := flags.Arg(0) + if err := client.Reply(ctx, annotationID, *message); err != nil { + return err + } + + result := map[string]any{"replied": true, "annotationId": annotationID, "message": strings.TrimSpace(*message)} + if *asJSON { + return writeJSON(stdout, result) + } + + fmt.Fprintf(stdout, "Replied to %s\n", annotationID) + return nil +} + +func runWatch(ctx context.Context, client *api.Client, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("watch", flag.ContinueOnError) + flags.SetOutput(stderr) + sessionID := flags.String("session", "", "Optional session ID filter") + batchWindow := flags.Int("batch-window", 10, "Seconds to collect after first event (1-60)") + timeout := flags.Int("timeout", 120, "Seconds to wait for first event (1-300)") + asJSON := flags.Bool("json", false, "Output JSON") + if err := flags.Parse(args); err != nil { + return err + } + + output, err := client.Watch(ctx, api.WatchOptions{ + SessionID: strings.TrimSpace(*sessionID), + BatchWindow: time.Duration(*batchWindow) * time.Second, + Timeout: time.Duration(*timeout) * time.Second, + }) + if err != nil { + return err + } + + if *asJSON { + return writeJSON(stdout, output) + } + + if output.Timeout { + fmt.Fprintln(stdout, output.Message) + return nil + } + + fmt.Fprintf(stdout, "Received %d annotation(s)\n", output.Count) + for idx, ann := range output.Annotations { + fmt.Fprintf(stdout, "[%d] %s\n", idx+1, ann.ID) + fmt.Fprintf(stdout, " %s\n", ann.Comment) + if ann.SessionID != "" { + fmt.Fprintf(stdout, " Session: %s\n", ann.SessionID) + } + } + return nil +} + +func writeJSON(writer io.Writer, value any) error { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func printUsage(writer io.Writer) { + fmt.Fprintln(writer, "agentation - CLI companion for Agentation HTTP server") + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Commands:") + fmt.Fprintln(writer, " sessions [--base-url ] List sessions") + fmt.Fprintln(writer, " session [--base-url ] Get session with annotations") + fmt.Fprintln(writer, " pending [--base-url ] [--session ] Get pending annotations") + fmt.Fprintln(writer, " ack [--base-url ] Mark annotation acknowledged") + fmt.Fprintln(writer, " resolve [--base-url ] Resolve annotation") + fmt.Fprintln(writer, " dismiss [--base-url ] Dismiss annotation") + fmt.Fprintln(writer, " reply [--base-url ] Add thread reply") + fmt.Fprintln(writer, " watch [--base-url ] Wait for new annotations/thread replies") + fmt.Fprintln(writer, " start Start local services (single PID)") + fmt.Fprintln(writer, " stop Stop local services (single PID)") + fmt.Fprintln(writer, " status Show local service status") + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Examples:") + fmt.Fprintln(writer, " AGENTATION_BASE_URL=http://127.0.0.1:4747 agentation pending --json") + fmt.Fprintln(writer, " agentation pending --base-url http://127.0.0.1:4747 --json") + fmt.Fprintln(writer, " agentation ack --base-url http://127.0.0.1:4747 ann_123") + fmt.Fprintln(writer, " agentation resolve ann_123 --summary \"Updated spacing\"") + fmt.Fprintln(writer, " agentation watch --batch-window 5 --timeout 120 --json") + fmt.Fprintln(writer, " agentation start") + fmt.Fprintln(writer, " AGENTATION_SERVER_ADDR=127.0.0.1:5757 AGENTATION_ROUTER_ADDR=127.0.0.1:8787 agentation start") + fmt.Fprintln(writer, " AGENTATION_SERVER_ADDR=0 agentation start") + fmt.Fprintln(writer, " AGENTATION_ROUTER_ADDR=0 agentation start") + fmt.Fprintln(writer, " agentation start --server-addr 127.0.0.1:4747 --router-addr 127.0.0.1:8787") +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 00000000..7e149886 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,17 @@ +module github.com/benjitaylor/agentation/cli + +go 1.26.1 + +require modernc.org/sqlite v1.36.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 00000000..fb1f5ca6 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.36.0 h1:EQXNRn4nIS+gfsKeUTymHIz1waxuv5BzU7558dHSfH8= +modernc.org/sqlite v1.36.0/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go new file mode 100644 index 00000000..c1a13e69 --- /dev/null +++ b/cli/internal/api/client.go @@ -0,0 +1,187 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultTimeout = 15 * time.Second + +type Client struct { + baseURL string + httpClient *http.Client +} + +func NewClient(baseURL string) *Client { + base := strings.TrimSpace(baseURL) + if base == "" { + base = "http://localhost:4747" + } + + return &Client{ + baseURL: strings.TrimRight(base, "/"), + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + } +} + +func (c *Client) ListSessions(ctx context.Context) ([]Session, error) { + var sessions []Session + if err := c.doJSON(ctx, http.MethodGet, "/sessions", nil, &sessions); err != nil { + return nil, fmt.Errorf("listing sessions: %w", err) + } + return sessions, nil +} + +func (c *Client) GetSession(ctx context.Context, sessionID string) (*SessionWithAnnotations, error) { + var session SessionWithAnnotations + path := fmt.Sprintf("/sessions/%s", url.PathEscape(sessionID)) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &session); err != nil { + return nil, fmt.Errorf("getting session %q: %w", sessionID, err) + } + return &session, nil +} + +func (c *Client) GetPending(ctx context.Context, sessionID string) (*PendingResponse, error) { + var pending PendingResponse + path := "/pending" + if sessionID != "" { + path = fmt.Sprintf("/sessions/%s/pending", url.PathEscape(sessionID)) + } + + if err := c.doJSON(ctx, http.MethodGet, path, nil, &pending); err != nil { + return nil, fmt.Errorf("getting pending annotations: %w", err) + } + return &pending, nil +} + +func (c *Client) Acknowledge(ctx context.Context, annotationID string) error { + path := fmt.Sprintf("/annotations/%s", url.PathEscape(annotationID)) + if err := c.doJSON(ctx, http.MethodPatch, path, map[string]any{"status": "acknowledged"}, nil); err != nil { + return fmt.Errorf("acknowledging annotation %q: %w", annotationID, err) + } + return nil +} + +func (c *Client) Resolve(ctx context.Context, annotationID, summary string) error { + path := fmt.Sprintf("/annotations/%s", url.PathEscape(annotationID)) + body := map[string]any{ + "status": "resolved", + "resolvedBy": "agent", + } + if err := c.doJSON(ctx, http.MethodPatch, path, body, nil); err != nil { + return fmt.Errorf("resolving annotation %q: %w", annotationID, err) + } + + if strings.TrimSpace(summary) == "" { + return nil + } + + message := fmt.Sprintf("Resolved: %s", strings.TrimSpace(summary)) + if err := c.addThreadMessage(ctx, annotationID, "agent", message); err != nil { + return fmt.Errorf("adding resolution summary for annotation %q: %w", annotationID, err) + } + + return nil +} + +func (c *Client) Dismiss(ctx context.Context, annotationID, reason string) error { + path := fmt.Sprintf("/annotations/%s", url.PathEscape(annotationID)) + body := map[string]any{ + "status": "dismissed", + "resolvedBy": "agent", + } + if err := c.doJSON(ctx, http.MethodPatch, path, body, nil); err != nil { + return fmt.Errorf("dismissing annotation %q: %w", annotationID, err) + } + + message := fmt.Sprintf("Dismissed: %s", strings.TrimSpace(reason)) + if err := c.addThreadMessage(ctx, annotationID, "agent", message); err != nil { + return fmt.Errorf("adding dismissal message for annotation %q: %w", annotationID, err) + } + + return nil +} + +func (c *Client) Reply(ctx context.Context, annotationID, message string) error { + if err := c.addThreadMessage(ctx, annotationID, "agent", message); err != nil { + return fmt.Errorf("replying to annotation %q: %w", annotationID, err) + } + return nil +} + +func (c *Client) addThreadMessage(ctx context.Context, annotationID, role, content string) error { + path := fmt.Sprintf("/annotations/%s/thread", url.PathEscape(annotationID)) + body := map[string]any{ + "role": role, + "content": content, + } + if err := c.doJSON(ctx, http.MethodPost, path, body, nil); err != nil { + return err + } + return nil +} + +func (c *Client) doJSON(ctx context.Context, method, path string, body any, target any) error { + requestBody, err := marshalBody(body) + if err != nil { + return fmt.Errorf("marshaling request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, requestBody) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + payload, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("http %d (failed to read error body: %v)", resp.StatusCode, readErr) + } + message := strings.TrimSpace(string(payload)) + if message == "" { + message = http.StatusText(resp.StatusCode) + } + return fmt.Errorf("http %d: %s", resp.StatusCode, message) + } + + if target == nil { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + return nil +} + +func marshalBody(body any) (io.Reader, error) { + if body == nil { + return nil, nil + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(payload), nil +} diff --git a/cli/internal/api/client_test.go b/cli/internal/api/client_test.go new file mode 100644 index 00000000..0eacb644 --- /dev/null +++ b/cli/internal/api/client_test.go @@ -0,0 +1,260 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewClientUsesDefaultBaseURL(t *testing.T) { + client := NewClient(" ") + if client.baseURL != "http://localhost:4747" { + t.Fatalf("client.baseURL = %q, want %q", client.baseURL, "http://localhost:4747") + } +} + +func TestListSessionsAndGetSession(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/sessions": + if request.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", request.Method) + } + _, _ = writer.Write([]byte(`[{"id":"s1","url":"http://example.com","status":"active","createdAt":"now"}]`)) + case "/sessions/s1": + if request.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", request.Method) + } + _, _ = writer.Write([]byte(`{"id":"s1","url":"http://example.com","status":"active","createdAt":"now","annotations":[]}`)) + default: + t.Fatalf("unexpected path: %s", request.URL.Path) + } + })) + defer testServer.Close() + + client := NewClient(testServer.URL) + + sessions, err := client.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions returned error: %v", err) + } + if len(sessions) != 1 || sessions[0].ID != "s1" { + t.Fatalf("unexpected sessions: %#v", sessions) + } + + session, err := client.GetSession(context.Background(), "s1") + if err != nil { + t.Fatalf("GetSession returned error: %v", err) + } + if session.ID != "s1" { + t.Fatalf("session.ID = %q, want %q", session.ID, "s1") + } +} + +func TestGetPendingAllAndBySession(t *testing.T) { + calls := make(map[string]int) + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls[request.URL.Path]++ + switch request.URL.Path { + case "/pending": + _, _ = writer.Write([]byte(`{"count":1,"annotations":[{"id":"a1","sessionId":"s1","comment":"Fix","element":"button","elementPath":"body > button"}]}`)) + case "/sessions/s1/pending": + _, _ = writer.Write([]byte(`{"count":0,"annotations":[]}`)) + default: + t.Fatalf("unexpected path: %s", request.URL.Path) + } + })) + defer testServer.Close() + + client := NewClient(testServer.URL) + + pendingAll, err := client.GetPending(context.Background(), "") + if err != nil { + t.Fatalf("GetPending(all) returned error: %v", err) + } + if pendingAll.Count != 1 { + t.Fatalf("pendingAll.Count = %d, want 1", pendingAll.Count) + } + + pendingSession, err := client.GetPending(context.Background(), "s1") + if err != nil { + t.Fatalf("GetPending(session) returned error: %v", err) + } + if pendingSession.Count != 0 { + t.Fatalf("pendingSession.Count = %d, want 0", pendingSession.Count) + } + + if calls["/pending"] != 1 || calls["/sessions/s1/pending"] != 1 { + t.Fatalf("unexpected calls: %#v", calls) + } +} + +func TestAcknowledgeResolveDismissAndReply(t *testing.T) { + var requests []string + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + requests = append(requests, request.Method+" "+request.URL.Path+" "+strings.TrimSpace(string(body))) + + if request.URL.Path == "/annotations/a1" && request.Method == http.MethodPatch { + _, _ = writer.Write([]byte(`{"id":"a1"}`)) + return + } + if request.URL.Path == "/annotations/a1/thread" && request.Method == http.MethodPost { + _, _ = writer.Write([]byte(`{"id":"a1"}`)) + return + } + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + })) + defer testServer.Close() + + client := NewClient(testServer.URL) + ctx := context.Background() + + if err := client.Acknowledge(ctx, "a1"); err != nil { + t.Fatalf("Acknowledge returned error: %v", err) + } + if err := client.Resolve(ctx, "a1", "updated spacing"); err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if err := client.Resolve(ctx, "a1", " "); err != nil { + t.Fatalf("Resolve(empty summary) returned error: %v", err) + } + if err := client.Dismiss(ctx, "a1", "won't fix"); err != nil { + t.Fatalf("Dismiss returned error: %v", err) + } + if err := client.Reply(ctx, "a1", "on it"); err != nil { + t.Fatalf("Reply returned error: %v", err) + } + + joined := strings.Join(requests, "\n") + mustContain(t, joined, `PATCH /annotations/a1 {"status":"acknowledged"}`) + mustContain(t, joined, `PATCH /annotations/a1 {"resolvedBy":"agent","status":"resolved"}`) + mustContain(t, joined, `POST /annotations/a1/thread {"content":"Resolved: updated spacing","role":"agent"}`) + mustContain(t, joined, `PATCH /annotations/a1 {"resolvedBy":"agent","status":"dismissed"}`) + mustContain(t, joined, `POST /annotations/a1/thread {"content":"Dismissed: won't fix","role":"agent"}`) + mustContain(t, joined, `POST /annotations/a1/thread {"content":"on it","role":"agent"}`) +} + +func TestDoJSONErrorPaths(t *testing.T) { + badStatus := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusBadGateway) + _, _ = writer.Write([]byte("upstream error")) + })) + defer badStatus.Close() + + client := NewClient(badStatus.URL) + err := client.Acknowledge(context.Background(), "a1") + if err == nil || !strings.Contains(err.Error(), "http 502") { + t.Fatalf("expected http 502 error, got %v", err) + } + + invalidJSON := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte("not-json")) + })) + defer invalidJSON.Close() + + client = NewClient(invalidJSON.URL) + _, err = client.ListSessions(context.Background()) + if err == nil || !strings.Contains(err.Error(), "decoding response") { + t.Fatalf("expected decode error, got %v", err) + } + + noBodyError := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusForbidden) + })) + defer noBodyError.Close() + + client = NewClient(noBodyError.URL) + _, err = client.GetSession(context.Background(), "s1") + if err == nil || !strings.Contains(err.Error(), "Forbidden") { + t.Fatalf("expected status text fallback, got %v", err) + } + + client = NewClient("http://127.0.0.1:1") + err = client.Acknowledge(context.Background(), "a1") + if err == nil || !strings.Contains(err.Error(), "sending request") { + t.Fatalf("expected transport error, got %v", err) + } +} + +func TestClientActionAndLookupErrorPaths(t *testing.T) { + failingServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/sessions/missing" { + writer.WriteHeader(http.StatusNotFound) + _, _ = writer.Write([]byte("missing")) + return + } + if request.URL.Path == "/annotations/a1" && request.Method == http.MethodPatch { + _, _ = writer.Write([]byte(`{"id":"a1"}`)) + return + } + if request.URL.Path == "/annotations/a1/thread" && request.Method == http.MethodPost { + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte("thread failed")) + return + } + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte("unexpected")) + })) + defer failingServer.Close() + + client := NewClient(failingServer.URL) + + _, err := client.GetSession(context.Background(), "missing") + if err == nil || !strings.Contains(err.Error(), "getting session") { + t.Fatalf("expected get session error, got %v", err) + } + + err = client.Resolve(context.Background(), "a1", "summary") + if err == nil || !strings.Contains(err.Error(), "adding resolution summary") { + t.Fatalf("expected resolve thread error, got %v", err) + } + + err = client.Dismiss(context.Background(), "a1", "reason") + if err == nil || !strings.Contains(err.Error(), "adding dismissal message") { + t.Fatalf("expected dismiss thread error, got %v", err) + } + + err = client.Reply(context.Background(), "a1", "hello") + if err == nil || !strings.Contains(err.Error(), "replying to annotation") { + t.Fatalf("expected reply error, got %v", err) + } +} + +func TestMarshalBody(t *testing.T) { + reader, err := marshalBody(nil) + if err != nil { + t.Fatalf("marshalBody(nil) error: %v", err) + } + if reader != nil { + t.Fatal("marshalBody(nil) should return nil reader") + } + + reader, err = marshalBody(map[string]any{"a": 1}) + if err != nil { + t.Fatalf("marshalBody(map) error: %v", err) + } + payload, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + + parsed := make(map[string]any) + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if parsed["a"].(float64) != 1 { + t.Fatalf("parsed[a] = %v, want 1", parsed["a"]) + } +} + +func mustContain(t *testing.T, text, fragment string) { + t.Helper() + if !strings.Contains(text, fragment) { + t.Fatalf("expected %q to contain %q", text, fragment) + } +} diff --git a/cli/internal/api/types.go b/cli/internal/api/types.go new file mode 100644 index 00000000..d000e63b --- /dev/null +++ b/cli/internal/api/types.go @@ -0,0 +1,60 @@ +package api + +import "time" + +type Session struct { + ID string `json:"id"` + URL string `json:"url"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` + ProjectID string `json:"projectId,omitempty"` +} + +type ThreadMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Timestamp int64 `json:"timestamp"` +} + +type Annotation struct { + ID string `json:"id"` + SessionID string `json:"sessionId,omitempty"` + Comment string `json:"comment"` + Element string `json:"element"` + ElementPath string `json:"elementPath"` + URL string `json:"url,omitempty"` + Intent string `json:"intent,omitempty"` + Severity string `json:"severity,omitempty"` + Status string `json:"status,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + NearbyText string `json:"nearbyText,omitempty"` + ReactComponents string `json:"reactComponents,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + Thread []ThreadMessage `json:"thread,omitempty"` +} + +type SessionWithAnnotations struct { + Session + Annotations []Annotation `json:"annotations"` +} + +type PendingResponse struct { + Count int `json:"count"` + Annotations []Annotation `json:"annotations"` +} + +type WatchOptions struct { + SessionID string + BatchWindow time.Duration + Timeout time.Duration +} + +type WatchOutput struct { + Timeout bool `json:"timeout"` + Message string `json:"message,omitempty"` + Count int `json:"count,omitempty"` + Sessions []string `json:"sessions,omitempty"` + Annotations []Annotation `json:"annotations,omitempty"` +} diff --git a/cli/internal/api/watch.go b/cli/internal/api/watch.go new file mode 100644 index 00000000..5ce553cb --- /dev/null +++ b/cli/internal/api/watch.go @@ -0,0 +1,258 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + defaultBatchWindow = 10 * time.Second + defaultWatchTimeout = 120 * time.Second + maxBatchWindow = 60 * time.Second + maxWatchTimeout = 300 * time.Second +) + +type afsEvent struct { + Type string `json:"type"` + SessionID string `json:"sessionId"` + Sequence int `json:"sequence"` + Payload json.RawMessage `json:"payload"` +} + +func (c *Client) Watch(ctx context.Context, opts WatchOptions) (*WatchOutput, error) { + batchWindow := clampDuration(opts.BatchWindow, defaultBatchWindow, time.Second, maxBatchWindow) + watchTimeout := clampDuration(opts.Timeout, defaultWatchTimeout, time.Second, maxWatchTimeout) + + pending, err := c.GetPending(ctx, opts.SessionID) + if err != nil { + return nil, fmt.Errorf("draining pending annotations before watch: %w", err) + } + if pending.Count > 0 { + sessions := uniqueSessions(pending.Annotations) + return &WatchOutput{ + Timeout: false, + Count: pending.Count, + Sessions: sessions, + Annotations: pending.Annotations, + }, nil + } + + watchCtx, cancel := context.WithTimeout(ctx, watchTimeout) + defer cancel() + + events := make(chan Annotation, 32) + errs := make(chan error, 1) + go c.streamAnnotations(watchCtx, opts.SessionID, events, errs) + + collected := make(map[string]Annotation) + order := make([]string, 0) + var batchTimer *time.Timer + var batchDone <-chan time.Time + + for { + select { + case ann := <-events: + if ann.ID == "" { + continue + } + if _, exists := collected[ann.ID]; !exists { + order = append(order, ann.ID) + } + collected[ann.ID] = ann + + if batchTimer == nil { + batchTimer = time.NewTimer(batchWindow) + batchDone = batchTimer.C + } + + case <-batchDone: + return buildWatchOutput(collected, order), nil + + case err := <-errs: + if len(collected) > 0 { + return buildWatchOutput(collected, order), nil + } + if err == nil { + continue + } + return nil, fmt.Errorf("watch stream failed: %w", err) + + case <-watchCtx.Done(): + if batchTimer != nil { + batchTimer.Stop() + } + if len(collected) > 0 { + return buildWatchOutput(collected, order), nil + } + if errors.Is(watchCtx.Err(), context.DeadlineExceeded) { + return &WatchOutput{ + Timeout: true, + Message: fmt.Sprintf("No new annotations within %d seconds", int(watchTimeout.Seconds())), + }, nil + } + return nil, fmt.Errorf("watch canceled: %w", watchCtx.Err()) + } + } +} + +func (c *Client) streamAnnotations(ctx context.Context, sessionID string, out chan<- Annotation, errs chan<- error) { + ssePath := "/events?agent=true" + if sessionID != "" { + ssePath = fmt.Sprintf("/sessions/%s/events?agent=true", url.PathEscape(sessionID)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+ssePath, nil) + if err != nil { + errs <- fmt.Errorf("creating watch request: %w", err) + return + } + req.Header.Set("Accept", "text/event-stream") + + resp, err := c.httpClient.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + errs <- nil + return + } + errs <- fmt.Errorf("opening watch stream: %w", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errs <- fmt.Errorf("watch endpoint returned http %d", resp.StatusCode) + return + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + dataLines := make([]string, 0) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + if len(dataLines) > 0 { + c.handleEventPayload(strings.Join(dataLines, "\n"), sessionID, out) + dataLines = dataLines[:0] + } + continue + } + + if strings.HasPrefix(line, ":") { + continue + } + if after, ok := strings.CutPrefix(line, "data:"); ok { + data := strings.TrimSpace(after) + dataLines = append(dataLines, data) + } + } + + if scannerErr := scanner.Err(); scannerErr != nil { + if errors.Is(scannerErr, context.Canceled) || errors.Is(scannerErr, context.DeadlineExceeded) { + errs <- nil + return + } + errs <- fmt.Errorf("reading watch stream: %w", scannerErr) + return + } + + if ctx.Err() != nil { + errs <- nil + return + } + + errs <- errors.New("watch stream closed unexpectedly") +} + +func (c *Client) handleEventPayload(payload, sessionID string, out chan<- Annotation) { + var event afsEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return + } + + if event.Sequence == 0 { + return + } + if sessionID != "" && event.SessionID != sessionID { + return + } + + switch event.Type { + case "annotation.created": + var ann Annotation + if err := json.Unmarshal(event.Payload, &ann); err != nil { + return + } + if ann.SessionID == "" { + ann.SessionID = event.SessionID + } + out <- ann + + case "thread.message": + var ann Annotation + if err := json.Unmarshal(event.Payload, &ann); err != nil { + return + } + if len(ann.Thread) == 0 { + return + } + last := ann.Thread[len(ann.Thread)-1] + if last.Role != "human" { + return + } + if ann.SessionID == "" { + ann.SessionID = event.SessionID + } + out <- ann + } +} + +func buildWatchOutput(collected map[string]Annotation, order []string) *WatchOutput { + annotations := make([]Annotation, 0, len(order)) + for _, id := range order { + annotations = append(annotations, collected[id]) + } + + return &WatchOutput{ + Timeout: false, + Count: len(annotations), + Sessions: uniqueSessions(annotations), + Annotations: annotations, + } +} + +func uniqueSessions(annotations []Annotation) []string { + seen := make(map[string]struct{}) + sessions := make([]string, 0) + for _, ann := range annotations { + if ann.SessionID == "" { + continue + } + if _, exists := seen[ann.SessionID]; exists { + continue + } + seen[ann.SessionID] = struct{}{} + sessions = append(sessions, ann.SessionID) + } + return sessions +} + +func clampDuration(value, fallback, minValue, maxValue time.Duration) time.Duration { + if value <= 0 { + value = fallback + } + if value < minValue { + value = minValue + } + if value > maxValue { + value = maxValue + } + return value +} diff --git a/cli/internal/api/watch_additional_test.go b/cli/internal/api/watch_additional_test.go new file mode 100644 index 00000000..cf72b4bb --- /dev/null +++ b/cli/internal/api/watch_additional_test.go @@ -0,0 +1,249 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestWatchTimeoutWhenNoEvents(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/pending": + _, _ = writer.Write([]byte(`{"count":0,"annotations":[]}`)) + case "/events": + writer.Header().Set("Content-Type", "text/event-stream") + flusher, ok := writer.(http.Flusher) + if !ok { + t.Fatal("missing flusher") + } + _, _ = writer.Write([]byte(": connected\n\n")) + flusher.Flush() + <-request.Context().Done() + default: + t.Fatalf("unexpected path: %s", request.URL.Path) + } + })) + defer testServer.Close() + + client := NewClient(testServer.URL) + output, err := client.Watch(context.Background(), WatchOptions{ + Timeout: 1100 * time.Millisecond, + }) + if err != nil { + t.Fatalf("Watch returned error: %v", err) + } + if !output.Timeout { + t.Fatal("expected timeout output") + } + if !strings.Contains(output.Message, "No new annotations") { + t.Fatalf("unexpected timeout message: %q", output.Message) + } +} + +func TestWatchReturnsErrorWhenPendingFails(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte("boom")) + })) + defer testServer.Close() + + client := NewClient(testServer.URL) + _, err := client.Watch(context.Background(), WatchOptions{Timeout: time.Second}) + if err == nil || !strings.Contains(err.Error(), "draining pending annotations") { + t.Fatalf("expected pending drain error, got %v", err) + } +} + +func TestWatchUsesSessionEventsPath(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/sessions/s1/pending": + _, _ = writer.Write([]byte(`{"count":0,"annotations":[]}`)) + case "/sessions/s1/events": + writer.Header().Set("Content-Type", "text/event-stream") + flusher, ok := writer.(http.Flusher) + if !ok { + t.Fatal("missing flusher") + } + _, _ = writer.Write([]byte(`data: {"type":"annotation.created","sessionId":"s1","sequence":1,"payload":{"id":"a1","comment":"Fix","element":"button","elementPath":"body > button"}}` + "\n\n")) + flusher.Flush() + <-request.Context().Done() + default: + t.Fatalf("unexpected path: %s", request.URL.Path) + } + })) + defer testServer.Close() + + client := NewClient(testServer.URL) + output, err := client.Watch(context.Background(), WatchOptions{ + SessionID: "s1", + BatchWindow: 50 * time.Millisecond, + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("Watch returned error: %v", err) + } + if output.Count != 1 || output.Annotations[0].SessionID != "s1" { + t.Fatalf("unexpected output: %#v", output) + } +} + +func TestHandleEventPayloadFilters(t *testing.T) { + client := NewClient("http://localhost:4747") + out := make(chan Annotation, 10) + + client.handleEventPayload("not-json", "", out) + client.handleEventPayload(`{"type":"annotation.created","sessionId":"s1","sequence":0,"payload":{"id":"a0"}}`, "", out) + client.handleEventPayload(`{"type":"annotation.created","sessionId":"s2","sequence":1,"payload":{"id":"a1"}}`, "s1", out) + client.handleEventPayload(`{"type":"thread.message","sessionId":"s1","sequence":1,"payload":{"id":"a2","thread":[]}}`, "", out) + client.handleEventPayload(`{"type":"thread.message","sessionId":"s1","sequence":1,"payload":{"id":"a3","thread":[{"role":"agent","content":"x","timestamp":1}]}}`, "", out) + client.handleEventPayload(`{"type":"unknown","sessionId":"s1","sequence":1,"payload":{"id":"a4"}}`, "", out) + + if len(out) != 0 { + t.Fatalf("expected no forwarded annotations, got %d", len(out)) + } + + client.handleEventPayload(`{"type":"annotation.created","sessionId":"s1","sequence":2,"payload":{"id":"a5","comment":"Fix","element":"button","elementPath":"body > button"}}`, "", out) + client.handleEventPayload(`{"type":"thread.message","sessionId":"s1","sequence":2,"payload":{"id":"a6","thread":[{"role":"human","content":"help","timestamp":1}]}}`, "", out) + + if len(out) != 2 { + t.Fatalf("expected 2 forwarded annotations, got %d", len(out)) + } +} + +func TestWatchReturnsCollectedOnStreamError(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/pending": + _, _ = writer.Write([]byte(`{"count":0,"annotations":[]}`)) + case "/events": + writer.Header().Set("Content-Type", "text/event-stream") + flusher, ok := writer.(http.Flusher) + if !ok { + t.Fatal("missing flusher") + } + _, _ = writer.Write([]byte(`data: {"type":"annotation.created","sessionId":"s1","sequence":1,"payload":{"id":"a1","comment":"Fix","element":"button","elementPath":"body > button"}}` + "\n\n")) + flusher.Flush() + return + default: + t.Fatalf("unexpected path: %s", request.URL.Path) + } + })) + defer testServer.Close() + + client := NewClient(testServer.URL) + output, err := client.Watch(context.Background(), WatchOptions{ + BatchWindow: 2 * time.Second, + Timeout: 3 * time.Second, + }) + if err != nil { + t.Fatalf("Watch returned error: %v", err) + } + if output.Count != 1 { + t.Fatalf("output.Count = %d, want 1", output.Count) + } +} + +func TestWatchReturnsCanceledError(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/pending": + _, _ = writer.Write([]byte(`{"count":0,"annotations":[]}`)) + case "/events": + writer.Header().Set("Content-Type", "text/event-stream") + flusher, ok := writer.(http.Flusher) + if !ok { + t.Fatal("missing flusher") + } + _, _ = writer.Write([]byte(": connected\n\n")) + flusher.Flush() + <-request.Context().Done() + default: + t.Fatalf("unexpected path: %s", request.URL.Path) + } + })) + defer testServer.Close() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(120 * time.Millisecond) + cancel() + }() + + client := NewClient(testServer.URL) + _, err := client.Watch(ctx, WatchOptions{Timeout: 10 * time.Second}) + if err == nil || !strings.Contains(err.Error(), "watch canceled") { + t.Fatalf("expected canceled error, got %v", err) + } +} + +func TestStreamAnnotationsDirectPaths(t *testing.T) { + client := NewClient("http://[::1") + errCh := make(chan error, 1) + out := make(chan Annotation, 1) + client.streamAnnotations(context.Background(), "", out, errCh) + err := <-errCh + if err == nil || !strings.Contains(err.Error(), "creating watch request") { + t.Fatalf("expected request creation error, got %v", err) + } + + notOKServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusServiceUnavailable) + })) + defer notOKServer.Close() + + client = NewClient(notOKServer.URL) + errCh = make(chan error, 1) + client.streamAnnotations(context.Background(), "", out, errCh) + err = <-errCh + if err == nil || !strings.Contains(err.Error(), "http 503") { + t.Fatalf("expected non-200 error, got %v", err) + } + + closedServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + flusher, ok := writer.(http.Flusher) + if ok { + flusher.Flush() + } + })) + defer closedServer.Close() + + client = NewClient(closedServer.URL) + errCh = make(chan error, 1) + client.streamAnnotations(context.Background(), "", out, errCh) + err = <-errCh + if err == nil || !strings.Contains(err.Error(), "closed unexpectedly") { + t.Fatalf("expected unexpected close error, got %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + errCh = make(chan error, 1) + client.streamAnnotations(ctx, "", out, errCh) + err = <-errCh + if err != nil { + t.Fatalf("expected nil error on canceled context, got %v", err) + } +} + +func TestClampDurationAndUniqueSessions(t *testing.T) { + if got := clampDuration(0, 10*time.Second, time.Second, 60*time.Second); got != 10*time.Second { + t.Fatalf("clampDuration fallback = %v", got) + } + if got := clampDuration(200*time.Millisecond, 10*time.Second, time.Second, 60*time.Second); got != time.Second { + t.Fatalf("clampDuration min = %v", got) + } + if got := clampDuration(120*time.Second, 10*time.Second, time.Second, 60*time.Second); got != 60*time.Second { + t.Fatalf("clampDuration max = %v", got) + } + + sessions := uniqueSessions([]Annotation{{SessionID: "s1"}, {SessionID: "s2"}, {SessionID: "s1"}, {SessionID: ""}}) + if len(sessions) != 2 || sessions[0] != "s1" || sessions[1] != "s2" { + t.Fatalf("uniqueSessions = %#v", sessions) + } +} diff --git a/cli/internal/api/watch_test.go b/cli/internal/api/watch_test.go new file mode 100644 index 00000000..f3c9967d --- /dev/null +++ b/cli/internal/api/watch_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestWatchReturnsPendingImmediately(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/pending" { + t.Fatalf("unexpected path: %s", request.URL.Path) + } + writer.Header().Set("Content-Type", "application/json") + fmt.Fprint(writer, `{"count":1,"annotations":[{"id":"a1","sessionId":"s1","comment":"Fix button","element":"button","elementPath":"body > button"}]}`) + })) + defer server.Close() + + client := NewClient(server.URL) + output, err := client.Watch(context.Background(), WatchOptions{}) + if err != nil { + t.Fatalf("Watch returned error: %v", err) + } + + if output.Timeout { + t.Fatal("expected non-timeout output") + } + if output.Count != 1 { + t.Fatalf("output.Count = %d, want 1", output.Count) + } + if len(output.Sessions) != 1 || output.Sessions[0] != "s1" { + t.Fatalf("output.Sessions = %#v, want [\"s1\"]", output.Sessions) + } +} + +func TestWatchCollectsSSEAnnotations(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/pending": + writer.Header().Set("Content-Type", "application/json") + fmt.Fprint(writer, `{"count":0,"annotations":[]}`) + return + case "/events": + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Cache-Control", "no-cache") + + flusher, ok := writer.(http.Flusher) + if !ok { + t.Fatal("response writer does not support flushing") + } + + fmt.Fprint(writer, ": connected\n\n") + fmt.Fprint(writer, `data: {"type":"annotation.created","sessionId":"s1","sequence":0,"payload":{"id":"ignored","sessionId":"s1","comment":"old"}}`+"\n\n") + flusher.Flush() + + time.Sleep(20 * time.Millisecond) + fmt.Fprint(writer, `data: {"type":"annotation.created","sessionId":"s1","sequence":1,"payload":{"id":"a1","sessionId":"s1","comment":"Fix spacing","element":"button","elementPath":"body > button"}}`+"\n\n") + flusher.Flush() + + time.Sleep(20 * time.Millisecond) + fmt.Fprint(writer, `data: {"type":"thread.message","sessionId":"s2","sequence":2,"payload":{"id":"a2","sessionId":"s2","comment":"Need follow-up","element":"div","elementPath":"body > div","thread":[{"id":"m1","role":"human","content":"Please also change color","timestamp":1}]}}`+"\n\n") + flusher.Flush() + + <-request.Context().Done() + return + default: + t.Fatalf("unexpected path: %s", request.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.URL) + output, err := client.Watch(context.Background(), WatchOptions{ + BatchWindow: 80 * time.Millisecond, + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("Watch returned error: %v", err) + } + + if output.Timeout { + t.Fatal("expected non-timeout output") + } + if output.Count != 2 { + t.Fatalf("output.Count = %d, want 2", output.Count) + } + if output.Annotations[0].ID != "a1" { + t.Fatalf("first annotation ID = %s, want a1", output.Annotations[0].ID) + } + if output.Annotations[1].ID != "a2" { + t.Fatalf("second annotation ID = %s, want a2", output.Annotations[1].ID) + } +} diff --git a/cli/internal/lifecycle/lifecycle.go b/cli/internal/lifecycle/lifecycle.go new file mode 100644 index 00000000..4588514b --- /dev/null +++ b/cli/internal/lifecycle/lifecycle.go @@ -0,0 +1,588 @@ +package lifecycle + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + routerconfig "github.com/benjitaylor/agentation/cli/internal/router/config" + routerhttp "github.com/benjitaylor/agentation/cli/internal/router/http" + routerpkg "github.com/benjitaylor/agentation/cli/internal/router/router" + routerstore "github.com/benjitaylor/agentation/cli/internal/router/store" + "github.com/benjitaylor/agentation/cli/internal/server" +) + +const ( + defaultServerAddress = "127.0.0.1:4747" + defaultRouterAddress = "127.0.0.1:8787" + shutdownTimeout = 5 * time.Second +) + +type serveConfig struct { + serverAddr string + routerAddr string + enableServer bool + enableRouter bool +} + +type startConfig struct { + foreground bool + serve serveConfig +} + +func RunStart(args []string, stdout, stderr io.Writer) int { + cfg, err := parseStartFlags(args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintf(stderr, "failed to parse start flags: %v\n", err) + return 1 + } + + if pid, ok := loadRunningPID(); ok { + fmt.Fprintf(stdout, "agentation already running (pid %d)\n", pid) + return 0 + } + + if cfg.foreground { + return runServe(cfg.serve, stdout, stderr) + } + + executablePath, err := os.Executable() + if err != nil { + fmt.Fprintf(stderr, "failed to resolve executable path: %v\n", err) + return 1 + } + + stackLogPath := stackLogFilePath() + if err := os.MkdirAll(filepath.Dir(stackLogPath), 0o755); err != nil { + fmt.Fprintf(stderr, "failed to create log directory: %v\n", err) + return 1 + } + + stackLogFile, err := os.OpenFile(stackLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + fmt.Fprintf(stderr, "failed to open log file: %v\n", err) + return 1 + } + defer stackLogFile.Close() + + commandArgs := []string{ + "__serve-stack", + "--server-addr", cfg.serve.serverAddr, + "--router-addr", cfg.serve.routerAddr, + } + command := exec.Command(executablePath, commandArgs...) + command.Stdout = stackLogFile + command.Stderr = stackLogFile + + if err := command.Start(); err != nil { + fmt.Fprintf(stderr, "failed to start agentation: %v\n", err) + return 1 + } + + pid := command.Process.Pid + if err := writePID(pid); err != nil { + fmt.Fprintf(stderr, "failed to write pid file: %v\n", err) + _ = command.Process.Kill() + return 1 + } + + time.Sleep(250 * time.Millisecond) + if !isProcessRunning(pid) { + _ = removePIDFile() + fmt.Fprintln(stderr, "agentation failed to stay running") + return 1 + } + + fmt.Fprintf(stdout, "agentation started in background (pid %d)\n", pid) + fmt.Fprintf(stdout, "log: %s\n", stackLogPath) + if cfg.serve.enableServer { + fmt.Fprintf(stdout, "server log: %s\n", serverLogFilePath()) + } + if cfg.serve.enableRouter { + fmt.Fprintf(stdout, "router log: %s\n", routerLogFilePath()) + } + + return 0 +} + +func RunServe(args []string, stdout, stderr io.Writer) int { + cfg, err := parseServeFlags(args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintf(stderr, "failed to parse serve flags: %v\n", err) + return 1 + } + + return runServe(cfg, stdout, stderr) +} + +func runServe(cfg serveConfig, stdout, stderr io.Writer) int { + if !cfg.enableServer && !cfg.enableRouter { + fmt.Fprintln(stderr, "nothing to serve: both server and router are disabled") + return 1 + } + + serverWriter, serverCloser, err := openServiceLogWriter(serverLogFilePath(), stdout) + if err != nil { + fmt.Fprintf(stderr, "failed to open server log file: %v\n", err) + return 1 + } + if serverCloser != nil { + defer serverCloser.Close() + } + + routerWriter, routerCloser, err := openServiceLogWriter(routerLogFilePath(), stdout) + if err != nil { + fmt.Fprintf(stderr, "failed to open router log file: %v\n", err) + return 1 + } + if routerCloser != nil { + defer routerCloser.Close() + } + + signalContext, stopSignal := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stopSignal() + + serveErrors := make(chan error, 2) + + var serverService *server.Service + if cfg.enableServer { + serverLogger := slog.New(slog.NewTextHandler(serverWriter, &slog.HandlerOptions{Level: slog.LevelInfo})) + serverService = server.NewService(cfg.serverAddr, serverLogger) + + go func() { + err := serverService.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErrors <- fmt.Errorf("server failed: %w", err) + } + }() + } else { + fmt.Fprintln(stdout, "agentation server disabled") + } + + var routerService *http.Server + if cfg.enableRouter { + routerCfg, cfgErr := routerconfig.Load([]string{"--address", cfg.routerAddr}, stderr) + if cfgErr != nil { + fmt.Fprintf(stderr, "failed to build router config: %v\n", cfgErr) + return 1 + } + + routerLogger := slog.New(slog.NewTextHandler(routerWriter, &slog.HandlerOptions{Level: slog.LevelInfo})) + registry := routerstore.NewRegistry(routerCfg.SessionStaleAfter) + forwarder := routerpkg.NewForwarder(routerCfg.ForwardTimeout) + routerService = routerhttp.NewServer(routerCfg, routerLogger, registry, forwarder) + + go func() { + err := routerService.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErrors <- fmt.Errorf("router failed: %w", err) + } + }() + } else { + fmt.Fprintln(stdout, "agentation router disabled") + } + + select { + case <-signalContext.Done(): + case err := <-serveErrors: + fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + + shutdownContext, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + shutdownErr := false + if routerService != nil { + if err := routerService.Shutdown(shutdownContext); err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintf(stderr, "agentation router shutdown failed: %v\n", err) + shutdownErr = true + } + } + + if serverService != nil { + if err := serverService.Shutdown(shutdownContext); err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintf(stderr, "agentation server shutdown failed: %v\n", err) + shutdownErr = true + } + } + + if shutdownErr { + return 1 + } + + return 0 +} + +func RunStop(args []string, stdout, stderr io.Writer) int { + if err := parseNoArgCommand("stop", args, stderr); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintf(stderr, "failed to parse stop flags: %v\n", err) + return 1 + } + + pid, err := readPID() + if err != nil || !isProcessRunning(pid) { + fallbackPID, ok := findRunningPIDByScan() + if !ok { + _ = removePIDFile() + fmt.Fprintln(stdout, "agentation is not running") + return 0 + } + pid = fallbackPID + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Fprintf(stderr, "failed to find process: %v\n", err) + return 1 + } + + if err := process.Signal(os.Interrupt); err != nil { + if killErr := process.Kill(); killErr != nil { + fmt.Fprintf(stderr, "failed to stop agentation: %v\n", killErr) + return 1 + } + } + + for range 30 { + if !isProcessRunning(pid) { + _ = removePIDFile() + fmt.Fprintf(stdout, "agentation stopped (pid %d)\n", pid) + return 0 + } + time.Sleep(100 * time.Millisecond) + } + + if err := process.Kill(); err != nil { + fmt.Fprintf(stderr, "failed to kill agentation: %v\n", err) + return 1 + } + + _ = removePIDFile() + fmt.Fprintf(stdout, "agentation stopped (pid %d)\n", pid) + return 0 +} + +func RunStatus(args []string, stdout, stderr io.Writer) int { + if err := parseNoArgCommand("status", args, stderr); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintf(stderr, "failed to parse status flags: %v\n", err) + return 1 + } + + pid, err := readPID() + if err != nil || !isProcessRunning(pid) { + fallbackPID, ok := findRunningPIDByScan() + if !ok { + _ = removePIDFile() + fmt.Fprintln(stdout, "agentation not running") + return 1 + } + pid = fallbackPID + _ = writePID(pid) + } + + fmt.Fprintf(stdout, "agentation running (pid %d)\n", pid) + return 0 +} + +func parseStartFlags(args []string, stderr io.Writer) (startConfig, error) { + flags := flag.NewFlagSet("agentation start", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.Usage = func() { + fmt.Fprintln(stderr, "Usage: agentation start [--server-addr host:port|0] [--router-addr host:port|0] [--foreground|--background]") + fmt.Fprintln(stderr) + fmt.Fprintln(stderr, "Options:") + flags.PrintDefaults() + fmt.Fprintln(stderr) + fmt.Fprintln(stderr, "Examples:") + fmt.Fprintln(stderr, " agentation start") + fmt.Fprintln(stderr, " AGENTATION_SERVER_ADDR=0 agentation start") + fmt.Fprintln(stderr, " AGENTATION_ROUTER_ADDR=0 agentation start") + fmt.Fprintln(stderr, " agentation start --server-addr 127.0.0.1:4747 --router-addr 127.0.0.1:8787") + } + + serverAddrFlag := flags.String("server-addr", "", "Server address (default: AGENTATION_SERVER_ADDR or 127.0.0.1:4747; use 0 to disable)") + routerAddrFlag := flags.String("router-addr", "", "Router address (default: AGENTATION_ROUTER_ADDR or 127.0.0.1:8787; use 0 to disable)") + foreground := flags.Bool("foreground", false, "Run in foreground") + background := flags.Bool("background", false, "Run in background (default)") + + if err := flags.Parse(args); err != nil { + return startConfig{}, err + } + if flags.NArg() != 0 { + return startConfig{}, fmt.Errorf("start does not accept positional arguments") + } + if *foreground && *background { + return startConfig{}, fmt.Errorf("--foreground and --background cannot be used together") + } + + serve, err := resolveServeConfig(strings.TrimSpace(*serverAddrFlag), strings.TrimSpace(*routerAddrFlag)) + if err != nil { + return startConfig{}, err + } + + return startConfig{ + foreground: *foreground, + serve: serve, + }, nil +} + +func parseServeFlags(args []string, stderr io.Writer) (serveConfig, error) { + flags := flag.NewFlagSet("agentation __serve-stack", flag.ContinueOnError) + flags.SetOutput(stderr) + + serverAddrFlag := flags.String("server-addr", "", "Server address") + routerAddrFlag := flags.String("router-addr", "", "Router address") + + if err := flags.Parse(args); err != nil { + return serveConfig{}, err + } + if flags.NArg() != 0 { + return serveConfig{}, fmt.Errorf("serve does not accept positional arguments") + } + + return resolveServeConfig(strings.TrimSpace(*serverAddrFlag), strings.TrimSpace(*routerAddrFlag)) +} + +func resolveServeConfig(serverAddrFlag string, routerAddrFlag string) (serveConfig, error) { + serverAddr := resolveAddress(serverAddrFlag, strings.TrimSpace(os.Getenv("AGENTATION_SERVER_ADDR")), defaultServerAddress) + routerAddr := resolveAddress(routerAddrFlag, firstNonEmptyEnv("AGENTATION_ROUTER_ADDR", "AGENTATION_ROUTER_ADDRESS"), defaultRouterAddress) + + cfg := serveConfig{ + serverAddr: serverAddr, + routerAddr: routerAddr, + enableServer: serverAddr != "0", + enableRouter: routerAddr != "0", + } + + if !cfg.enableServer && !cfg.enableRouter { + return serveConfig{}, fmt.Errorf("both server and router are disabled; set AGENTATION_SERVER_ADDR and/or AGENTATION_ROUTER_ADDR to a listen address") + } + + return cfg, nil +} + +func resolveAddress(flagValue string, envValue string, fallback string) string { + if flagValue != "" { + return flagValue + } + if envValue != "" { + return envValue + } + return fallback +} + +func firstNonEmptyEnv(keys ...string) string { + for _, key := range keys { + value := strings.TrimSpace(os.Getenv(key)) + if value != "" { + return value + } + } + return "" +} + +func parseNoArgCommand(commandName string, args []string, stderr io.Writer) error { + flags := flag.NewFlagSet("agentation "+commandName, flag.ContinueOnError) + flags.SetOutput(stderr) + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("%s does not accept positional arguments", commandName) + } + return nil +} + +func pidFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_PID_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation.pid") +} + +func stackLogFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_LOG_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation.log") +} + +func serverLogFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_SERVER_LOG_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation-server.log") +} + +func routerLogFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_ROUTER_LOG_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation-router.log") +} + +func openServiceLogWriter(path string, stdout io.Writer) (io.Writer, *os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, nil, err + } + + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return nil, nil, err + } + + writer := io.Writer(file) + if stdout != nil { + writer = io.MultiWriter(stdout, file) + } + + return writer, file, nil +} + +func writePID(pid int) error { + path := pidFilePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o644) +} + +func readPID() (int, error) { + data, err := os.ReadFile(pidFilePath()) + if err != nil { + return 0, err + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return 0, fmt.Errorf("pid file is empty") + } + + pid, err := strconv.Atoi(value) + if err != nil { + return 0, err + } + if pid <= 0 { + return 0, fmt.Errorf("invalid pid") + } + + return pid, nil +} + +func removePIDFile() error { + err := os.Remove(pidFilePath()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + err = process.Signal(syscall.Signal(0)) + if err == nil { + return true + } + + message := strings.ToLower(err.Error()) + if strings.Contains(message, "process already finished") || strings.Contains(message, "no such process") { + return false + } + + return true +} + +func loadRunningPID() (int, bool) { + pid, err := readPID() + if err == nil && isProcessRunning(pid) { + return pid, true + } + + fallbackPID, ok := findRunningPIDByScan() + if !ok { + _ = removePIDFile() + return 0, false + } + + _ = writePID(fallbackPID) + return fallbackPID, true +} + +func findRunningPIDByScan() (int, bool) { + output, err := exec.Command("pgrep", "-f", "__serve-stack").Output() + if err != nil { + return 0, false + } + + lines := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") + for line := range lines { + value := strings.TrimSpace(line) + if value == "" { + continue + } + + pid, parseErr := strconv.Atoi(value) + if parseErr != nil { + continue + } + if pid <= 0 || pid == os.Getpid() { + continue + } + if !isProcessRunning(pid) { + continue + } + + commandOutput, commandErr := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() + if commandErr != nil { + continue + } + + commandLine := strings.TrimSpace(string(commandOutput)) + if commandLine == "" { + continue + } + + if strings.Contains(commandLine, "__serve-stack") { + return pid, true + } + } + + return 0, false +} diff --git a/cli/internal/lifecycle/lifecycle_test.go b/cli/internal/lifecycle/lifecycle_test.go new file mode 100644 index 00000000..f29d92d9 --- /dev/null +++ b/cli/internal/lifecycle/lifecycle_test.go @@ -0,0 +1,115 @@ +package lifecycle + +import ( + "bytes" + "testing" +) + +func TestResolveServeConfig_DefaultStartsBoth(t *testing.T) { + t.Setenv("AGENTATION_SERVER_ADDR", "") + t.Setenv("AGENTATION_ROUTER_ADDR", "") + + cfg, err := resolveServeConfig("", "") + if err != nil { + t.Fatalf("resolveServeConfig error: %v", err) + } + + if !cfg.enableServer || !cfg.enableRouter { + t.Fatalf("expected both services enabled, got server=%v router=%v", cfg.enableServer, cfg.enableRouter) + } + if cfg.serverAddr != defaultServerAddress { + t.Fatalf("server addr = %q, want %q", cfg.serverAddr, defaultServerAddress) + } + if cfg.routerAddr != defaultRouterAddress { + t.Fatalf("router addr = %q, want %q", cfg.routerAddr, defaultRouterAddress) + } +} + +func TestResolveServeConfig_DisableServerWithZero(t *testing.T) { + t.Setenv("AGENTATION_SERVER_ADDR", "0") + t.Setenv("AGENTATION_ROUTER_ADDR", "") + + cfg, err := resolveServeConfig("", "") + if err != nil { + t.Fatalf("resolveServeConfig error: %v", err) + } + if cfg.enableServer { + t.Fatal("server should be disabled when AGENTATION_SERVER_ADDR=0") + } + if !cfg.enableRouter { + t.Fatal("router should remain enabled") + } +} + +func TestResolveServeConfig_DisableRouterWithZero(t *testing.T) { + t.Setenv("AGENTATION_SERVER_ADDR", "") + t.Setenv("AGENTATION_ROUTER_ADDR", "0") + + cfg, err := resolveServeConfig("", "") + if err != nil { + t.Fatalf("resolveServeConfig error: %v", err) + } + if !cfg.enableServer { + t.Fatal("server should remain enabled") + } + if cfg.enableRouter { + t.Fatal("router should be disabled when AGENTATION_ROUTER_ADDR=0") + } +} + +func TestResolveServeConfig_BothDisabledErrors(t *testing.T) { + t.Setenv("AGENTATION_SERVER_ADDR", "0") + t.Setenv("AGENTATION_ROUTER_ADDR", "0") + + if _, err := resolveServeConfig("", ""); err == nil { + t.Fatal("expected error when both services are disabled") + } +} + +func TestResolveServeConfig_FlagOverridesEnv(t *testing.T) { + t.Setenv("AGENTATION_SERVER_ADDR", "0") + t.Setenv("AGENTATION_ROUTER_ADDR", "0") + + cfg, err := resolveServeConfig("127.0.0.1:4748", "127.0.0.1:8788") + if err != nil { + t.Fatalf("resolveServeConfig error: %v", err) + } + + if !cfg.enableServer || !cfg.enableRouter { + t.Fatalf("expected both services enabled, got server=%v router=%v", cfg.enableServer, cfg.enableRouter) + } + if cfg.serverAddr != "127.0.0.1:4748" { + t.Fatalf("server addr = %q, want flag override", cfg.serverAddr) + } + if cfg.routerAddr != "127.0.0.1:8788" { + t.Fatalf("router addr = %q, want flag override", cfg.routerAddr) + } +} + +func TestResolveServeConfig_RouterLegacyEnvFallback(t *testing.T) { + t.Setenv("AGENTATION_SERVER_ADDR", "") + t.Setenv("AGENTATION_ROUTER_ADDR", "") + t.Setenv("AGENTATION_ROUTER_ADDRESS", "127.0.0.1:8999") + + cfg, err := resolveServeConfig("", "") + if err != nil { + t.Fatalf("resolveServeConfig error: %v", err) + } + if cfg.routerAddr != "127.0.0.1:8999" { + t.Fatalf("router addr = %q, want legacy env fallback", cfg.routerAddr) + } +} + +func TestParseStartFlags_ConflictingModeFlags(t *testing.T) { + _, err := parseStartFlags([]string{"--foreground", "--background"}, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected error for conflicting mode flags") + } +} + +func TestParseNoArgCommandRejectsPositional(t *testing.T) { + err := parseNoArgCommand("status", []string{"extra"}, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected positional argument error") + } +} diff --git a/router/internal/config/config.go b/cli/internal/router/config/config.go similarity index 93% rename from router/internal/config/config.go rename to cli/internal/router/config/config.go index 66f7f060..bdfd38a8 100644 --- a/router/internal/config/config.go +++ b/cli/internal/router/config/config.go @@ -2,6 +2,8 @@ package config import ( "flag" + "fmt" + "io" "os" "strconv" "strings" @@ -22,7 +24,7 @@ type Config struct { EnforceRootBounds bool } -func Load(args []string) Config { +func Load(args []string, stderr io.Writer) (Config, error) { defaults := Config{ Address: envOrDefault("AGENTATION_ROUTER_ADDRESS", "127.0.0.1:8787"), AuthToken: strings.TrimSpace(os.Getenv("AGENTATION_ROUTER_TOKEN")), @@ -38,6 +40,7 @@ func Load(args []string) Config { } flags := flag.NewFlagSet("agentation-router", flag.ContinueOnError) + flags.SetOutput(stderr) address := flags.String("address", defaults.Address, "listen address (host:port)") authToken := flags.String("token", defaults.AuthToken, "shared auth token for mutating endpoints") requestBodyLimit := flags.Int64("body-limit", defaults.RequestBodyLimit, "max request body size in bytes") @@ -50,7 +53,12 @@ func Load(args []string) Config { allowAbsolutePaths := flags.Bool("allow-absolute-paths", defaults.AllowAbsolutePaths, "allow absolute file paths on /open") enforceRootBounds := flags.Bool("enforce-root-bounds", defaults.EnforceRootBounds, "require absolute /open paths to stay under session root") - _ = flags.Parse(args) + if err := flags.Parse(args); err != nil { + return Config{}, err + } + if flags.NArg() != 0 { + return Config{}, fmt.Errorf("unexpected positional arguments: %v", flags.Args()) + } return Config{ Address: strings.TrimSpace(*address), @@ -64,7 +72,7 @@ func Load(args []string) Config { SessionStaleAfter: *sessionStaleAfter, AllowAbsolutePaths: *allowAbsolutePaths, EnforceRootBounds: *enforceRootBounds, - } + }, nil } func envOrDefault(key string, fallback string) string { diff --git a/router/internal/http/server.go b/cli/internal/router/http/server.go similarity index 97% rename from router/internal/http/server.go rename to cli/internal/router/http/server.go index 326a7868..8a29df20 100644 --- a/router/internal/http/server.go +++ b/cli/internal/router/http/server.go @@ -10,10 +10,10 @@ import ( "path/filepath" "strings" - "github.com/benjitaylor/agentation/router/internal/config" - "github.com/benjitaylor/agentation/router/internal/model" - routerpkg "github.com/benjitaylor/agentation/router/internal/router" - "github.com/benjitaylor/agentation/router/internal/store" + "github.com/benjitaylor/agentation/cli/internal/router/config" + "github.com/benjitaylor/agentation/cli/internal/router/model" + routerpkg "github.com/benjitaylor/agentation/cli/internal/router/router" + "github.com/benjitaylor/agentation/cli/internal/router/store" ) type Server struct { diff --git a/router/internal/http/server_test.go b/cli/internal/router/http/server_test.go similarity index 96% rename from router/internal/http/server_test.go rename to cli/internal/router/http/server_test.go index fdf8db96..6b4ad35b 100644 --- a/router/internal/http/server_test.go +++ b/cli/internal/router/http/server_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/benjitaylor/agentation/router/internal/config" - "github.com/benjitaylor/agentation/router/internal/model" - routerpkg "github.com/benjitaylor/agentation/router/internal/router" - "github.com/benjitaylor/agentation/router/internal/store" + "github.com/benjitaylor/agentation/cli/internal/router/config" + "github.com/benjitaylor/agentation/cli/internal/router/model" + routerpkg "github.com/benjitaylor/agentation/cli/internal/router/router" + "github.com/benjitaylor/agentation/cli/internal/router/store" ) func TestRegisterAndListSessions(t *testing.T) { diff --git a/router/internal/model/types.go b/cli/internal/router/model/types.go similarity index 100% rename from router/internal/model/types.go rename to cli/internal/router/model/types.go diff --git a/router/internal/router/forwarder.go b/cli/internal/router/router/forwarder.go similarity index 94% rename from router/internal/router/forwarder.go rename to cli/internal/router/router/forwarder.go index 98116f28..ac94b834 100644 --- a/router/internal/router/forwarder.go +++ b/cli/internal/router/router/forwarder.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/benjitaylor/agentation/router/internal/model" + "github.com/benjitaylor/agentation/cli/internal/router/model" ) type Forwarder struct { @@ -100,10 +100,3 @@ func buildTargetURL(endpoint string, routePath string, query url.Values) (string return resolvedURL.String(), nil } - -func max(value int, minimum int) int { - if value < minimum { - return minimum - } - return value -} diff --git a/router/internal/router/forwarder_test.go b/cli/internal/router/router/forwarder_test.go similarity index 97% rename from router/internal/router/forwarder_test.go rename to cli/internal/router/router/forwarder_test.go index a05ee05f..abab6cd7 100644 --- a/router/internal/router/forwarder_test.go +++ b/cli/internal/router/router/forwarder_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/benjitaylor/agentation/router/internal/model" + "github.com/benjitaylor/agentation/cli/internal/router/model" ) func TestForwardPing(t *testing.T) { diff --git a/router/internal/store/store.go b/cli/internal/router/store/store.go similarity index 99% rename from router/internal/store/store.go rename to cli/internal/router/store/store.go index 2508ac8f..4ca0579f 100644 --- a/router/internal/store/store.go +++ b/cli/internal/router/store/store.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/benjitaylor/agentation/router/internal/model" + "github.com/benjitaylor/agentation/cli/internal/router/model" ) var ( diff --git a/router/internal/store/store_test.go b/cli/internal/router/store/store_test.go similarity index 98% rename from router/internal/store/store_test.go rename to cli/internal/router/store/store_test.go index ae8aefb3..e86f07e0 100644 --- a/router/internal/store/store_test.go +++ b/cli/internal/router/store/store_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/benjitaylor/agentation/router/internal/model" + "github.com/benjitaylor/agentation/cli/internal/router/model" ) func TestResolveByProjectID(t *testing.T) { diff --git a/cli/internal/routerctl/routerctl.go b/cli/internal/routerctl/routerctl.go new file mode 100644 index 00000000..1f0b2a4e --- /dev/null +++ b/cli/internal/routerctl/routerctl.go @@ -0,0 +1,373 @@ +package routerctl + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/benjitaylor/agentation/cli/internal/router/config" + httpserver "github.com/benjitaylor/agentation/cli/internal/router/http" + routerpkg "github.com/benjitaylor/agentation/cli/internal/router/router" + "github.com/benjitaylor/agentation/cli/internal/router/store" +) + +const shutdownTimeout = 5 * time.Second + +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stdout) + return 0 + } + + subcommand := args[0] + subcommandArgs := args[1:] + + switch subcommand { + case "serve": + return runServe(subcommandArgs, stdout, stderr) + case "start": + return runStart(subcommandArgs, stdout, stderr) + case "stop": + return runStop(stdout, stderr) + case "status": + return runStatus(stdout) + case "help", "--help", "-h": + printUsage(stdout) + return 0 + default: + return runServe(args, stdout, stderr) + } +} + +func runServe(args []string, stdout, stderr io.Writer) int { + cfg, err := config.Load(args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintf(stderr, "failed to parse router serve flags: %v\n", err) + return 1 + } + + logger := slog.New(slog.NewTextHandler(stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + registry := store.NewRegistry(cfg.SessionStaleAfter) + forwarder := routerpkg.NewForwarder(cfg.ForwardTimeout) + server := httpserver.NewServer(cfg, logger, registry, forwarder) + + go func() { + logger.Info("agentation router listening", "address", cfg.Address) + err := server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("router server failed", "error", err) + os.Exit(1) + } + }() + + signalContext, stopSignal := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stopSignal() + + <-signalContext.Done() + shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancelShutdown() + + if err := server.Shutdown(shutdownContext); err != nil { + logger.Error("router shutdown failed", "error", err) + return 1 + } + + logger.Info("agentation router stopped") + return 0 +} + +func runStart(args []string, stdout, stderr io.Writer) int { + foreground, serveArgs := parseStartArgs(args) + + if pid, ok := loadRunningPID(); ok { + fmt.Fprintf(stdout, "agentation router already running (pid %d)\n", pid) + return 0 + } + + if foreground { + fmt.Fprintln(stdout, "starting agentation router in foreground") + return runServe(serveArgs, stdout, stderr) + } + + executablePath, err := os.Executable() + if err != nil { + fmt.Fprintf(stderr, "failed to resolve executable path: %v\n", err) + return 1 + } + + logPath := logFilePath() + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + fmt.Fprintf(stderr, "failed to create log directory: %v\n", err) + return 1 + } + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + fmt.Fprintf(stderr, "failed to open log file: %v\n", err) + return 1 + } + defer logFile.Close() + + commandArgs := append([]string{"__serve-router"}, serveArgs...) + command := exec.Command(executablePath, commandArgs...) + command.Stdout = logFile + command.Stderr = logFile + + if err := command.Start(); err != nil { + fmt.Fprintf(stderr, "failed to start agentation router: %v\n", err) + return 1 + } + + pid := command.Process.Pid + if err := writePID(pid); err != nil { + fmt.Fprintf(stderr, "failed to write pid file: %v\n", err) + _ = command.Process.Kill() + return 1 + } + + time.Sleep(250 * time.Millisecond) + if !isProcessRunning(pid) { + _ = removePIDFile() + fmt.Fprintln(stderr, "agentation router failed to stay running") + return 1 + } + + fmt.Fprintf(stdout, "agentation router started in background (pid %d)\n", pid) + fmt.Fprintf(stdout, "log: %s\n", logPath) + return 0 +} + +func runStop(stdout, stderr io.Writer) int { + pid, err := readPID() + if err != nil || !isProcessRunning(pid) { + fallbackPID, ok := findRunningRouterPIDByScan() + if !ok { + _ = removePIDFile() + fmt.Fprintln(stdout, "agentation router is not running") + return 0 + } + pid = fallbackPID + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Fprintf(stderr, "failed to find agentation router process: %v\n", err) + return 1 + } + + if err := process.Signal(os.Interrupt); err != nil { + if killErr := process.Kill(); killErr != nil { + fmt.Fprintf(stderr, "failed to stop agentation router: %v\n", killErr) + return 1 + } + } + + for range 30 { + if !isProcessRunning(pid) { + _ = removePIDFile() + fmt.Fprintf(stdout, "agentation router stopped (pid %d)\n", pid) + return 0 + } + time.Sleep(100 * time.Millisecond) + } + + if err := process.Kill(); err != nil { + fmt.Fprintf(stderr, "failed to kill agentation router: %v\n", err) + return 1 + } + + _ = removePIDFile() + fmt.Fprintf(stdout, "agentation router stopped (pid %d)\n", pid) + return 0 +} + +func runStatus(stdout io.Writer) int { + pid, err := readPID() + if err != nil || !isProcessRunning(pid) { + fallbackPID, ok := findRunningRouterPIDByScan() + if !ok { + _ = removePIDFile() + fmt.Fprintln(stdout, "agentation router not running") + return 1 + } + pid = fallbackPID + _ = writePID(pid) + } + + fmt.Fprintf(stdout, "agentation router running (pid %d)\n", pid) + return 0 +} + +func parseStartArgs(args []string) (bool, []string) { + foreground := false + serveArgs := make([]string, 0, len(args)) + for _, arg := range args { + switch arg { + case "--foreground", "foreground": + foreground = true + case "--background", "background": + foreground = false + default: + serveArgs = append(serveArgs, arg) + } + } + return foreground, serveArgs +} + +func pidFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_ROUTER_PID_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation-router.pid") +} + +func logFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_ROUTER_LOG_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation-router.log") +} + +func writePID(pid int) error { + path := pidFilePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o644) +} + +func readPID() (int, error) { + contents, err := os.ReadFile(pidFilePath()) + if err != nil { + return 0, err + } + + raw := strings.TrimSpace(string(contents)) + if raw == "" { + return 0, fmt.Errorf("pid file is empty") + } + + pid, err := strconv.Atoi(raw) + if err != nil { + return 0, err + } + if pid <= 0 { + return 0, fmt.Errorf("pid is invalid") + } + + return pid, nil +} + +func removePIDFile() error { + err := os.Remove(pidFilePath()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + err = process.Signal(syscall.Signal(0)) + if err == nil { + return true + } + + message := strings.ToLower(err.Error()) + if strings.Contains(message, "process already finished") || strings.Contains(message, "no such process") { + return false + } + + return true +} + +func loadRunningPID() (int, bool) { + pid, err := readPID() + if err == nil && isProcessRunning(pid) { + return pid, true + } + + fallbackPID, ok := findRunningRouterPIDByScan() + if !ok { + _ = removePIDFile() + return 0, false + } + + _ = writePID(fallbackPID) + return fallbackPID, true +} + +func findRunningRouterPIDByScan() (int, bool) { + output, err := exec.Command("pgrep", "-f", "__serve-router").Output() + if err != nil { + return 0, false + } + + lines := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") + for line := range lines { + value := strings.TrimSpace(line) + if value == "" { + continue + } + + pid, parseErr := strconv.Atoi(value) + if parseErr != nil { + continue + } + if pid <= 0 || pid == os.Getpid() { + continue + } + if !isProcessRunning(pid) { + continue + } + + commandOutput, commandErr := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() + if commandErr != nil { + continue + } + + commandLine := strings.TrimSpace(string(commandOutput)) + if commandLine == "" { + continue + } + + if strings.Contains(commandLine, "__serve-router") { + return pid, true + } + } + + return 0, false +} + +func printUsage(writer io.Writer) { + fmt.Fprintln(writer, "agentation router internal commands:") + fmt.Fprintln(writer, " start [--foreground|--background] [serve flags]") + fmt.Fprintln(writer, " stop") + fmt.Fprintln(writer, " status") + fmt.Fprintln(writer, " serve [flags] (run foreground server)") +} diff --git a/cli/internal/server/model.go b/cli/internal/server/model.go new file mode 100644 index 00000000..27776437 --- /dev/null +++ b/cli/internal/server/model.go @@ -0,0 +1,135 @@ +package server + +import "time" + +type AnnotationIntent string + +type AnnotationSeverity string + +type AnnotationStatus string + +const ( + StatusPending AnnotationStatus = "pending" + StatusAcknowledged AnnotationStatus = "acknowledged" + StatusResolved AnnotationStatus = "resolved" + StatusDismissed AnnotationStatus = "dismissed" +) + +type Session struct { + ID string `json:"id"` + URL string `json:"url"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` + ProjectID string `json:"projectId,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ThreadMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Timestamp int64 `json:"timestamp"` +} + +type Annotation struct { + ID string `json:"id"` + SessionID string `json:"sessionId,omitempty"` + X float64 `json:"x,omitempty"` + Y float64 `json:"y,omitempty"` + Comment string `json:"comment"` + Element string `json:"element"` + ElementPath string `json:"elementPath"` + Timestamp int64 `json:"timestamp,omitempty"` + SelectedText string `json:"selectedText,omitempty"` + BoundingBox map[string]any `json:"boundingBox,omitempty"` + NearbyText string `json:"nearbyText,omitempty"` + CSSClasses string `json:"cssClasses,omitempty"` + NearbyElements string `json:"nearbyElements,omitempty"` + ComputedStyles string `json:"computedStyles,omitempty"` + FullPath string `json:"fullPath,omitempty"` + Accessibility string `json:"accessibility,omitempty"` + IsMultiSelect bool `json:"isMultiSelect,omitempty"` + IsFixed bool `json:"isFixed,omitempty"` + ReactComponents string `json:"reactComponents,omitempty"` + SourceFile string `json:"sourceFile,omitempty"` + ElementBoundingBoxes []map[string]any `json:"elementBoundingBoxes,omitempty"` + URL string `json:"url,omitempty"` + Intent AnnotationIntent `json:"intent,omitempty"` + Severity AnnotationSeverity `json:"severity,omitempty"` + Status AnnotationStatus `json:"status,omitempty"` + Thread []ThreadMessage `json:"thread,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + ResolvedAt string `json:"resolvedAt,omitempty"` + ResolvedBy string `json:"resolvedBy,omitempty"` + AuthorID string `json:"authorId,omitempty"` +} + +type SessionWithAnnotations struct { + Session + Annotations []Annotation `json:"annotations"` +} + +type ActionRequest struct { + SessionID string `json:"sessionId"` + Annotations []Annotation `json:"annotations"` + Output string `json:"output"` + RequestedAt string `json:"timestamp"` +} + +type EventType string + +const ( + EventAnnotationCreated EventType = "annotation.created" + EventAnnotationUpdated EventType = "annotation.updated" + EventAnnotationDeleted EventType = "annotation.deleted" + EventSessionCreated EventType = "session.created" + EventSessionUpdated EventType = "session.updated" + EventSessionClosed EventType = "session.closed" + EventThreadMessage EventType = "thread.message" + EventActionRequested EventType = "action.requested" +) + +type Event struct { + Type EventType `json:"type"` + Timestamp string `json:"timestamp"` + SessionID string `json:"sessionId"` + Sequence int64 `json:"sequence"` + Payload any `json:"payload"` +} + +type sessionCreateInput struct { + URL string `json:"url"` + ProjectID string `json:"projectId,omitempty"` +} + +type actionRequestInput struct { + Output string `json:"output"` +} + +type threadInput struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type pendingResponse struct { + Count int `json:"count"` + Annotations []Annotation `json:"annotations"` +} + +type deliveredInfo struct { + SSEListeners int `json:"sseListeners"` + Webhooks int `json:"webhooks"` + Total int `json:"total"` +} + +type actionResponse struct { + Success bool `json:"success"` + AnnotationCount int `json:"annotationCount"` + Delivered deliveredInfo `json:"delivered"` +} + +func nowISO() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} diff --git a/cli/internal/server/service.go b/cli/internal/server/service.go new file mode 100644 index 00000000..78bfbb67 --- /dev/null +++ b/cli/internal/server/service.go @@ -0,0 +1,565 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const requestBodyLimit = 2 << 20 + +type Service struct { + store *Store + log *slog.Logger + + httpServer *http.Server + + activeListeners int64 + agentListeners int64 + + shutdownCtx context.Context + shutdownCancel context.CancelFunc + shutdownOnce sync.Once +} + +func NewService(address string, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) + + service := &Service{ + store: NewStore(), + log: logger, + shutdownCtx: shutdownCtx, + shutdownCancel: shutdownCancel, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", service.handleHealth) + mux.HandleFunc("GET /status", service.handleStatus) + mux.HandleFunc("GET /sessions", service.handleListSessions) + mux.HandleFunc("POST /sessions", service.handleCreateSession) + mux.HandleFunc("GET /sessions/{id}", service.handleGetSession) + mux.HandleFunc("POST /sessions/{id}/annotations", service.handleAddAnnotation) + mux.HandleFunc("GET /sessions/{id}/pending", service.handleSessionPending) + mux.HandleFunc("GET /sessions/{id}/events", service.handleSessionEvents) + mux.HandleFunc("POST /sessions/{id}/action", service.handleRequestAction) + mux.HandleFunc("GET /annotations/{id}", service.handleGetAnnotation) + mux.HandleFunc("PATCH /annotations/{id}", service.handleUpdateAnnotation) + mux.HandleFunc("DELETE /annotations/{id}", service.handleDeleteAnnotation) + mux.HandleFunc("POST /annotations/{id}/thread", service.handleAddThreadMessage) + mux.HandleFunc("GET /pending", service.handleAllPending) + mux.HandleFunc("GET /events", service.handleGlobalEvents) + + service.httpServer = &http.Server{ + Addr: address, + Handler: service.withCORS(mux), + } + + return service +} + +func (s *Service) ListenAndServe() error { + s.log.Info("agentation server listening", "address", s.httpServer.Addr) + return s.httpServer.ListenAndServe() +} + +func (s *Service) Shutdown(ctx context.Context) error { + s.shutdownOnce.Do(func() { + s.shutdownCancel() + }) + + err := s.httpServer.Shutdown(ctx) + if err == nil { + return nil + } + + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + s.log.Warn("graceful shutdown timed out, forcing server close", "error", err) + closeErr := s.httpServer.Close() + if closeErr != nil && !errors.Is(closeErr, http.ErrServerClosed) { + return closeErr + } + return nil + } + + return err +} + +func (s *Service) withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Access-Control-Allow-Origin", "*") + writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Last-Event-ID") + writer.Header().Set("Access-Control-Expose-Headers", "Last-Event-ID") + + if request.Method == http.MethodOptions { + writer.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(writer, request) + }) +} + +func (s *Service) handleHealth(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, http.StatusOK, map[string]any{"status": "ok", "mode": "local"}) +} + +func (s *Service) handleStatus(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, http.StatusOK, map[string]any{ + "mode": "local", + "webhooksConfigured": false, + "webhookCount": 0, + "activeListeners": atomic.LoadInt64(&s.activeListeners), + "agentListeners": atomic.LoadInt64(&s.agentListeners), + }) +} + +func (s *Service) handleListSessions(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, http.StatusOK, s.store.ListSessions()) +} + +func (s *Service) handleCreateSession(writer http.ResponseWriter, request *http.Request) { + var input sessionCreateInput + if err := decodeBody(request, &input); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + + if strings.TrimSpace(input.URL) == "" { + writeError(writer, http.StatusBadRequest, "url is required") + return + } + + session := s.store.CreateSession(strings.TrimSpace(input.URL), strings.TrimSpace(input.ProjectID)) + s.log.Info("frontend session connected", "sessionId", session.ID, "url", session.URL) + writeJSON(writer, http.StatusCreated, session) +} + +func (s *Service) handleGetSession(writer http.ResponseWriter, request *http.Request) { + sessionID := request.PathValue("id") + session, ok := s.store.GetSessionWithAnnotations(sessionID) + if !ok { + writeError(writer, http.StatusNotFound, "Session not found") + return + } + + s.log.Info("frontend session loaded", "sessionId", session.ID, "annotations", len(session.Annotations)) + writeJSON(writer, http.StatusOK, session) +} + +func (s *Service) handleAddAnnotation(writer http.ResponseWriter, request *http.Request) { + sessionID := request.PathValue("id") + var annotation Annotation + if err := decodeBody(request, &annotation); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + + if strings.TrimSpace(annotation.Comment) == "" || strings.TrimSpace(annotation.Element) == "" || strings.TrimSpace(annotation.ElementPath) == "" { + writeError(writer, http.StatusBadRequest, "comment, element, and elementPath are required") + return + } + + created, ok := s.store.AddAnnotation(sessionID, annotation) + if !ok { + writeError(writer, http.StatusNotFound, "Session not found") + return + } + + s.log.Info("frontend annotation received", "sessionId", created.SessionID, "annotationId", created.ID, "element", created.Element) + writeJSON(writer, http.StatusCreated, created) +} + +func (s *Service) handleGetAnnotation(writer http.ResponseWriter, request *http.Request) { + annotationID := request.PathValue("id") + annotation, ok := s.store.GetAnnotation(annotationID) + if !ok { + writeError(writer, http.StatusNotFound, "Annotation not found") + return + } + writeJSON(writer, http.StatusOK, annotation) +} + +func (s *Service) handleUpdateAnnotation(writer http.ResponseWriter, request *http.Request) { + annotationID := request.PathValue("id") + patch := make(map[string]any) + if err := decodeBody(request, &patch); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + + annotation, ok := s.store.UpdateAnnotation(annotationID, patch) + if !ok { + writeError(writer, http.StatusNotFound, "Annotation not found") + return + } + + writeJSON(writer, http.StatusOK, annotation) +} + +func (s *Service) handleDeleteAnnotation(writer http.ResponseWriter, request *http.Request) { + annotationID := request.PathValue("id") + _, ok := s.store.DeleteAnnotation(annotationID) + if !ok { + writeError(writer, http.StatusNotFound, "Annotation not found") + return + } + + writeJSON(writer, http.StatusOK, map[string]any{"deleted": true, "annotationId": annotationID}) +} + +func (s *Service) handleAddThreadMessage(writer http.ResponseWriter, request *http.Request) { + annotationID := request.PathValue("id") + var input threadInput + if err := decodeBody(request, &input); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + + if input.Role != "human" && input.Role != "agent" { + writeError(writer, http.StatusBadRequest, "role must be 'human' or 'agent'") + return + } + if strings.TrimSpace(input.Content) == "" { + writeError(writer, http.StatusBadRequest, "content is required") + return + } + + annotation, ok := s.store.AddThreadMessage(annotationID, input.Role, strings.TrimSpace(input.Content)) + if !ok { + writeError(writer, http.StatusNotFound, "Annotation not found") + return + } + + writeJSON(writer, http.StatusCreated, annotation) +} + +func (s *Service) handleSessionPending(writer http.ResponseWriter, request *http.Request) { + sessionID := request.PathValue("id") + _, ok := s.store.GetSession(sessionID) + if !ok { + writeError(writer, http.StatusNotFound, "Session not found") + return + } + + pending := s.store.GetAnnotationsNeedingAttention(sessionID) + writeJSON(writer, http.StatusOK, pendingResponse{Count: len(pending), Annotations: pending}) +} + +func (s *Service) handleAllPending(writer http.ResponseWriter, request *http.Request) { + pending := s.store.GetAllAnnotationsNeedingAttention() + writeJSON(writer, http.StatusOK, pendingResponse{Count: len(pending), Annotations: pending}) +} + +func (s *Service) handleRequestAction(writer http.ResponseWriter, request *http.Request) { + sessionID := request.PathValue("id") + session, ok := s.store.GetSessionWithAnnotations(sessionID) + if !ok { + writeError(writer, http.StatusNotFound, "Session not found") + return + } + + var input actionRequestInput + if err := decodeBody(request, &input); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + if strings.TrimSpace(input.Output) == "" { + writeError(writer, http.StatusBadRequest, "output is required") + return + } + + action := ActionRequest{ + SessionID: sessionID, + Annotations: session.Annotations, + Output: input.Output, + RequestedAt: nowISO(), + } + s.store.EmitActionRequested(sessionID, action) + + agent := int(atomic.LoadInt64(&s.agentListeners)) + response := actionResponse{ + Success: true, + AnnotationCount: len(session.Annotations), + Delivered: deliveredInfo{ + SSEListeners: agent, + Webhooks: 0, + Total: agent, + }, + } + writeJSON(writer, http.StatusOK, response) +} + +func (s *Service) handleSessionEvents(writer http.ResponseWriter, request *http.Request) { + sessionID := request.PathValue("id") + _, ok := s.store.GetSession(sessionID) + if !ok { + writeError(writer, http.StatusNotFound, "Session not found") + return + } + + isAgent := request.URL.Query().Get("agent") == "true" + if !startSSE(writer) { + writeError(writer, http.StatusInternalServerError, "streaming not supported") + return + } + + clientType := "frontend" + atomic.AddInt64(&s.activeListeners, 1) + defer atomic.AddInt64(&s.activeListeners, -1) + if isAgent { + clientType = "agent" + atomic.AddInt64(&s.agentListeners, 1) + defer atomic.AddInt64(&s.agentListeners, -1) + } + + s.log.Info("sse connected", "sessionId", sessionID, "client", clientType) + defer s.log.Info("sse disconnected", "sessionId", sessionID, "client", clientType) + + lastID := parseLastEventID(request.Header.Get("Last-Event-ID")) + if lastID > 0 { + for _, event := range s.store.GetEventsSince(sessionID, lastID) { + if err := writeSSEEvent(writer, event); err != nil { + return + } + } + } + + events, unsubscribe := s.store.SubscribeSession(sessionID) + defer unsubscribe() + s.streamEvents(request.Context(), writer, events) +} + +func (s *Service) handleGlobalEvents(writer http.ResponseWriter, request *http.Request) { + isAgent := request.URL.Query().Get("agent") == "true" + domain := strings.TrimSpace(request.URL.Query().Get("domain")) + + if !startSSE(writer) { + writeError(writer, http.StatusInternalServerError, "streaming not supported") + return + } + + atomic.AddInt64(&s.activeListeners, 1) + defer atomic.AddInt64(&s.activeListeners, -1) + if isAgent { + atomic.AddInt64(&s.agentListeners, 1) + defer atomic.AddInt64(&s.agentListeners, -1) + s.sendInitialSync(writer, domain) + } + + events, unsubscribe := s.store.SubscribeAll() + defer unsubscribe() + s.streamGlobalEvents(request.Context(), writer, events, domain) +} + +func (s *Service) streamEvents(ctx context.Context, writer http.ResponseWriter, events <-chan Event) { + keepAlive := time.NewTicker(30 * time.Second) + defer keepAlive.Stop() + + for { + select { + case event := <-events: + if err := writeSSEEvent(writer, event); err != nil { + return + } + case <-keepAlive.C: + if err := writeSSEComment(writer, "ping"); err != nil { + return + } + case <-ctx.Done(): + return + case <-s.shutdownCtx.Done(): + return + } + } +} + +func (s *Service) streamGlobalEvents(ctx context.Context, writer http.ResponseWriter, events <-chan Event, domain string) { + keepAlive := time.NewTicker(30 * time.Second) + defer keepAlive.Stop() + + for { + select { + case event := <-events: + if domain != "" && !s.eventMatchesDomain(event, domain) { + continue + } + if err := writeSSEEvent(writer, event); err != nil { + return + } + case <-keepAlive.C: + if err := writeSSEComment(writer, "ping"); err != nil { + return + } + case <-ctx.Done(): + return + case <-s.shutdownCtx.Done(): + return + } + } +} + +func (s *Service) sendInitialSync(writer http.ResponseWriter, domain string) { + count := 0 + for _, session := range s.store.ListSessions() { + if domain != "" && !sessionMatchesDomain(session, domain) { + continue + } + + annotations := s.store.GetAnnotationsNeedingAttention(session.ID) + for _, annotation := range annotations { + event := Event{ + Type: EventAnnotationCreated, + Timestamp: annotation.CreatedAt, + SessionID: session.ID, + Sequence: 0, + Payload: annotation, + } + if err := writeSSEEvent(writer, event); err != nil { + return + } + count++ + } + } + + syncPayload := map[string]any{ + "domain": valueOr(domain, "all"), + "count": count, + "timestamp": nowISO(), + } + _ = writeSSECustomEvent(writer, "sync.complete", syncPayload) +} + +func (s *Service) eventMatchesDomain(event Event, domain string) bool { + session, ok := s.store.GetSession(event.SessionID) + if !ok { + return false + } + return sessionMatchesDomain(session, domain) +} + +func sessionMatchesDomain(session Session, domain string) bool { + parsed, err := url.Parse(session.URL) + if err != nil { + return false + } + return strings.EqualFold(parsed.Host, domain) +} + +func valueOr(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func startSSE(writer http.ResponseWriter) bool { + flusher, ok := writer.(http.Flusher) + if !ok { + return false + } + + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Cache-Control", "no-cache") + writer.Header().Set("Connection", "keep-alive") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(": connected\n\n")) + flusher.Flush() + return true +} + +func parseLastEventID(value string) int64 { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + id, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0 + } + if id < 0 { + return 0 + } + return id +} + +func decodeBody(request *http.Request, target any) error { + reader := io.LimitReader(request.Body, requestBodyLimit) + decoder := json.NewDecoder(reader) + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("invalid JSON") + } + return nil +} + +func writeJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(payload) +} + +func writeError(writer http.ResponseWriter, status int, message string) { + writeJSON(writer, status, map[string]string{"error": message}) +} + +func writeSSEEvent(writer http.ResponseWriter, event Event) error { + payload, err := json.Marshal(event) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(writer, "event: %s\n", event.Type); err != nil { + return err + } + if _, err := fmt.Fprintf(writer, "id: %d\n", event.Sequence); err != nil { + return err + } + if _, err := fmt.Fprintf(writer, "data: %s\n\n", payload); err != nil { + return err + } + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } + return nil +} + +func writeSSECustomEvent(writer http.ResponseWriter, name string, payload any) error { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + if _, err := fmt.Fprintf(writer, "event: %s\n", name); err != nil { + return err + } + if _, err := fmt.Fprintf(writer, "data: %s\n\n", encoded); err != nil { + return err + } + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } + return nil +} + +func writeSSEComment(writer http.ResponseWriter, comment string) error { + if _, err := fmt.Fprintf(writer, ": %s\n\n", comment); err != nil { + return err + } + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } + return nil +} diff --git a/cli/internal/server/service_more_test.go b/cli/internal/server/service_more_test.go new file mode 100644 index 00000000..d95ef0eb --- /dev/null +++ b/cli/internal/server/service_more_test.go @@ -0,0 +1,456 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestHTTPAPIBranches(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + service := NewService("127.0.0.1:0", slog.New(slog.NewTextHandler(io.Discard, nil))) + ts := httptest.NewServer(service.httpServer.Handler) + defer ts.Close() + + call := func(method, path string, body any) (*http.Response, string) { + t.Helper() + var reader io.Reader + if body != nil { + payload, _ := json.Marshal(body) + reader = bytes.NewReader(payload) + } + req, err := http.NewRequest(method, ts.URL+path, reader) + if err != nil { + t.Fatalf("NewRequest failed: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + content, _ := io.ReadAll(resp.Body) + return resp, string(content) + } + + resp, _ := call(http.MethodGet, "/health", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("/health status = %d", resp.StatusCode) + } + + resp, _ = call(http.MethodGet, "/status", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("/status status = %d", resp.StatusCode) + } + + resp, _ = call(http.MethodOptions, "/sessions", nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("OPTIONS /sessions status = %d", resp.StatusCode) + } + + resp, body := call(http.MethodPost, "/sessions", map[string]any{}) + if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "url is required") { + t.Fatalf("expected create session validation error, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodGet, "/sessions/missing", nil) + if resp.StatusCode != http.StatusNotFound || !strings.Contains(body, "Session not found") { + t.Fatalf("expected missing session, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/sessions", map[string]any{"url": "http://example.com"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create session status = %d body=%s", resp.StatusCode, body) + } + var session Session + if err := json.Unmarshal([]byte(body), &session); err != nil { + t.Fatalf("unmarshal session failed: %v", err) + } + + resp, body = call(http.MethodGet, "/sessions", nil) + if resp.StatusCode != http.StatusOK || !strings.Contains(body, session.ID) { + t.Fatalf("expected session list to include session, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/sessions/missing/annotations", map[string]any{"comment": "c", "element": "button", "elementPath": "body > button"}) + if resp.StatusCode != http.StatusNotFound || !strings.Contains(body, "Session not found") { + t.Fatalf("expected missing session annotation add, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/sessions/"+session.ID+"/annotations", map[string]any{"comment": "", "element": "button", "elementPath": "body > button"}) + if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "required") { + t.Fatalf("expected add annotation validation error, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/sessions/"+session.ID+"/annotations", map[string]any{ + "comment": "Fix", + "element": "button", + "elementPath": "body > button", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("add annotation status = %d body=%s", resp.StatusCode, body) + } + var annotation Annotation + _ = json.Unmarshal([]byte(body), &annotation) + + resp, body = call(http.MethodGet, "/annotations/missing", nil) + if resp.StatusCode != http.StatusNotFound || !strings.Contains(body, "Annotation not found") { + t.Fatalf("expected missing annotation, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPatch, "/annotations/"+annotation.ID, map[string]any{"status": "acknowledged"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("patch annotation status = %d body=%s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPatch, "/annotations/missing", map[string]any{"status": "acknowledged"}) + if resp.StatusCode != http.StatusNotFound || !strings.Contains(body, "Annotation not found") { + t.Fatalf("expected patch missing annotation, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/annotations/"+annotation.ID+"/thread", map[string]any{"role": "robot", "content": "x"}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected invalid role, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/annotations/"+annotation.ID+"/thread", map[string]any{"role": "human", "content": " "}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected empty content, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/annotations/missing/thread", map[string]any{"role": "human", "content": "x"}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected missing thread target, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/annotations/"+annotation.ID+"/thread", map[string]any{"role": "human", "content": "please fix hover"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("thread create status = %d body=%s", resp.StatusCode, body) + } + + resp, body = call(http.MethodGet, "/sessions/missing/pending", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected missing pending session, got %d %s", resp.StatusCode, body) + } + + resp, _ = call(http.MethodGet, "/sessions/"+session.ID+"/pending", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("session pending status = %d", resp.StatusCode) + } + + resp, _ = call(http.MethodGet, "/pending", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("all pending status = %d", resp.StatusCode) + } + + resp, body = call(http.MethodPost, "/sessions/missing/action", map[string]any{"output": "run"}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected missing action session, got %d %s", resp.StatusCode, body) + } + + resp, body = call(http.MethodPost, "/sessions/"+session.ID+"/action", map[string]any{"output": ""}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected action output validation, got %d %s", resp.StatusCode, body) + } + + resp, _ = call(http.MethodPost, "/sessions/"+session.ID+"/action", map[string]any{"output": "run it"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("action request status = %d", resp.StatusCode) + } + + resp, body = call(http.MethodDelete, "/annotations/missing", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected delete missing annotation, got %d %s", resp.StatusCode, body) + } + + resp, _ = call(http.MethodDelete, "/annotations/"+annotation.ID, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete annotation status = %d", resp.StatusCode) + } +} + +func TestServiceUtilitiesAndSSEHelpers(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + service := NewService("127.0.0.1:0", slog.New(slog.NewTextHandler(io.Discard, nil))) + + if parseLastEventID("") != 0 || parseLastEventID("bad") != 0 || parseLastEventID("-1") != 0 || parseLastEventID("2") != 2 { + t.Fatal("parseLastEventID should handle empty/invalid/negative/valid values") + } + + if valueOr("", "fallback") != "fallback" || valueOr("x", "fallback") != "x" { + t.Fatal("valueOr returned unexpected values") + } + + if sessionMatchesDomain(Session{URL: "::bad-url::"}, "example.com") { + t.Fatal("invalid URL should not match domain") + } + if !sessionMatchesDomain(Session{URL: "http://example.com/path"}, "example.com") { + t.Fatal("valid URL should match domain") + } + + if service.eventMatchesDomain(Event{SessionID: "missing"}, "example.com") { + t.Fatal("missing session should not match domain") + } + + s := service.store.CreateSession("http://example.com/page", "") + if !service.eventMatchesDomain(Event{SessionID: s.ID}, "example.com") { + t.Fatal("existing session event should match domain") + } + + nonFlusher := &headerOnlyWriter{header: make(http.Header)} + if startSSE(nonFlusher) { + t.Fatal("startSSE should fail when Flusher is not implemented") + } + + flusher := newBufferSSEWriter(false) + if !startSSE(flusher) { + t.Fatal("startSSE should succeed for flusher writer") + } + + if err := writeSSEEvent(flusher, Event{Type: EventAnnotationCreated, Sequence: 1, Payload: map[string]any{"id": "a1"}}); err != nil { + t.Fatalf("writeSSEEvent failed: %v", err) + } + if err := writeSSECustomEvent(flusher, "sync.complete", map[string]any{"count": 1}); err != nil { + t.Fatalf("writeSSECustomEvent failed: %v", err) + } + if err := writeSSEComment(flusher, "ping"); err != nil { + t.Fatalf("writeSSEComment failed: %v", err) + } + + failingWriter := newBufferSSEWriter(true) + if err := writeSSEEvent(failingWriter, Event{Type: EventAnnotationCreated}); err == nil { + t.Fatal("writeSSEEvent should fail when writer errors") + } + if err := writeSSECustomEvent(failingWriter, "x", map[string]any{}); err == nil { + t.Fatal("writeSSECustomEvent should fail when writer errors") + } + if err := writeSSEComment(failingWriter, "x"); err == nil { + t.Fatal("writeSSEComment should fail when writer errors") + } + + badRequest := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{")) + var payload map[string]any + if err := decodeBody(badRequest, &payload); err == nil { + t.Fatal("decodeBody should fail for invalid JSON") + } + goodRequest := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"a":1}`)) + if err := decodeBody(goodRequest, &payload); err != nil { + t.Fatalf("decodeBody should parse valid JSON: %v", err) + } + + jsonWriter := httptest.NewRecorder() + writeError(jsonWriter, http.StatusBadRequest, "oops") + if jsonWriter.Code != http.StatusBadRequest || !strings.Contains(jsonWriter.Body.String(), "oops") { + t.Fatalf("writeError response unexpected: status=%d body=%s", jsonWriter.Code, jsonWriter.Body.String()) + } +} + +func TestStreamFunctionsAndSync(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + service := NewService("127.0.0.1:0", slog.New(slog.NewTextHandler(io.Discard, nil))) + s1 := service.store.CreateSession("http://example.com/a", "") + s2 := service.store.CreateSession("http://other.com/b", "") + + _, _ = service.store.AddAnnotation(s1.ID, Annotation{Comment: "A", Element: "button", ElementPath: "body > button"}) + _, _ = service.store.AddAnnotation(s2.ID, Annotation{Comment: "B", Element: "div", ElementPath: "body > div"}) + + writer := newBufferSSEWriter(false) + service.sendInitialSync(writer, "example.com") + if !strings.Contains(writer.String(), "sync.complete") || !strings.Contains(writer.String(), "annotation.created") { + t.Fatalf("sendInitialSync output missing expected events: %s", writer.String()) + } + + events := make(chan Event, 2) + events <- Event{Type: EventAnnotationCreated, SessionID: s1.ID, Sequence: 2, Payload: map[string]any{"id": "a1"}} + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + service.streamEvents(ctx, writer, events) + + globalWriter := newBufferSSEWriter(false) + globalEvents := make(chan Event, 3) + globalEvents <- Event{Type: EventAnnotationCreated, SessionID: s2.ID, Sequence: 3, Payload: map[string]any{"id": "skip"}} + globalEvents <- Event{Type: EventAnnotationCreated, SessionID: s1.ID, Sequence: 4, Payload: map[string]any{"id": "keep"}} + ctx2, cancel2 := context.WithCancel(context.Background()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel2() + }() + service.streamGlobalEvents(ctx2, globalWriter, globalEvents, "example.com") + if strings.Contains(globalWriter.String(), "skip") { + t.Fatal("streamGlobalEvents should filter unmatched domains") + } + if !strings.Contains(globalWriter.String(), "keep") { + t.Fatal("streamGlobalEvents should include matching domain event") + } + + errorWriter := newBufferSSEWriter(true) + errorEvents := make(chan Event, 1) + errorEvents <- Event{Type: EventAnnotationCreated, SessionID: s1.ID, Sequence: 5, Payload: map[string]any{"id": "x"}} + service.streamEvents(context.Background(), errorWriter, errorEvents) +} + +func TestSessionAndGlobalEventHandlersDirect(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + service := NewService("127.0.0.1:0", slog.New(slog.NewTextHandler(io.Discard, nil))) + session := service.store.CreateSession("http://example.com/page", "") + _, _ = service.store.AddAnnotation(session.ID, Annotation{Comment: "A", Element: "button", ElementPath: "body > button"}) + _, _ = service.store.AddAnnotation(session.ID, Annotation{Comment: "B", Element: "div", ElementPath: "body > div"}) + + nonFlusher := &headerOnlyWriter{header: make(http.Header)} + req := httptest.NewRequest(http.MethodGet, "/sessions/"+session.ID+"/events", nil) + req.SetPathValue("id", session.ID) + service.handleSessionEvents(nonFlusher, req) + if nonFlusher.status != http.StatusInternalServerError { + t.Fatalf("expected 500 for non-flusher writer, got %d", nonFlusher.status) + } + + missingReq := httptest.NewRequest(http.MethodGet, "/sessions/missing/events", nil) + missingReq.SetPathValue("id", "missing") + missingWriter := httptest.NewRecorder() + service.handleSessionEvents(missingWriter, missingReq) + if missingWriter.Code != http.StatusNotFound { + t.Fatalf("expected 404 for missing session events, got %d", missingWriter.Code) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancelWriter := newBufferSSEWriter(false) + replayReq := httptest.NewRequest(http.MethodGet, "/sessions/"+session.ID+"/events?agent=true", nil).WithContext(ctx) + replayReq.Header.Set("Last-Event-ID", "1") + replayReq.SetPathValue("id", session.ID) + + var wg sync.WaitGroup + wg.Go(func() { + service.handleSessionEvents(cancelWriter, replayReq) + }) + time.Sleep(60 * time.Millisecond) + cancel() + wg.Wait() + + if service.activeListeners != 0 || service.agentListeners != 0 { + t.Fatal("listener counters should return to zero after disconnect") + } + if !strings.Contains(cancelWriter.String(), "annotation.created") { + t.Fatalf("expected replay/output events, got: %s", cancelWriter.String()) + } + + globalNonFlusher := &headerOnlyWriter{header: make(http.Header)} + globalReq := httptest.NewRequest(http.MethodGet, "/events", nil) + service.handleGlobalEvents(globalNonFlusher, globalReq) + if globalNonFlusher.status != http.StatusInternalServerError { + t.Fatalf("expected 500 for global non-flusher, got %d", globalNonFlusher.status) + } + + globalCtx, globalCancel := context.WithCancel(context.Background()) + globalWriter := newBufferSSEWriter(false) + globalRequest := httptest.NewRequest(http.MethodGet, "/events?agent=true&domain=example.com", nil).WithContext(globalCtx) + wg.Go(func() { + service.handleGlobalEvents(globalWriter, globalRequest) + }) + time.Sleep(60 * time.Millisecond) + globalCancel() + wg.Wait() + + if !strings.Contains(globalWriter.String(), "sync.complete") { + t.Fatalf("expected sync.complete in global stream output: %s", globalWriter.String()) + } +} + +func TestListenAndShutdown(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + service := NewService("127.0.0.1:0", slog.New(slog.NewTextHandler(io.Discard, nil))) + + errCh := make(chan error, 1) + go func() { + errCh <- service.ListenAndServe() + }() + + time.Sleep(80 * time.Millisecond) + shutdownErr := service.Shutdown(context.Background()) + if shutdownErr != nil { + t.Fatalf("Shutdown returned error: %v", shutdownErr) + } + + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("ListenAndServe returned unexpected error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ListenAndServe to return") + } +} + +type headerOnlyWriter struct { + header http.Header + status int + body bytes.Buffer +} + +func (w *headerOnlyWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *headerOnlyWriter) WriteHeader(status int) { + w.status = status +} + +func (w *headerOnlyWriter) Write(data []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + return w.body.Write(data) +} + +type bufferSSEWriter struct { + header http.Header + status int + body bytes.Buffer + failWrite bool +} + +func newBufferSSEWriter(failWrite bool) *bufferSSEWriter { + return &bufferSSEWriter{header: make(http.Header), failWrite: failWrite} +} + +func (w *bufferSSEWriter) Header() http.Header { + return w.header +} + +func (w *bufferSSEWriter) WriteHeader(status int) { + w.status = status +} + +func (w *bufferSSEWriter) Write(data []byte) (int, error) { + if w.failWrite { + return 0, fmt.Errorf("forced write error") + } + if w.status == 0 { + w.status = http.StatusOK + } + return w.body.Write(data) +} + +func (w *bufferSSEWriter) Flush() {} + +func (w *bufferSSEWriter) String() string { + return w.body.String() +} diff --git a/cli/internal/server/service_test.go b/cli/internal/server/service_test.go new file mode 100644 index 00000000..9597584c --- /dev/null +++ b/cli/internal/server/service_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSessionAndPendingFlow(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + service := NewService("127.0.0.1:0", logger) + testServer := httptest.NewServer(service.httpServer.Handler) + defer testServer.Close() + + sessionID := createSession(t, testServer.URL, "http://example.com") + + annotationID := createAnnotation(t, testServer.URL, sessionID, map[string]any{ + "x": 10, + "y": 20, + "comment": "Fix this button", + "element": "button", + "elementPath": "body > button", + "timestamp": 123, + }) + + pending := getPending(t, testServer.URL, "/pending") + if pending.Count != 1 { + t.Fatalf("pending.Count = %d, want 1", pending.Count) + } + + patchBody := map[string]any{"status": "acknowledged"} + callJSON(t, http.MethodPatch, testServer.URL+"/annotations/"+annotationID, patchBody, nil, http.StatusOK) + + pending = getPending(t, testServer.URL, "/pending") + if pending.Count != 0 { + t.Fatalf("pending.Count after acknowledge = %d, want 0", pending.Count) + } + + reply := map[string]any{"role": "human", "content": "Please also update hover state"} + callJSON(t, http.MethodPost, testServer.URL+"/annotations/"+annotationID+"/thread", reply, nil, http.StatusCreated) + + pending = getPending(t, testServer.URL, "/pending") + if pending.Count != 1 { + t.Fatalf("pending.Count after human reply = %d, want 1", pending.Count) + } +} + +func createSession(t *testing.T, baseURL, pageURL string) string { + t.Helper() + + var session Session + callJSON(t, http.MethodPost, baseURL+"/sessions", map[string]any{"url": pageURL}, &session, http.StatusCreated) + if session.ID == "" { + t.Fatal("session.ID should not be empty") + } + return session.ID +} + +func createAnnotation(t *testing.T, baseURL, sessionID string, body map[string]any) string { + t.Helper() + + var annotation Annotation + callJSON(t, http.MethodPost, baseURL+"/sessions/"+sessionID+"/annotations", body, &annotation, http.StatusCreated) + if annotation.ID == "" { + t.Fatal("annotation.ID should not be empty") + } + return annotation.ID +} + +func getPending(t *testing.T, baseURL, path string) pendingResponse { + t.Helper() + + var pending pendingResponse + callJSON(t, http.MethodGet, baseURL+path, nil, &pending, http.StatusOK) + return pending +} + +func callJSON(t *testing.T, method, endpoint string, body any, target any, expectedStatus int) { + t.Helper() + + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + reader = bytes.NewReader(payload) + } + + request, err := http.NewRequest(method, endpoint, reader) + if err != nil { + t.Fatalf("http.NewRequest failed: %v", err) + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer response.Body.Close() + + if response.StatusCode != expectedStatus { + content, _ := io.ReadAll(response.Body) + t.Fatalf("status = %d, want %d, body = %s", response.StatusCode, expectedStatus, string(content)) + } + + if target != nil { + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + t.Fatalf("json decode failed: %v", err) + } + } +} diff --git a/cli/internal/server/sqlite.go b/cli/internal/server/sqlite.go new file mode 100644 index 00000000..78e8b68d --- /dev/null +++ b/cli/internal/server/sqlite.go @@ -0,0 +1,291 @@ +package server + +import ( + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" +) + +type storeSnapshot struct { + Sessions map[string]Session + Annotations map[string]Annotation + Events map[string][]Event + Sequence int64 +} + +type persistenceBackend interface { + LoadSnapshot() (storeSnapshot, error) + UpsertSession(session Session) error + UpsertAnnotation(annotation Annotation) error + DeleteAnnotation(annotationID string) error + InsertEvent(event Event) error + Close() error +} + +type sqliteBackend struct { + db *sql.DB +} + +func newPersistenceBackend() (persistenceBackend, error) { + mode := strings.TrimSpace(strings.ToLower(os.Getenv("AGENTATION_STORE"))) + if mode == "memory" { + return nil, nil + } + + dbPath, err := sqlitePath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + backend := &sqliteBackend{db: db} + if err := backend.init(); err != nil { + _ = db.Close() + return nil, err + } + + return backend, nil +} + +func sqlitePath() (string, error) { + override := strings.TrimSpace(os.Getenv("AGENTATION_DB_PATH")) + if override != "" { + return override, nil + } + + xdgDataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")) + if xdgDataHome != "" { + return filepath.Join(xdgDataHome, "agentation", "store.db"), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "agentation", "store.db"), nil +} + +func (b *sqliteBackend) init() error { + statements := []string{ + "PRAGMA journal_mode=WAL;", + "PRAGMA busy_timeout=5000;", + "PRAGMA foreign_keys=ON;", + `CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + project_id TEXT, + metadata_json TEXT + );`, + `CREATE TABLE IF NOT EXISTS annotations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data_json TEXT NOT NULL, + updated_at TEXT, + FOREIGN KEY(session_id) REFERENCES sessions(id) + );`, + `CREATE TABLE IF NOT EXISTS events ( + sequence INTEGER PRIMARY KEY, + session_id TEXT NOT NULL, + data_json TEXT NOT NULL + );`, + } + + for _, statement := range statements { + if _, err := b.db.Exec(statement); err != nil { + return err + } + } + + return nil +} + +func (b *sqliteBackend) LoadSnapshot() (storeSnapshot, error) { + snapshot := storeSnapshot{ + Sessions: make(map[string]Session), + Annotations: make(map[string]Annotation), + Events: make(map[string][]Event), + } + + sessionRows, err := b.db.Query(`SELECT id, url, status, created_at, COALESCE(updated_at, ''), COALESCE(project_id, ''), COALESCE(metadata_json, '') FROM sessions`) + if err != nil { + return storeSnapshot{}, err + } + defer sessionRows.Close() + + for sessionRows.Next() { + var session Session + var updatedAt string + var projectID string + var metadataJSON string + if err := sessionRows.Scan(&session.ID, &session.URL, &session.Status, &session.CreatedAt, &updatedAt, &projectID, &metadataJSON); err != nil { + return storeSnapshot{}, err + } + if updatedAt != "" { + session.UpdatedAt = updatedAt + } + if projectID != "" { + session.ProjectID = projectID + } + if metadataJSON != "" { + _ = json.Unmarshal([]byte(metadataJSON), &session.Metadata) + } + snapshot.Sessions[session.ID] = session + } + if err := sessionRows.Err(); err != nil { + return storeSnapshot{}, err + } + + annotationRows, err := b.db.Query(`SELECT id, data_json FROM annotations`) + if err != nil { + return storeSnapshot{}, err + } + defer annotationRows.Close() + + for annotationRows.Next() { + var annotationID string + var dataJSON string + if err := annotationRows.Scan(&annotationID, &dataJSON); err != nil { + return storeSnapshot{}, err + } + var annotation Annotation + if err := json.Unmarshal([]byte(dataJSON), &annotation); err != nil { + return storeSnapshot{}, err + } + snapshot.Annotations[annotation.ID] = annotation + } + if err := annotationRows.Err(); err != nil { + return storeSnapshot{}, err + } + + eventRows, err := b.db.Query(`SELECT sequence, session_id, data_json FROM events ORDER BY sequence ASC`) + if err != nil { + return storeSnapshot{}, err + } + defer eventRows.Close() + + for eventRows.Next() { + var sequence int64 + var sessionID string + var dataJSON string + if err := eventRows.Scan(&sequence, &sessionID, &dataJSON); err != nil { + return storeSnapshot{}, err + } + var event Event + if err := json.Unmarshal([]byte(dataJSON), &event); err != nil { + return storeSnapshot{}, err + } + event.Sequence = sequence + if event.SessionID == "" { + event.SessionID = sessionID + } + snapshot.Events[event.SessionID] = append(snapshot.Events[event.SessionID], event) + if sequence > snapshot.Sequence { + snapshot.Sequence = sequence + } + } + if err := eventRows.Err(); err != nil { + return storeSnapshot{}, err + } + + return snapshot, nil +} + +func (b *sqliteBackend) UpsertSession(session Session) error { + metadataJSON := "" + if session.Metadata != nil { + payload, err := json.Marshal(session.Metadata) + if err != nil { + return err + } + metadataJSON = string(payload) + } + + _, err := b.db.Exec( + `INSERT INTO sessions (id, url, status, created_at, updated_at, project_id, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + url = excluded.url, + status = excluded.status, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + project_id = excluded.project_id, + metadata_json = excluded.metadata_json`, + session.ID, + session.URL, + session.Status, + session.CreatedAt, + emptyToNil(session.UpdatedAt), + emptyToNil(session.ProjectID), + emptyToNil(metadataJSON), + ) + return err +} + +func (b *sqliteBackend) UpsertAnnotation(annotation Annotation) error { + payload, err := json.Marshal(annotation) + if err != nil { + return err + } + + _, err = b.db.Exec( + `INSERT INTO annotations (id, session_id, data_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + session_id = excluded.session_id, + data_json = excluded.data_json, + updated_at = excluded.updated_at`, + annotation.ID, + annotation.SessionID, + string(payload), + emptyToNil(annotation.UpdatedAt), + ) + return err +} + +func (b *sqliteBackend) DeleteAnnotation(annotationID string) error { + _, err := b.db.Exec(`DELETE FROM annotations WHERE id = ?`, annotationID) + return err +} + +func (b *sqliteBackend) InsertEvent(event Event) error { + if event.Sequence <= 0 { + return errors.New("event sequence must be greater than zero") + } + + payload, err := json.Marshal(event) + if err != nil { + return err + } + + _, err = b.db.Exec(`INSERT OR REPLACE INTO events (sequence, session_id, data_json) VALUES (?, ?, ?)`, event.Sequence, event.SessionID, string(payload)) + return err +} + +func (b *sqliteBackend) Close() error { + if b == nil || b.db == nil { + return nil + } + return b.db.Close() +} + +func emptyToNil(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return value +} diff --git a/cli/internal/server/sqlite_test.go b/cli/internal/server/sqlite_test.go new file mode 100644 index 00000000..5e03ee7d --- /dev/null +++ b/cli/internal/server/sqlite_test.go @@ -0,0 +1,177 @@ +package server + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestSQLiteBackendRoundTrip(t *testing.T) { + t.Setenv("AGENTATION_STORE", "sqlite") + t.Setenv("AGENTATION_DB_PATH", filepath.Join(t.TempDir(), "store.db")) + + backend, err := newPersistenceBackend() + if err != nil { + t.Fatalf("newPersistenceBackend error: %v", err) + } + if backend == nil { + t.Fatal("expected sqlite backend") + } + defer backend.Close() + + session := Session{ + ID: "s1", + URL: "http://example.com", + Status: "active", + CreatedAt: nowISO(), + Metadata: map[string]any{"env": "dev"}, + } + if err := backend.UpsertSession(session); err != nil { + t.Fatalf("UpsertSession error: %v", err) + } + + annotation := Annotation{ + ID: "a1", + SessionID: session.ID, + Comment: "Fix this", + Element: "button", + ElementPath: "body > button", + Status: StatusPending, + CreatedAt: nowISO(), + } + if err := backend.UpsertAnnotation(annotation); err != nil { + t.Fatalf("UpsertAnnotation error: %v", err) + } + + event := Event{Type: EventAnnotationCreated, SessionID: session.ID, Sequence: 1, Payload: annotation} + if err := backend.InsertEvent(event); err != nil { + t.Fatalf("InsertEvent error: %v", err) + } + + snapshot, err := backend.LoadSnapshot() + if err != nil { + t.Fatalf("LoadSnapshot error: %v", err) + } + if len(snapshot.Sessions) != 1 || len(snapshot.Annotations) != 1 { + t.Fatalf("unexpected snapshot sizes: sessions=%d annotations=%d", len(snapshot.Sessions), len(snapshot.Annotations)) + } + if snapshot.Sequence != 1 { + t.Fatalf("snapshot sequence = %d, want 1", snapshot.Sequence) + } + + if err := backend.DeleteAnnotation(annotation.ID); err != nil { + t.Fatalf("DeleteAnnotation error: %v", err) + } + postDelete, err := backend.LoadSnapshot() + if err != nil { + t.Fatalf("LoadSnapshot after delete error: %v", err) + } + if len(postDelete.Annotations) != 0 { + t.Fatalf("expected no annotations after delete, got %d", len(postDelete.Annotations)) + } + + if err := backend.InsertEvent(Event{Sequence: 0, SessionID: session.ID}); err == nil { + t.Fatal("InsertEvent should fail when sequence is zero") + } + + if err := backend.Close(); err != nil { + t.Fatalf("Close error: %v", err) + } +} + +func TestSQLiteModesAndPaths(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + backend, err := newPersistenceBackend() + if err != nil { + t.Fatalf("newPersistenceBackend(memory) error: %v", err) + } + if backend != nil { + t.Fatal("memory mode should return nil backend") + } + + customPath := filepath.Join(t.TempDir(), "custom.db") + t.Setenv("AGENTATION_STORE", "sqlite") + t.Setenv("AGENTATION_DB_PATH", customPath) + + resolved, err := sqlitePath() + if err != nil { + t.Fatalf("sqlitePath error: %v", err) + } + if resolved != customPath { + t.Fatalf("sqlitePath = %q, want %q", resolved, customPath) + } + + backend, err = newPersistenceBackend() + if err != nil { + t.Fatalf("newPersistenceBackend(sqlite) error: %v", err) + } + if backend == nil { + t.Fatal("sqlite mode should create backend") + } + _ = backend.Close() + + t.Setenv("AGENTATION_DB_PATH", "") + xdgDataHome := filepath.Join(t.TempDir(), "xdg-data") + t.Setenv("XDG_DATA_HOME", xdgDataHome) + xdgPath, err := sqlitePath() + if err != nil { + t.Fatalf("sqlitePath xdg error: %v", err) + } + if xdgPath != filepath.Join(xdgDataHome, "agentation", "store.db") { + t.Fatalf("unexpected xdg sqlite path: %q", xdgPath) + } + + t.Setenv("XDG_DATA_HOME", "") + defaultPath, err := sqlitePath() + if err != nil { + t.Fatalf("sqlitePath default error: %v", err) + } + if !strings.Contains(defaultPath, filepath.Join(".local", "share", "agentation")) || !strings.HasSuffix(defaultPath, "store.db") { + t.Fatalf("unexpected default sqlite path: %q", defaultPath) + } + + if got := emptyToNil(" "); got != nil { + t.Fatalf("emptyToNil should return nil for blank string, got %#v", got) + } + if got := emptyToNil("x"); got != "x" { + t.Fatalf("emptyToNil should preserve non-empty value, got %#v", got) + } +} + +func TestSQLiteLoadSnapshotErrors(t *testing.T) { + t.Setenv("AGENTATION_STORE", "sqlite") + t.Setenv("AGENTATION_DB_PATH", filepath.Join(t.TempDir(), "bad.db")) + + backendAny, err := newPersistenceBackend() + if err != nil { + t.Fatalf("newPersistenceBackend error: %v", err) + } + backend := backendAny.(*sqliteBackend) + defer backend.Close() + + _, err = backend.db.Exec(`INSERT INTO sessions (id, url, status, created_at) VALUES ('s1', 'http://example.com', 'active', ?)`, nowISO()) + if err != nil { + t.Fatalf("insert session error: %v", err) + } + + _, err = backend.db.Exec(`INSERT INTO annotations (id, session_id, data_json) VALUES ('a1', 's1', '{bad-json')`) + if err != nil { + t.Fatalf("insert invalid annotation row error: %v", err) + } + + if _, err := backend.LoadSnapshot(); err == nil { + t.Fatal("LoadSnapshot should fail for invalid annotation JSON") + } +} + +func TestSQLiteCloseNilBackend(t *testing.T) { + var backend *sqliteBackend + if err := backend.Close(); err != nil { + t.Fatalf("nil backend Close should not fail: %v", err) + } + + nonNil := &sqliteBackend{} + if err := nonNil.Close(); err != nil { + t.Fatalf("backend with nil db Close should not fail: %v", err) + } +} diff --git a/cli/internal/server/store.go b/cli/internal/server/store.go new file mode 100644 index 00000000..ce514cb1 --- /dev/null +++ b/cli/internal/server/store.go @@ -0,0 +1,435 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "sync" + "sync/atomic" + "time" +) + +type Store struct { + mu sync.RWMutex + + sessions map[string]Session + annotations map[string]Annotation + events map[string][]Event + + sequence int64 + + subsMu sync.RWMutex + nextSubID int64 + globalSubs map[int64]chan Event + sessionSubs map[string]map[int64]chan Event + + idCounter uint64 + + persistence persistenceBackend +} + +func NewStore() *Store { + store := &Store{ + sessions: make(map[string]Session), + annotations: make(map[string]Annotation), + events: make(map[string][]Event), + globalSubs: make(map[int64]chan Event), + sessionSubs: make(map[string]map[int64]chan Event), + } + + backend, err := newPersistenceBackend() + if err != nil { + fmt.Fprintf(os.Stderr, "[Store] SQLite unavailable, using in-memory store: %v\n", err) + return store + } + if backend == nil { + fmt.Fprintln(os.Stderr, "[Store] Using in-memory store (AGENTATION_STORE=memory)") + return store + } + + snapshot, err := backend.LoadSnapshot() + if err != nil { + fmt.Fprintf(os.Stderr, "[Store] Failed to load SQLite snapshot, using in-memory store: %v\n", err) + _ = backend.Close() + return store + } + + store.sessions = snapshot.Sessions + store.annotations = snapshot.Annotations + store.events = snapshot.Events + store.sequence = snapshot.Sequence + store.persistence = backend + + fmt.Fprintln(os.Stderr, "[Store] Using SQLite store") + return store +} + +func (s *Store) CreateSession(url, projectID string) Session { + s.mu.Lock() + defer s.mu.Unlock() + + session := Session{ + ID: s.newID(), + URL: url, + Status: "active", + CreatedAt: nowISO(), + ProjectID: projectID, + } + s.sessions[session.ID] = session + s.persistSessionLocked(session) + s.emitLocked(EventSessionCreated, session.ID, session) + return session +} + +func (s *Store) ListSessions() []Session { + s.mu.RLock() + defer s.mu.RUnlock() + + sessions := make([]Session, 0, len(s.sessions)) + for _, session := range s.sessions { + sessions = append(sessions, session) + } + return sessions +} + +func (s *Store) GetSession(id string) (Session, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + session, ok := s.sessions[id] + return session, ok +} + +func (s *Store) GetSessionWithAnnotations(id string) (SessionWithAnnotations, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + session, ok := s.sessions[id] + if !ok { + return SessionWithAnnotations{}, false + } + + annotations := make([]Annotation, 0) + for _, annotation := range s.annotations { + if annotation.SessionID == id { + annotations = append(annotations, annotation) + } + } + + return SessionWithAnnotations{ + Session: session, + Annotations: annotations, + }, true +} + +func (s *Store) AddAnnotation(sessionID string, annotation Annotation) (Annotation, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.sessions[sessionID]; !ok { + return Annotation{}, false + } + + annotation.ID = s.newID() + annotation.SessionID = sessionID + annotation.Status = StatusPending + annotation.CreatedAt = nowISO() + if annotation.Timestamp == 0 { + annotation.Timestamp = time.Now().UnixMilli() + } + if annotation.Thread == nil { + annotation.Thread = []ThreadMessage{} + } + + s.annotations[annotation.ID] = annotation + s.persistAnnotationLocked(annotation) + s.emitLocked(EventAnnotationCreated, sessionID, annotation) + return annotation, true +} + +func (s *Store) GetAnnotation(id string) (Annotation, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + annotation, ok := s.annotations[id] + return annotation, ok +} + +func (s *Store) UpdateAnnotation(id string, patch map[string]any) (Annotation, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + annotation, ok := s.annotations[id] + if !ok { + return Annotation{}, false + } + + if value, exists := patch["comment"].(string); exists { + annotation.Comment = value + } + if value, exists := patch["status"].(string); exists { + annotation.Status = AnnotationStatus(value) + } + if value, exists := patch["resolvedBy"].(string); exists { + annotation.ResolvedBy = value + } + if value, exists := patch["resolvedAt"].(string); exists { + annotation.ResolvedAt = value + } + + if annotation.Status == StatusResolved || annotation.Status == StatusDismissed { + if annotation.ResolvedAt == "" { + annotation.ResolvedAt = nowISO() + } + if annotation.ResolvedBy == "" { + annotation.ResolvedBy = "agent" + } + } + + annotation.UpdatedAt = nowISO() + s.annotations[id] = annotation + s.persistAnnotationLocked(annotation) + s.emitLocked(EventAnnotationUpdated, annotation.SessionID, annotation) + return annotation, true +} + +func (s *Store) DeleteAnnotation(id string) (Annotation, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + annotation, ok := s.annotations[id] + if !ok { + return Annotation{}, false + } + + delete(s.annotations, id) + s.deleteAnnotationLocked(id) + s.emitLocked(EventAnnotationDeleted, annotation.SessionID, annotation) + return annotation, true +} + +func (s *Store) AddThreadMessage(annotationID, role, content string) (Annotation, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + annotation, ok := s.annotations[annotationID] + if !ok { + return Annotation{}, false + } + + message := ThreadMessage{ + ID: s.newID(), + Role: role, + Content: content, + Timestamp: time.Now().UnixMilli(), + } + annotation.Thread = append(annotation.Thread, message) + annotation.UpdatedAt = nowISO() + s.annotations[annotationID] = annotation + s.persistAnnotationLocked(annotation) + s.emitLocked(EventThreadMessage, annotation.SessionID, annotation) + return annotation, true +} + +func (s *Store) GetAnnotationsNeedingAttention(sessionID string) []Annotation { + s.mu.RLock() + defer s.mu.RUnlock() + + annotations := make([]Annotation, 0) + for _, annotation := range s.annotations { + if annotation.SessionID != sessionID { + continue + } + if needsAttention(annotation) { + annotations = append(annotations, annotation) + } + } + return annotations +} + +func (s *Store) GetAllAnnotationsNeedingAttention() []Annotation { + s.mu.RLock() + defer s.mu.RUnlock() + + annotations := make([]Annotation, 0) + for _, annotation := range s.annotations { + if needsAttention(annotation) { + annotations = append(annotations, annotation) + } + } + return annotations +} + +func (s *Store) GetSessionAnnotations(sessionID string) []Annotation { + s.mu.RLock() + defer s.mu.RUnlock() + + annotations := make([]Annotation, 0) + for _, annotation := range s.annotations { + if annotation.SessionID == sessionID { + annotations = append(annotations, annotation) + } + } + return annotations +} + +func (s *Store) GetEventsSince(sessionID string, sequence int64) []Event { + s.mu.RLock() + defer s.mu.RUnlock() + + events := s.events[sessionID] + filtered := make([]Event, 0) + for _, event := range events { + if event.Sequence > sequence { + filtered = append(filtered, event) + } + } + return filtered +} + +func (s *Store) EmitActionRequested(sessionID string, request ActionRequest) { + s.mu.Lock() + defer s.mu.Unlock() + s.emitLocked(EventActionRequested, sessionID, request) +} + +func (s *Store) SubscribeAll() (<-chan Event, func()) { + id := atomic.AddInt64(&s.nextSubID, 1) + ch := make(chan Event, 64) + + s.subsMu.Lock() + s.globalSubs[id] = ch + s.subsMu.Unlock() + + return ch, func() { + s.subsMu.Lock() + defer s.subsMu.Unlock() + delete(s.globalSubs, id) + } +} + +func (s *Store) SubscribeSession(sessionID string) (<-chan Event, func()) { + id := atomic.AddInt64(&s.nextSubID, 1) + ch := make(chan Event, 64) + + s.subsMu.Lock() + if s.sessionSubs[sessionID] == nil { + s.sessionSubs[sessionID] = make(map[int64]chan Event) + } + s.sessionSubs[sessionID][id] = ch + s.subsMu.Unlock() + + return ch, func() { + s.subsMu.Lock() + defer s.subsMu.Unlock() + subs := s.sessionSubs[sessionID] + if subs == nil { + return + } + delete(subs, id) + if len(subs) == 0 { + delete(s.sessionSubs, sessionID) + } + } +} + +func (s *Store) emitLocked(kind EventType, sessionID string, payload any) { + s.sequence++ + event := Event{ + Type: kind, + Timestamp: nowISO(), + SessionID: sessionID, + Sequence: s.sequence, + Payload: payload, + } + s.events[sessionID] = append(s.events[sessionID], event) + s.persistEventLocked(event) + s.publish(event) +} + +func (s *Store) publish(event Event) { + s.subsMu.RLock() + global := make([]chan Event, 0, len(s.globalSubs)) + for _, ch := range s.globalSubs { + global = append(global, ch) + } + session := make([]chan Event, 0) + if subs := s.sessionSubs[event.SessionID]; subs != nil { + session = make([]chan Event, 0, len(subs)) + for _, ch := range subs { + session = append(session, ch) + } + } + s.subsMu.RUnlock() + + for _, ch := range global { + nonBlockingSend(ch, event) + } + for _, ch := range session { + nonBlockingSend(ch, event) + } +} + +func (s *Store) persistSessionLocked(session Session) { + if s.persistence == nil { + return + } + if err := s.persistence.UpsertSession(session); err != nil { + fmt.Fprintf(os.Stderr, "[Store] Failed to persist session: %v\n", err) + } +} + +func (s *Store) persistAnnotationLocked(annotation Annotation) { + if s.persistence == nil { + return + } + if err := s.persistence.UpsertAnnotation(annotation); err != nil { + fmt.Fprintf(os.Stderr, "[Store] Failed to persist annotation: %v\n", err) + } +} + +func (s *Store) deleteAnnotationLocked(annotationID string) { + if s.persistence == nil { + return + } + if err := s.persistence.DeleteAnnotation(annotationID); err != nil { + fmt.Fprintf(os.Stderr, "[Store] Failed to delete annotation from SQLite: %v\n", err) + } +} + +func (s *Store) persistEventLocked(event Event) { + if s.persistence == nil { + return + } + if err := s.persistence.InsertEvent(event); err != nil { + fmt.Fprintf(os.Stderr, "[Store] Failed to persist event: %v\n", err) + } +} + +func nonBlockingSend(ch chan Event, event Event) { + select { + case ch <- event: + default: + } +} + +func needsAttention(annotation Annotation) bool { + if annotation.Status == "" || annotation.Status == StatusPending { + return true + } + + if len(annotation.Thread) == 0 { + return false + } + + last := annotation.Thread[len(annotation.Thread)-1] + return last.Role == "human" +} + +func (s *Store) newID() string { + counter := atomic.AddUint64(&s.idCounter, 1) + bytes := make([]byte, 4) + if _, err := rand.Read(bytes); err != nil { + return fmt.Sprintf("%d-%d", time.Now().UnixMilli(), counter) + } + return fmt.Sprintf("%d-%s", time.Now().UnixMilli(), hex.EncodeToString(bytes)) +} diff --git a/cli/internal/server/store_test.go b/cli/internal/server/store_test.go new file mode 100644 index 00000000..67847afe --- /dev/null +++ b/cli/internal/server/store_test.go @@ -0,0 +1,262 @@ +package server + +import ( + "fmt" + "path/filepath" + "testing" +) + +func TestStoreCoreFlows(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + store := NewStore() + + if _, ok := store.GetSession("missing"); ok { + t.Fatal("GetSession should return missing for unknown id") + } + if _, ok := store.GetSessionWithAnnotations("missing"); ok { + t.Fatal("GetSessionWithAnnotations should return missing for unknown id") + } + + session := store.CreateSession("http://example.com/page", "p1") + if session.ID == "" { + t.Fatal("session id should not be empty") + } + + sessions := store.ListSessions() + if len(sessions) != 1 { + t.Fatalf("ListSessions length = %d, want 1", len(sessions)) + } + + if _, ok := store.AddAnnotation("missing", Annotation{Comment: "x", Element: "button", ElementPath: "body > button"}); ok { + t.Fatal("AddAnnotation should fail for unknown session") + } + + annotation, ok := store.AddAnnotation(session.ID, Annotation{ + Comment: "Fix button", + Element: "button", + ElementPath: "body > button", + }) + if !ok { + t.Fatal("AddAnnotation should succeed") + } + if annotation.ID == "" || annotation.Status != StatusPending { + t.Fatalf("unexpected annotation: %#v", annotation) + } + if annotation.Timestamp == 0 { + t.Fatal("annotation timestamp should be set") + } + if annotation.Thread == nil { + t.Fatal("annotation thread slice should be initialized") + } + + if _, ok := store.GetAnnotation(annotation.ID); !ok { + t.Fatal("GetAnnotation should find inserted annotation") + } + if _, ok := store.GetAnnotation("missing"); ok { + t.Fatal("GetAnnotation should not find unknown annotation") + } + + if _, ok := store.UpdateAnnotation("missing", map[string]any{"comment": "x"}); ok { + t.Fatal("UpdateAnnotation should fail for unknown id") + } + + resolved, ok := store.UpdateAnnotation(annotation.ID, map[string]any{"status": "resolved"}) + if !ok { + t.Fatal("UpdateAnnotation should succeed") + } + if resolved.ResolvedAt == "" || resolved.ResolvedBy == "" { + t.Fatalf("resolved annotation should set resolution metadata: %#v", resolved) + } + + updated, ok := store.UpdateAnnotation(annotation.ID, map[string]any{"comment": "Updated", "resolvedBy": "human", "resolvedAt": "custom-time"}) + if !ok { + t.Fatal("UpdateAnnotation second patch should succeed") + } + if updated.Comment != "Updated" || updated.ResolvedBy != "human" || updated.ResolvedAt != "custom-time" { + t.Fatalf("unexpected updated annotation: %#v", updated) + } + + if _, ok := store.AddThreadMessage("missing", "human", "hello"); ok { + t.Fatal("AddThreadMessage should fail for unknown annotation") + } + threaded, ok := store.AddThreadMessage(annotation.ID, "human", "Please also fix hover") + if !ok { + t.Fatal("AddThreadMessage should succeed") + } + if len(threaded.Thread) == 0 { + t.Fatal("thread message should be appended") + } + + withAnn, ok := store.GetSessionWithAnnotations(session.ID) + if !ok || len(withAnn.Annotations) == 0 { + t.Fatalf("expected session with annotations, got ok=%v len=%d", ok, len(withAnn.Annotations)) + } + + attention := store.GetAnnotationsNeedingAttention(session.ID) + if len(attention) == 0 { + t.Fatal("expected annotation to need attention after human thread reply") + } + + allAttention := store.GetAllAnnotationsNeedingAttention() + if len(allAttention) == 0 { + t.Fatal("GetAllAnnotationsNeedingAttention should include annotation") + } + + sessionAnnotations := store.GetSessionAnnotations(session.ID) + if len(sessionAnnotations) == 0 { + t.Fatal("GetSessionAnnotations should return inserted annotation") + } + + eventsSinceZero := store.GetEventsSince(session.ID, 0) + if len(eventsSinceZero) == 0 { + t.Fatal("GetEventsSince should return session events") + } + + store.EmitActionRequested(session.ID, ActionRequest{SessionID: session.ID, Output: "do work"}) + eventsSinceOne := store.GetEventsSince(session.ID, 1) + if len(eventsSinceOne) == 0 { + t.Fatal("EmitActionRequested should append event") + } + + if _, ok := store.DeleteAnnotation("missing"); ok { + t.Fatal("DeleteAnnotation should fail for unknown id") + } + deleted, ok := store.DeleteAnnotation(annotation.ID) + if !ok || deleted.ID != annotation.ID { + t.Fatal("DeleteAnnotation should remove existing annotation") + } +} + +func TestStoreSubscriptionsAndHelpers(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + store := NewStore() + session := store.CreateSession("http://example.com", "") + + allCh, unsubAll := store.SubscribeAll() + sessionCh, unsubSession := store.SubscribeSession(session.ID) + + _, ok := store.AddAnnotation(session.ID, Annotation{Comment: "A", Element: "button", ElementPath: "body > button"}) + if !ok { + t.Fatal("AddAnnotation should succeed") + } + + select { + case <-allCh: + default: + t.Fatal("global subscriber should receive event") + } + select { + case <-sessionCh: + default: + t.Fatal("session subscriber should receive event") + } + + unsubAll() + unsubSession() + + full := make(chan Event, 1) + full <- Event{Type: EventAnnotationCreated} + nonBlockingSend(full, Event{Type: EventAnnotationUpdated}) + if len(full) != 1 { + t.Fatal("nonBlockingSend should not block or add when channel is full") + } + + if !needsAttention(Annotation{Status: ""}) { + t.Fatal("empty status should need attention") + } + if !needsAttention(Annotation{Status: StatusPending}) { + t.Fatal("pending should need attention") + } + if needsAttention(Annotation{Status: StatusAcknowledged}) { + t.Fatal("acknowledged without thread should not need attention") + } + if !needsAttention(Annotation{Status: StatusAcknowledged, Thread: []ThreadMessage{{Role: "human"}}}) { + t.Fatal("human last thread message should need attention") + } + if needsAttention(Annotation{Status: StatusAcknowledged, Thread: []ThreadMessage{{Role: "agent"}}}) { + t.Fatal("agent last thread message should not need attention") + } + + if id := store.newID(); id == "" { + t.Fatal("newID should return non-empty id") + } +} + +func TestStoreSQLitePersistence(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "store.db") + t.Setenv("AGENTATION_STORE", "sqlite") + t.Setenv("AGENTATION_DB_PATH", dbPath) + + store := NewStore() + session := store.CreateSession("http://example.com/persisted", "project-1") + annotation, ok := store.AddAnnotation(session.ID, Annotation{Comment: "Persist me", Element: "button", ElementPath: "body > button"}) + if !ok { + t.Fatal("AddAnnotation should succeed") + } + _, ok = store.AddThreadMessage(annotation.ID, "human", "Hello") + if !ok { + t.Fatal("AddThreadMessage should succeed") + } + store.EmitActionRequested(session.ID, ActionRequest{SessionID: session.ID, Output: "do persisted work"}) + + reloaded := NewStore() + sessions := reloaded.ListSessions() + if len(sessions) == 0 { + t.Fatal("expected persisted sessions after reload") + } + _, found := reloaded.GetAnnotation(annotation.ID) + if !found { + t.Fatal("expected persisted annotation after reload") + } + events := reloaded.GetEventsSince(session.ID, 0) + if len(events) == 0 { + t.Fatal("expected persisted events after reload") + } +} + +func TestStorePersistenceHelpersWithFailingBackend(t *testing.T) { + t.Setenv("AGENTATION_STORE", "memory") + store := NewStore() + store.persistence = failingBackend{} + + store.persistSessionLocked(Session{ID: "s1", URL: "http://example.com", Status: "active", CreatedAt: nowISO()}) + store.persistAnnotationLocked(Annotation{ID: "a1", SessionID: "s1", Comment: "x", Element: "button", ElementPath: "body > button"}) + store.deleteAnnotationLocked("a1") + store.persistEventLocked(Event{Sequence: 1, SessionID: "s1", Type: EventAnnotationCreated, Payload: map[string]any{"id": "a1"}}) +} + +func TestStoreNewStoreFallbackOnPersistenceError(t *testing.T) { + t.Setenv("AGENTATION_STORE", "sqlite") + t.Setenv("AGENTATION_DB_PATH", t.TempDir()) + + store := NewStore() + if store.persistence != nil { + t.Fatal("expected in-memory fallback when SQLite cannot initialize") + } +} + +type failingBackend struct{} + +func (f failingBackend) LoadSnapshot() (storeSnapshot, error) { + return storeSnapshot{}, fmt.Errorf("load error") +} + +func (f failingBackend) UpsertSession(session Session) error { + return fmt.Errorf("session error") +} + +func (f failingBackend) UpsertAnnotation(annotation Annotation) error { + return fmt.Errorf("annotation error") +} + +func (f failingBackend) DeleteAnnotation(annotationID string) error { + return fmt.Errorf("delete error") +} + +func (f failingBackend) InsertEvent(event Event) error { + return fmt.Errorf("event error") +} + +func (f failingBackend) Close() error { + return nil +} diff --git a/cli/internal/serverctl/serverctl.go b/cli/internal/serverctl/serverctl.go new file mode 100644 index 00000000..c6943f67 --- /dev/null +++ b/cli/internal/serverctl/serverctl.go @@ -0,0 +1,391 @@ +package serverctl + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/benjitaylor/agentation/cli/internal/server" +) + +const shutdownTimeout = 5 * time.Second + +type serveConfig struct { + address string +} + +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stdout) + return 0 + } + + subcommand := args[0] + subArgs := args[1:] + + switch subcommand { + case "serve": + return runServe(subArgs, stdout, stderr) + case "start": + return runStart(subArgs, stdout, stderr) + case "stop": + return runStop(stdout, stderr) + case "status": + return runStatus(stdout) + case "help", "--help", "-h": + printUsage(stdout) + return 0 + default: + return runServe(args, stdout, stderr) + } +} + +func runServe(args []string, stdout, stderr io.Writer) int { + cfg, err := parseServeFlags(args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintf(stderr, "failed to parse serve flags: %v\n", err) + return 1 + } + + logger := slog.New(slog.NewTextHandler(stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + service := server.NewService(cfg.address, logger) + + go func() { + err := service.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("agentation server failed", "error", err) + os.Exit(1) + } + }() + + signalContext, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + <-signalContext.Done() + + shutdownContext, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := service.Shutdown(shutdownContext); err != nil { + logger.Error("agentation server shutdown failed", "error", err) + return 1 + } + + logger.Info("agentation server stopped") + return 0 +} + +func runStart(args []string, stdout, stderr io.Writer) int { + foreground, serveArgs := parseStartArgs(args) + + if pid, ok := loadRunningPID(); ok { + fmt.Fprintf(stdout, "agentation server already running (pid %d)\n", pid) + return 0 + } + + if foreground { + fmt.Fprintln(stdout, "starting agentation server in foreground") + return runServe(serveArgs, stdout, stderr) + } + + executablePath, err := os.Executable() + if err != nil { + fmt.Fprintf(stderr, "failed to resolve executable path: %v\n", err) + return 1 + } + + logPath := logFilePath() + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + fmt.Fprintf(stderr, "failed to create log directory: %v\n", err) + return 1 + } + + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + fmt.Fprintf(stderr, "failed to open log file: %v\n", err) + return 1 + } + defer logFile.Close() + + commandArgs := append([]string{"__serve-server"}, serveArgs...) + command := exec.Command(executablePath, commandArgs...) + command.Stdout = logFile + command.Stderr = logFile + + if err := command.Start(); err != nil { + fmt.Fprintf(stderr, "failed to start agentation server: %v\n", err) + return 1 + } + + pid := command.Process.Pid + if err := writePID(pid); err != nil { + fmt.Fprintf(stderr, "failed to write pid file: %v\n", err) + _ = command.Process.Kill() + return 1 + } + + time.Sleep(250 * time.Millisecond) + if !isProcessRunning(pid) { + _ = removePIDFile() + fmt.Fprintln(stderr, "agentation server failed to stay running") + return 1 + } + + fmt.Fprintf(stdout, "agentation server started in background (pid %d)\n", pid) + fmt.Fprintf(stdout, "log: %s\n", logPath) + return 0 +} + +func runStop(stdout, stderr io.Writer) int { + pid, err := readPID() + if err != nil || !isProcessRunning(pid) { + fallbackPID, ok := findRunningServerPIDByScan() + if !ok { + _ = removePIDFile() + fmt.Fprintln(stdout, "agentation server is not running") + return 0 + } + pid = fallbackPID + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Fprintf(stderr, "failed to find process: %v\n", err) + return 1 + } + + if err := process.Signal(os.Interrupt); err != nil { + if killErr := process.Kill(); killErr != nil { + fmt.Fprintf(stderr, "failed to stop agentation server: %v\n", killErr) + return 1 + } + } + + for range 30 { + if !isProcessRunning(pid) { + _ = removePIDFile() + fmt.Fprintf(stdout, "agentation server stopped (pid %d)\n", pid) + return 0 + } + time.Sleep(100 * time.Millisecond) + } + + if err := process.Kill(); err != nil { + fmt.Fprintf(stderr, "failed to kill agentation server: %v\n", err) + return 1 + } + + _ = removePIDFile() + fmt.Fprintf(stdout, "agentation server stopped (pid %d)\n", pid) + return 0 +} + +func runStatus(stdout io.Writer) int { + pid, err := readPID() + if err != nil || !isProcessRunning(pid) { + fallbackPID, ok := findRunningServerPIDByScan() + if !ok { + _ = removePIDFile() + fmt.Fprintln(stdout, "agentation server not running") + return 1 + } + pid = fallbackPID + _ = writePID(pid) + } + + fmt.Fprintf(stdout, "agentation server running (pid %d)\n", pid) + return 0 +} + +func parseServeFlags(args []string, stderr io.Writer) (serveConfig, error) { + cfg := serveConfig{address: "127.0.0.1:4747"} + + flags := flag.NewFlagSet("agentation server serve", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.StringVar(&cfg.address, "address", cfg.address, "HTTP listen address") + + if err := flags.Parse(args); err != nil { + return serveConfig{}, err + } + + cfg.address = strings.TrimSpace(cfg.address) + if cfg.address == "" { + cfg.address = "127.0.0.1:4747" + } + + return cfg, nil +} + +func parseStartArgs(args []string) (bool, []string) { + foreground := false + serveArgs := make([]string, 0, len(args)) + for _, arg := range args { + switch arg { + case "--foreground", "foreground": + foreground = true + case "--background", "background": + foreground = false + default: + serveArgs = append(serveArgs, arg) + } + } + return foreground, serveArgs +} + +func pidFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_SERVER_PID_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation-server.pid") +} + +func logFilePath() string { + path := strings.TrimSpace(os.Getenv("AGENTATION_SERVER_LOG_FILE")) + if path != "" { + return path + } + return filepath.Join(os.TempDir(), "agentation-server.log") +} + +func writePID(pid int) error { + path := pidFilePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o644) +} + +func readPID() (int, error) { + data, err := os.ReadFile(pidFilePath()) + if err != nil { + return 0, err + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return 0, fmt.Errorf("pid file is empty") + } + + pid, err := strconv.Atoi(value) + if err != nil { + return 0, err + } + if pid <= 0 { + return 0, fmt.Errorf("invalid pid") + } + + return pid, nil +} + +func removePIDFile() error { + err := os.Remove(pidFilePath()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + err = process.Signal(syscall.Signal(0)) + if err == nil { + return true + } + + message := strings.ToLower(err.Error()) + if strings.Contains(message, "process already finished") || strings.Contains(message, "no such process") { + return false + } + + return true +} + +func loadRunningPID() (int, bool) { + pid, err := readPID() + if err == nil && isProcessRunning(pid) { + return pid, true + } + + fallbackPID, ok := findRunningServerPIDByScan() + if !ok { + _ = removePIDFile() + return 0, false + } + + _ = writePID(fallbackPID) + return fallbackPID, true +} + +func findRunningServerPIDByScan() (int, bool) { + output, err := exec.Command("pgrep", "-f", "__serve-server").Output() + if err != nil { + return 0, false + } + + lines := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") + for line := range lines { + value := strings.TrimSpace(line) + if value == "" { + continue + } + + pid, parseErr := strconv.Atoi(value) + if parseErr != nil { + continue + } + if pid <= 0 || pid == os.Getpid() { + continue + } + if !isProcessRunning(pid) { + continue + } + + cmdOutput, cmdErr := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() + if cmdErr != nil { + continue + } + + commandLine := strings.TrimSpace(string(cmdOutput)) + if commandLine == "" { + continue + } + + if strings.Contains(commandLine, "__serve-server") { + return pid, true + } + } + + return 0, false +} + +func printUsage(writer io.Writer) { + fmt.Fprintln(writer, "agentation server commands:") + fmt.Fprintln(writer, " serve [--address 127.0.0.1:4747]") + fmt.Fprintln(writer, " start [--foreground|--background] [serve flags]") + fmt.Fprintln(writer, " stop") + fmt.Fprintln(writer, " status") +} diff --git a/cli/justfile b/cli/justfile new file mode 100644 index 00000000..0318af82 --- /dev/null +++ b/cli/justfile @@ -0,0 +1,14 @@ +set positional-arguments + +build: + mkdir -p ../bin + go build -o ../bin/agentation ./cmd/agentation + +test: + go test ./... + +fmt: + go fmt ./... + +lint: + go test ./... diff --git a/justfile b/justfile index 6e902c04..24351fdc 100644 --- a/justfile +++ b/justfile @@ -1,14 +1,4 @@ set positional-arguments -build-router: - mkdir -p bin - cd router && go build -o ../bin/agentation-router ./cmd/agentation-router - -test-router: - cd router && go test ./... - -fmt-router: - cd router && go fmt ./... - -lint-router: - cd router && go test ./... +build-cli: + cd cli && just build diff --git a/mcp/.gitignore b/mcp/.gitignore deleted file mode 100644 index dda67d14..00000000 --- a/mcp/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.env -.env.local -.env.* diff --git a/mcp/README.md b/mcp/README.md deleted file mode 100644 index 09fbba79..00000000 --- a/mcp/README.md +++ /dev/null @@ -1,171 +0,0 @@ -# Agentation MCP - -MCP (Model Context Protocol) server for Agentation - visual feedback for AI coding agents. - -This package provides an MCP server that allows AI coding agents (like Claude Code) to receive and respond to web page annotations created with the Agentation toolbar. - -## Installation - -```bash -npm install agentation-mcp -# or -pnpm add agentation-mcp -``` - -## Quick Start - -### 1. Add to your agent - -The fastest way to configure Agentation across any supported agent: - -```bash -npx add-mcp "npx -y agentation-mcp server" -``` - -Uses [add-mcp](https://github.com/neondatabase/add-mcp) to auto-detect installed agents (Claude Code, Cursor, Codex, Windsurf, and more). - -Or for Claude Code specifically: - -```bash -claude mcp add agentation -- npx agentation-mcp server -``` - - -### 2. Start the server - -```bash -agentation-mcp server -``` - -This starts both: -- **HTTP server** (port 4747) - receives annotations from the browser toolbar -- **MCP server** (stdio) - exposes tools for Claude Code - -### 3. Verify your setup - -```bash -agentation-mcp doctor -``` - -## CLI Commands - -```bash -agentation-mcp init # Setup wizard (registers via claude mcp add) -agentation-mcp server [options] # Start the annotation server -agentation-mcp doctor # Check your setup -agentation-mcp help # Show help -``` - -### Server Options - -```bash ---port # HTTP server port (default: 4747) ---mcp-only # Skip HTTP server, only run MCP on stdio ---http-url # HTTP server URL for MCP to fetch from -``` - -## MCP Tools - -The MCP server exposes these tools to AI agents: - -| Tool | Description | -|------|-------------| -| `agentation_list_sessions` | List all active annotation sessions | -| `agentation_get_session` | Get a session with all its annotations | -| `agentation_get_pending` | Get pending annotations for a session | -| `agentation_get_all_pending` | Get pending annotations across all sessions | -| `agentation_acknowledge` | Mark an annotation as acknowledged | -| `agentation_resolve` | Mark an annotation as resolved | -| `agentation_dismiss` | Dismiss an annotation with a reason | -| `agentation_reply` | Add a reply to an annotation thread | -| `agentation_watch_annotations` | Block until new annotations appear, then return batch | - -## HTTP API - -The HTTP server provides a REST API for the browser toolbar: - -### Sessions -- `POST /sessions` - Create a new session -- `GET /sessions` - List all sessions -- `GET /sessions/:id` - Get session with annotations - -### Annotations -- `POST /sessions/:id/annotations` - Add annotation -- `GET /annotations/:id` - Get annotation -- `PATCH /annotations/:id` - Update annotation -- `DELETE /annotations/:id` - Delete annotation -- `GET /sessions/:id/pending` - Get pending annotations -- `GET /pending` - Get all pending annotations - -### Events (SSE) -- `GET /sessions/:id/events` - Session event stream -- `GET /events` - Global event stream (optionally filter with `?domain=...`) - -### Health -- `GET /health` - Health check -- `GET /status` - Server status - -## Hands-Free Mode - -Use `agentation_watch_annotations` in a loop for automatic feedback processing -- the agent picks up new annotations as they're created: - -1. Agent calls `agentation_watch_annotations` (blocks until annotations appear) -2. Annotations arrive -- agent receives batch after collection window -3. Agent processes each annotation: - - `agentation_acknowledge` -- mark as seen - - Make code changes - - `agentation_resolve` -- mark as done with summary -4. Agent calls `agentation_watch_annotations` again (loop) - -Example CLAUDE.md instructions: - -```markdown -When I say "watch mode", call agentation_watch_annotations in a loop. -For each annotation: acknowledge it, make the fix, then resolve it with a summary. -Continue watching until I say stop or timeout is reached. -``` - -## Webhooks - -Configure webhooks to receive notifications when users request agent action: - -```bash -# Single webhook -export AGENTATION_WEBHOOK_URL=https://your-server.com/webhook - -# Multiple webhooks (comma-separated) -export AGENTATION_WEBHOOKS=https://server1.com/hook,https://server2.com/hook -``` - -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `AGENTATION_STORE` | Storage backend (`memory` or `sqlite`) | `sqlite` | -| `AGENTATION_WEBHOOK_URL` | Single webhook URL | - | -| `AGENTATION_WEBHOOKS` | Comma-separated webhook URLs | - | -| `AGENTATION_EVENT_RETENTION_DAYS` | Days to keep events | `7` | - -## Programmatic Usage - -```typescript -import { startHttpServer, startMcpServer } from 'agentation-mcp'; - -// Start HTTP server on port 4747 -startHttpServer(4747); - -// Start MCP server (connects via stdio) -await startMcpServer('http://localhost:4747'); -``` - -## Storage - -By default, data is persisted to SQLite at `~/.agentation/store.db`. To use in-memory storage: - -```bash -AGENTATION_STORE=memory agentation-mcp server -``` - -## License - -PolyForm Shield 1.0.0 diff --git a/mcp/package.json b/mcp/package.json deleted file mode 100644 index f9fd12f4..00000000 --- a/mcp/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "agentation-mcp", - "version": "1.2.0", - "description": "MCP server for Agentation - visual feedback for AI coding agents", - "license": "PolyForm-Shield-1.0.0", - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "bin": { - "agentation-mcp": "./dist/cli.js" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsup", - "watch": "tsup --watch", - "dev": "pnpm build && pnpm watch", - "start": "pnpm build && node dist/cli.js server", - "test": "vitest run", - "test:watch": "vitest", - "prepublishOnly": "pnpm build" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "better-sqlite3": "^12.6.2", - "zod": "^3.23.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^20.0.0", - "tsup": "^8.0.0", - "typescript": "^5.0.0", - "vitest": "^2.1.9" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml deleted file mode 100644 index e5f9420c..00000000 --- a/mcp/pnpm-lock.yaml +++ /dev/null @@ -1,1910 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.25.3(hono@4.11.5)(zod@3.25.76) - better-sqlite3: - specifier: ^12.6.2 - version: 12.6.2 - zod: - specifier: ^3.23.0 - version: 3.25.76 - devDependencies: - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 - '@types/node': - specifier: ^20.0.0 - version: 20.19.30 - tsup: - specifier: ^8.0.0 - version: 8.5.1(typescript@5.9.3) - typescript: - specifier: ^5.0.0 - version: 5.9.3 - -packages: - - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@modelcontextprotocol/sdk@1.25.3': - resolution: {integrity: sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - - '@rollup/rollup-android-arm-eabi@4.56.0': - resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.56.0': - resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.56.0': - resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.56.0': - resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.56.0': - resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.56.0': - resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.56.0': - resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.56.0': - resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.56.0': - resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.56.0': - resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.56.0': - resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.56.0': - resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.56.0': - resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.56.0': - resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.56.0': - resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.56.0': - resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.56.0': - resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.56.0': - resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.56.0': - resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.56.0': - resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.56.0': - resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.56.0': - resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.56.0': - resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.56.0': - resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.56.0': - resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} - cpu: [x64] - os: [win32] - - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/node@20.19.30': - resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - better-sqlite3@12.6.2: - resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} - hasBin: true - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - - express-rate-limit@7.5.1: - resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hono@4.11.5: - resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} - engines: {node: '>=16.9.0'} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - node-abi@3.87.0: - resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} - engines: {node: '>=10'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - hasBin: true - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} - engines: {node: '>=0.6'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - rollup@4.56.0: - resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} - peerDependencies: - zod: ^3.25 || ^4 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.27.2': - optional: true - - '@esbuild/android-arm64@0.27.2': - optional: true - - '@esbuild/android-arm@0.27.2': - optional: true - - '@esbuild/android-x64@0.27.2': - optional: true - - '@esbuild/darwin-arm64@0.27.2': - optional: true - - '@esbuild/darwin-x64@0.27.2': - optional: true - - '@esbuild/freebsd-arm64@0.27.2': - optional: true - - '@esbuild/freebsd-x64@0.27.2': - optional: true - - '@esbuild/linux-arm64@0.27.2': - optional: true - - '@esbuild/linux-arm@0.27.2': - optional: true - - '@esbuild/linux-ia32@0.27.2': - optional: true - - '@esbuild/linux-loong64@0.27.2': - optional: true - - '@esbuild/linux-mips64el@0.27.2': - optional: true - - '@esbuild/linux-ppc64@0.27.2': - optional: true - - '@esbuild/linux-riscv64@0.27.2': - optional: true - - '@esbuild/linux-s390x@0.27.2': - optional: true - - '@esbuild/linux-x64@0.27.2': - optional: true - - '@esbuild/netbsd-arm64@0.27.2': - optional: true - - '@esbuild/netbsd-x64@0.27.2': - optional: true - - '@esbuild/openbsd-arm64@0.27.2': - optional: true - - '@esbuild/openbsd-x64@0.27.2': - optional: true - - '@esbuild/openharmony-arm64@0.27.2': - optional: true - - '@esbuild/sunos-x64@0.27.2': - optional: true - - '@esbuild/win32-arm64@0.27.2': - optional: true - - '@esbuild/win32-ia32@0.27.2': - optional: true - - '@esbuild/win32-x64@0.27.2': - optional: true - - '@hono/node-server@1.19.9(hono@4.11.5)': - dependencies: - hono: 4.11.5 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@modelcontextprotocol/sdk@1.25.3(hono@4.11.5)(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.9(hono@4.11.5) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 7.5.1(express@5.2.1) - jose: 6.1.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - hono - - supports-color - - '@rollup/rollup-android-arm-eabi@4.56.0': - optional: true - - '@rollup/rollup-android-arm64@4.56.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.56.0': - optional: true - - '@rollup/rollup-darwin-x64@4.56.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.56.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.56.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.56.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.56.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.56.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.56.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.56.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.56.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.56.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.56.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.56.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.56.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.56.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.56.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.56.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.56.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.56.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.56.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.56.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.56.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.56.0': - optional: true - - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 20.19.30 - - '@types/estree@1.0.8': {} - - '@types/node@20.19.30': - dependencies: - undici-types: 6.21.0 - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - acorn@8.15.0: {} - - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - any-promise@1.3.0: {} - - base64-js@1.5.1: {} - - better-sqlite3@12.6.2: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.14.1 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - bundle-require@5.1.0(esbuild@0.27.2): - dependencies: - esbuild: 0.27.2 - load-tsconfig: 0.2.5 - - bytes@3.1.2: {} - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chownr@1.1.4: {} - - commander@4.1.1: {} - - confbox@0.1.8: {} - - consola@3.4.2: {} - - content-disposition@1.0.1: {} - - content-type@1.0.5: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - deep-extend@0.6.0: {} - - depd@2.0.0: {} - - detect-libc@2.1.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ee-first@1.1.1: {} - - encodeurl@2.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - esbuild@0.27.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - - escape-html@1.0.3: {} - - etag@1.8.1: {} - - eventsource-parser@3.0.6: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - - expand-template@2.0.3: {} - - express-rate-limit@7.5.1(express@5.2.1): - dependencies: - express: 5.2.1 - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.14.1 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-uri@3.1.0: {} - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - file-uri-to-path@1.0.0: {} - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.56.0 - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fs-constants@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - github-from-package@0.0.0: {} - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hono@4.11.5: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - inherits@2.0.4: {} - - ini@1.3.8: {} - - ipaddr.js@1.9.1: {} - - is-promise@4.0.0: {} - - isexe@2.0.0: {} - - jose@6.1.3: {} - - joycon@3.1.1: {} - - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - math-intrinsics@1.1.0: {} - - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-response@3.1.0: {} - - minimist@1.2.8: {} - - mkdirp-classic@0.5.3: {} - - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - napi-build-utils@2.0.0: {} - - negotiator@1.0.0: {} - - node-abi@3.87.0: - dependencies: - semver: 7.7.3 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - parseurl@1.3.3: {} - - path-key@3.1.1: {} - - path-to-regexp@8.3.0: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - pirates@4.0.7: {} - - pkce-challenge@5.0.1: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - postcss-load-config@6.0.1: - dependencies: - lilconfig: 3.1.3 - - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.87.0 - pump: 3.0.3 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - qs@6.14.1: - dependencies: - side-channel: 1.1.0 - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdirp@4.1.2: {} - - require-from-string@2.0.2: {} - - resolve-from@5.0.0: {} - - rollup@4.56.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.56.0 - '@rollup/rollup-android-arm64': 4.56.0 - '@rollup/rollup-darwin-arm64': 4.56.0 - '@rollup/rollup-darwin-x64': 4.56.0 - '@rollup/rollup-freebsd-arm64': 4.56.0 - '@rollup/rollup-freebsd-x64': 4.56.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.56.0 - '@rollup/rollup-linux-arm-musleabihf': 4.56.0 - '@rollup/rollup-linux-arm64-gnu': 4.56.0 - '@rollup/rollup-linux-arm64-musl': 4.56.0 - '@rollup/rollup-linux-loong64-gnu': 4.56.0 - '@rollup/rollup-linux-loong64-musl': 4.56.0 - '@rollup/rollup-linux-ppc64-gnu': 4.56.0 - '@rollup/rollup-linux-ppc64-musl': 4.56.0 - '@rollup/rollup-linux-riscv64-gnu': 4.56.0 - '@rollup/rollup-linux-riscv64-musl': 4.56.0 - '@rollup/rollup-linux-s390x-gnu': 4.56.0 - '@rollup/rollup-linux-x64-gnu': 4.56.0 - '@rollup/rollup-linux-x64-musl': 4.56.0 - '@rollup/rollup-openbsd-x64': 4.56.0 - '@rollup/rollup-openharmony-arm64': 4.56.0 - '@rollup/rollup-win32-arm64-msvc': 4.56.0 - '@rollup/rollup-win32-ia32-msvc': 4.56.0 - '@rollup/rollup-win32-x64-gnu': 4.56.0 - '@rollup/rollup-win32-x64-msvc': 4.56.0 - fsevents: 2.3.3 - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.3.0 - transitivePeerDependencies: - - supports-color - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - semver@7.7.3: {} - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - - source-map@0.7.6: {} - - statuses@2.0.2: {} - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-json-comments@2.0.1: {} - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 - - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.3 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinyexec@0.3.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - toidentifier@1.0.1: {} - - tree-kill@1.2.2: {} - - ts-interface-checker@0.1.13: {} - - tsup@8.5.1(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.2) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.2 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1 - resolve-from: 5.0.0 - rollup: 4.56.0 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - ufo@1.6.3: {} - - undici-types@6.21.0: {} - - unpipe@1.0.0: {} - - util-deprecate@1.0.2: {} - - vary@1.1.2: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrappy@1.0.2: {} - - zod-to-json-schema@3.25.1(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod@3.25.76: {} diff --git a/mcp/src/__tests__/thread-replies.test.ts b/mcp/src/__tests__/thread-replies.test.ts deleted file mode 100644 index ada2c58d..00000000 --- a/mcp/src/__tests__/thread-replies.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Tests that verify agents can discover human thread replies. - * - * Problem: When a human replies to an annotation thread, the agent cannot see it because: - * 1. The SSE watcher (agentation_watch_annotations) only listens for "annotation.created" events, - * ignoring "thread.message" events entirely. - * 2. agentation_get_pending filters by status="pending", so once an annotation is acknowledged, - * the human's reply to the thread is invisible to the agent. - */ - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createServer, type Server as HttpServer } from "http"; -import { - createSession, - addAnnotation, - addThreadMessage, - clearAll, - getAnnotationsNeedingAttention, -} from "../server/store.js"; -import { eventBus } from "../server/events.js"; -import { handleTool, setHttpBaseUrl } from "../server/mcp.js"; -import type { Annotation, AFSEvent } from "../types.js"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -let httpServer: HttpServer; -let baseUrl: string; - -/** - * Minimal HTTP server that serves just what the MCP tools need. - * We re-use the real store + eventBus so that SSE events fire naturally. - */ -function startTestServer(): Promise<{ server: HttpServer; port: number }> { - return new Promise((resolve) => { - const server = createServer(async (req, res) => { - const url = new URL(req.url || "/", "http://localhost"); - const pathname = url.pathname; - const method = req.method || "GET"; - - // CORS - res.setHeader("Access-Control-Allow-Origin", "*"); - - // Parse body helper - const parseBody = (): Promise> => - new Promise((resolve) => { - let body = ""; - req.on("data", (chunk: Buffer) => (body += chunk)); - req.on("end", () => resolve(body ? JSON.parse(body) : {})); - }); - - // GET /sessions/:id/pending - const pendingMatch = pathname.match(/^\/sessions\/([^/]+)\/pending$/); - if (method === "GET" && pendingMatch) { - const pending = getAnnotationsNeedingAttention(pendingMatch[1]); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ count: pending.length, annotations: pending })); - return; - } - - // GET /pending (all pending) - if (method === "GET" && pathname === "/pending") { - const { listSessions } = await import("../server/store.js"); - const sessions = listSessions(); - const allPending = sessions.flatMap((s) => getAnnotationsNeedingAttention(s.id)); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ count: allPending.length, annotations: allPending })); - return; - } - - // GET /sessions/:id/events (SSE) - const eventsMatch = pathname.match(/^\/sessions\/([^/]+)\/events$/); - if (method === "GET" && eventsMatch) { - const sessionId = eventsMatch[1]; - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - res.write(": connected\n\n"); - - const unsubscribe = eventBus.subscribeToSession(sessionId, (event: AFSEvent) => { - res.write(`data: ${JSON.stringify(event)}\n\n`); - }); - - req.on("close", () => unsubscribe()); - return; - } - - // GET /events (global SSE) - if (method === "GET" && pathname === "/events") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - res.write(": connected\n\n"); - - const unsubscribe = eventBus.subscribe((event: AFSEvent) => { - res.write(`data: ${JSON.stringify(event)}\n\n`); - }); - - req.on("close", () => unsubscribe()); - return; - } - - // PATCH /annotations/:id - const patchMatch = pathname.match(/^\/annotations\/([^/]+)$/); - if (method === "PATCH" && patchMatch) { - const { updateAnnotation, getAnnotation } = await import("../server/store.js"); - const body = await parseBody(); - const annotation = updateAnnotation(patchMatch[1], body); - if (!annotation) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Not found" })); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(annotation)); - return; - } - - // POST /annotations/:id/thread - const threadMatch = pathname.match(/^\/annotations\/([^/]+)\/thread$/); - if (method === "POST" && threadMatch) { - const body = await parseBody(); - const annotation = addThreadMessage( - threadMatch[1], - body.role as "human" | "agent", - body.content as string - ); - if (!annotation) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Not found" })); - return; - } - res.writeHead(201, { "Content-Type": "application/json" }); - res.end(JSON.stringify(annotation)); - return; - } - - // GET /sessions/:id - const sessionMatch = pathname.match(/^\/sessions\/([^/]+)$/); - if (method === "GET" && sessionMatch) { - const { getSessionWithAnnotations } = await import("../server/store.js"); - const session = getSessionWithAnnotations(sessionMatch[1]); - if (!session) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Not found" })); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(session)); - return; - } - - res.writeHead(404); - res.end(); - }); - - server.listen(0, () => { - const addr = server.address() as { port: number }; - resolve({ server, port: addr.port }); - }); - }); -} - -/** - * Parse the JSON content from a tool result. - */ -function parseResult(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) { - return JSON.parse(result.content[0].text); -} - -// --------------------------------------------------------------------------- -// Setup / Teardown -// --------------------------------------------------------------------------- - -beforeEach(async () => { - // Force in-memory store - process.env.AGENTATION_STORE = "memory"; - - // Clear store state between tests - clearAll(); - - // Start test HTTP server - const { server, port } = await startTestServer(); - httpServer = server; - baseUrl = `http://localhost:${port}`; - setHttpBaseUrl(baseUrl); -}); - -afterEach(async () => { - await new Promise((resolve) => httpServer.close(() => resolve())); - clearAll(); -}); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("agent visibility of human thread replies", () => { - it("human reply to acknowledged annotation is visible via get_pending or a dedicated tool", async () => { - // Setup: create a session and annotation - const session = createSession("http://example.com"); - const annotation = addAnnotation(session.id, { - x: 10, - y: 20, - comment: "Fix this button", - element: "button", - elementPath: "body > button", - timestamp: Date.now(), - })!; - - // Agent acknowledges the annotation - await handleTool("agentation_acknowledge", { annotationId: annotation.id }); - - // Human replies to the acknowledged annotation - addThreadMessage(annotation.id, "human", "Actually, also fix the color"); - - // The agent should be able to discover this reply. - // Currently: get_pending only returns status="pending" annotations, so it misses this. - // After fix: there should be a way for the agent to see annotations with unread human replies. - const result = await handleTool("agentation_get_pending", { sessionId: session.id }); - const data = parseResult(result); - - // The annotation has an unread human reply - it should appear in the results - // even though its status is "acknowledged" (not "pending") - const hasAnnotationWithReply = data.annotations.some( - (a: Annotation) => a.id === annotation.id - ); - - expect(hasAnnotationWithReply).toBe(true); - }); - - it("watch_annotations detects thread.message events from humans", { timeout: 15000 }, async () => { - // Setup: create a session and an acknowledged annotation - const session = createSession("http://example.com"); - const annotation = addAnnotation(session.id, { - x: 10, - y: 20, - comment: "Fix this button", - element: "button", - elementPath: "body > button", - timestamp: Date.now(), - })!; - - // Acknowledge the annotation first - await handleTool("agentation_acknowledge", { annotationId: annotation.id }); - - // Start watching - use a short timeout so test doesn't hang - const watchPromise = handleTool("agentation_watch_annotations", { - sessionId: session.id, - batchWindowSeconds: 1, - timeoutSeconds: 3, - }); - - // Wait for SSE connection to establish - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Human replies to the thread - addThreadMessage(annotation.id, "human", "Please also fix the hover state"); - - // The watcher should pick up the thread.message event - const result = await watchPromise; - const data = parseResult(result); - - // Should NOT have timed out - should have detected the thread reply - expect(data.timeout).not.toBe(true); - }); - - it("human reply to acknowledged annotation includes thread messages in response", async () => { - // Setup: create session, annotation, acknowledge it, then human replies - const session = createSession("http://example.com"); - const annotation = addAnnotation(session.id, { - x: 10, - y: 20, - comment: "Fix this button", - element: "button", - elementPath: "body > button", - timestamp: Date.now(), - })!; - - await handleTool("agentation_acknowledge", { annotationId: annotation.id }); - addThreadMessage(annotation.id, "human", "Wait, also fix the color"); - - // Agent should be able to see the annotation with thread via get_session - const sessionResult = await handleTool("agentation_get_session", { - sessionId: session.id, - }); - const sessionData = parseResult(sessionResult); - - // The thread messages should be visible on the annotation - const ann = sessionData.annotations.find((a: Annotation) => a.id === annotation.id); - expect(ann).toBeDefined(); - expect(ann.thread).toBeDefined(); - expect(ann.thread.length).toBeGreaterThanOrEqual(1); - expect(ann.thread.some((m: { role: string; content: string }) => m.role === "human")).toBe(true); - }); - - it("multiple human replies all surface to the agent", async () => { - const session = createSession("http://example.com"); - const annotation = addAnnotation(session.id, { - x: 10, - y: 20, - comment: "Fix this button", - element: "button", - elementPath: "body > button", - timestamp: Date.now(), - })!; - - // Agent acknowledges - await handleTool("agentation_acknowledge", { annotationId: annotation.id }); - - // Human sends multiple replies - addThreadMessage(annotation.id, "human", "First follow-up"); - addThreadMessage(annotation.id, "human", "Second follow-up"); - - // Agent should discover the annotation has unread replies - const result = await handleTool("agentation_get_pending", { sessionId: session.id }); - const data = parseResult(result); - - const ann = data.annotations.find((a: Annotation) => a.id === annotation.id); - expect(ann).toBeDefined(); - }); - - it("agent reply does not re-surface annotation as needing attention", async () => { - const session = createSession("http://example.com"); - const annotation = addAnnotation(session.id, { - x: 10, - y: 20, - comment: "Fix this button", - element: "button", - elementPath: "body > button", - timestamp: Date.now(), - })!; - - // Agent acknowledges, then agent replies (not human) - await handleTool("agentation_acknowledge", { annotationId: annotation.id }); - addThreadMessage(annotation.id, "agent", "Working on it"); - - // Agent's own reply should NOT make this appear as needing attention - const result = await handleTool("agentation_get_pending", { sessionId: session.id }); - const data = parseResult(result); - - const ann = data.annotations.find((a: Annotation) => a.id === annotation.id); - expect(ann).toBeUndefined(); - }); - - it("resolved annotation with human reply resurfaces for agent attention", async () => { - const session = createSession("http://example.com"); - const annotation = addAnnotation(session.id, { - x: 10, - y: 20, - comment: "Fix this button", - element: "button", - elementPath: "body > button", - timestamp: Date.now(), - })!; - - // Agent resolves the annotation - await handleTool("agentation_resolve", { - annotationId: annotation.id, - summary: "Fixed the button", - }); - - // Human replies after resolution (disagreeing or adding context) - addThreadMessage(annotation.id, "human", "This still doesn't look right"); - - // The annotation should resurface for agent attention - const result = await handleTool("agentation_get_pending", { sessionId: session.id }); - const data = parseResult(result); - - const ann = data.annotations.find((a: Annotation) => a.id === annotation.id); - expect(ann).toBeDefined(); - }); -}); diff --git a/mcp/src/cli.ts b/mcp/src/cli.ts deleted file mode 100644 index bc5115b8..00000000 --- a/mcp/src/cli.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Agentation MCP CLI - * - * Usage: - * agentation-mcp server [--port 4747] - * agentation-mcp init - * agentation-mcp doctor - */ - -import * as readline from "readline"; -import * as fs from "fs"; -import * as path from "path"; -import { spawn } from "child_process"; - -const command = process.argv[2]; - -// ============================================================================ -// INIT COMMAND - Interactive setup wizard -// ============================================================================ - -async function runInit() { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const question = (q: string): Promise => - new Promise((resolve) => rl.question(q, resolve)); - - console.log(` -╔═══════════════════════════════════════════════════════════════╗ -║ Agentation MCP Setup Wizard ║ -╚═══════════════════════════════════════════════════════════════╝ -`); - - // Step 1: Check Claude Code config - const homeDir = process.env.HOME || process.env.USERPROFILE || ""; - const claudeConfigPath = path.join(homeDir, ".claude.json"); - const hasClaudeConfig = fs.existsSync(claudeConfigPath); - - if (hasClaudeConfig) { - console.log(`✓ Found Claude Code config at ${claudeConfigPath}`); - } else { - console.log(`○ No Claude Code config found at ${claudeConfigPath}`); - } - console.log(); - - // Step 2: Ask about MCP server - console.log(`The Agentation MCP server allows Claude Code to receive`); - console.log(`real-time annotations and respond to feedback.`); - console.log(); - - const setupMcp = await question(`Set up MCP server integration? [Y/n] `); - const wantsMcp = setupMcp.toLowerCase() !== "n"; - - if (wantsMcp) { - let port = 4747; - const portAnswer = await question(`HTTP server port [4747]: `); - if (portAnswer && !isNaN(parseInt(portAnswer, 10))) { - port = parseInt(portAnswer, 10); - } - - // Register MCP server using claude mcp add - const mcpArgs = port === 4747 - ? ["mcp", "add", "agentation", "--", "npx", "agentation-mcp", "server"] - : ["mcp", "add", "agentation", "--", "npx", "agentation-mcp", "server", "--port", String(port)]; - - console.log(); - console.log(`Running: claude ${mcpArgs.join(" ")}`); - - try { - const result = spawn("claude", mcpArgs, { stdio: "inherit" }); - await new Promise((resolve, reject) => { - result.on("close", (code) => { - if (code === 0) resolve(); - else reject(new Error(`claude mcp add exited with code ${code}`)); - }); - result.on("error", reject); - }); - console.log(`✓ Registered agentation MCP server with Claude Code`); - } catch (err) { - console.log(`✗ Could not register MCP server automatically: ${err}`); - console.log(` You can register manually by running:`); - console.log(` claude mcp add agentation -- npx agentation-mcp server`); - } - console.log(); - - // Test connection - const testNow = await question(`Start server and test connection? [Y/n] `); - if (testNow.toLowerCase() !== "n") { - console.log(); - console.log(`Starting server on port ${port}...`); - - // Start server in background - const server = spawn("agentation-mcp", ["server", "--port", String(port)], { - stdio: "inherit", - detached: false, - }); - - // Wait a moment for server to start - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Test health endpoint - try { - const response = await fetch(`http://localhost:${port}/health`); - if (response.ok) { - console.log(); - console.log(`✓ Server is running on http://localhost:${port}`); - console.log(`✓ MCP tools available to Claude Code`); - console.log(); - console.log(`Press Ctrl+C to stop the server.`); - - // Keep running - await new Promise(() => {}); - } else { - console.log(`✗ Server health check failed: ${response.status}`); - server.kill(); - } - } catch (err) { - console.log(`✗ Could not connect to server: ${err}`); - server.kill(); - } - } - } - - console.log(); - console.log(`Setup complete! Run 'agentation-mcp doctor' to verify your setup.`); - rl.close(); -} - -// ============================================================================ -// DOCTOR COMMAND - Diagnostic checks -// ============================================================================ - -async function runDoctor() { - console.log(` -╔═══════════════════════════════════════════════════════════════╗ -║ Agentation MCP Doctor ║ -╚═══════════════════════════════════════════════════════════════╝ -`); - - let allPassed = true; - const results: Array<{ name: string; status: "pass" | "fail" | "warn"; message: string }> = []; - - // Check 1: Node version - const nodeVersion = process.version; - const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0], 10); - if (majorVersion >= 18) { - results.push({ name: "Node.js", status: "pass", message: `${nodeVersion} (18+ required)` }); - } else { - results.push({ name: "Node.js", status: "fail", message: `${nodeVersion} (18+ required)` }); - allPassed = false; - } - - // Check 2: Claude Code config - const homeDir = process.env.HOME || process.env.USERPROFILE || ""; - const claudeConfigPath = path.join(homeDir, ".claude.json"); - if (fs.existsSync(claudeConfigPath)) { - try { - const config = JSON.parse(fs.readFileSync(claudeConfigPath, "utf-8")); - // Check top-level and per-project mcpServers for agentation - let found = false; - if (config.mcpServers?.agentation) { - found = true; - } - // Also check per-project entries - if (!found && config.projects) { - for (const proj of Object.values(config.projects) as Record[]) { - if ((proj as { mcpServers?: { agentation?: unknown } }).mcpServers?.agentation) { - found = true; - break; - } - } - } - if (found) { - results.push({ name: "Claude Code config", status: "pass", message: "MCP server configured" }); - } else { - results.push({ name: "Claude Code config", status: "warn", message: "Config exists but no agentation MCP entry. Run: claude mcp add agentation -- npx agentation-mcp server" }); - } - } catch { - results.push({ name: "Claude Code config", status: "fail", message: "Could not parse config file" }); - allPassed = false; - } - } else { - results.push({ name: "Claude Code config", status: "warn", message: "No config found at ~/.claude.json. Run: claude mcp add agentation -- npx agentation-mcp server" }); - } - - // Check 3: Stale config at old (wrong) path - const oldConfigPath = path.join(homeDir, ".claude", "claude_code_config.json"); - if (fs.existsSync(oldConfigPath)) { - results.push({ name: "Stale config", status: "warn", message: `${oldConfigPath} exists but Claude Code doesn't read this file. Safe to delete.` }); - } - - // Check 4: Server connectivity (try default port) - try { - const response = await fetch("http://localhost:4747/health", { signal: AbortSignal.timeout(2000) }); - if (response.ok) { - results.push({ name: "Server (port 4747)", status: "pass", message: "Running and healthy" }); - } else { - results.push({ name: "Server (port 4747)", status: "warn", message: `Responded with ${response.status}` }); - } - } catch { - results.push({ name: "Server (port 4747)", status: "warn", message: "Not running (start with: agentation-mcp server)" }); - } - - // Print results - for (const r of results) { - const icon = r.status === "pass" ? "✓" : r.status === "fail" ? "✗" : "○"; - const color = r.status === "pass" ? "\x1b[32m" : r.status === "fail" ? "\x1b[31m" : "\x1b[33m"; - console.log(`${color}${icon}\x1b[0m ${r.name}: ${r.message}`); - } - - console.log(); - if (allPassed) { - console.log(`All checks passed!`); - } else { - console.log(`Some checks failed. Run 'agentation-mcp init' to fix.`); - process.exit(1); - } -} - -// ============================================================================ -// COMMAND ROUTER -// ============================================================================ - -if (command === "init") { - runInit().catch((err) => { - console.error("Init failed:", err); - process.exit(1); - }); -} else if (command === "doctor") { - runDoctor().catch((err) => { - console.error("Doctor failed:", err); - process.exit(1); - }); -} else if (command === "server") { - // Dynamic import to avoid loading server code for other commands - import("./server/index.js").then(({ startHttpServer, startMcpServer, setApiKey }) => { - const args = process.argv.slice(3); - let port = 4747; - let mcpOnly = false; - let httpUrl = "http://localhost:4747"; - let apiKeyArg: string | undefined; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--port" && args[i + 1]) { - const parsed = parseInt(args[i + 1], 10); - if (!isNaN(parsed) && parsed > 0 && parsed < 65536) { - port = parsed; - if (!args.includes("--http-url")) { - httpUrl = `http://localhost:${port}`; - } - } - i++; - } - if (args[i] === "--mcp-only") { - mcpOnly = true; - } - if (args[i] === "--http-url" && args[i + 1]) { - httpUrl = args[i + 1]; - i++; - } - if (args[i] === "--api-key" && args[i + 1]) { - apiKeyArg = args[i + 1]; - i++; - } - } - - // API key from flag or environment variable - const apiKey = apiKeyArg || process.env.AGENTATION_API_KEY; - if (apiKey) { - setApiKey(apiKey); - } - - if (!mcpOnly) { - startHttpServer(port, apiKey); - } - startMcpServer(httpUrl).catch((err) => { - console.error("MCP server error:", err); - process.exit(1); - }); - }); -} else if (command === "help" || command === "--help" || command === "-h" || !command) { - console.log(` -agentation-mcp - MCP server for Agentation visual feedback - -Usage: - agentation-mcp init Interactive setup wizard - agentation-mcp server [options] Start the annotation server - agentation-mcp doctor Check your setup and diagnose issues - agentation-mcp help Show this help message - -Server Options: - --port HTTP server port (default: 4747) - --mcp-only Skip HTTP server, only run MCP on stdio - --http-url HTTP server URL for MCP to fetch from - --api-key API key for cloud storage (or set AGENTATION_API_KEY env var) - -Commands: - init Guided setup that configures Claude Code to use the MCP server. - Registers the server via 'claude mcp add'. - - server Starts both an HTTP server and MCP server for collecting annotations. - The HTTP server receives annotations from the React component. - The MCP server exposes tools for Claude Code to read/act on annotations. - - doctor Runs diagnostic checks on your setup: - - Node.js version - - Claude Code configuration - - Server connectivity - -Examples: - agentation-mcp init Set up Agentation MCP - agentation-mcp server Start server on default port 4747 - agentation-mcp server --port 8080 Start server on port 8080 - agentation-mcp doctor Check if everything is configured correctly - - # Use cloud storage with API key (local server proxies to cloud) - agentation-mcp server --api-key ag_xxx - - # Or using environment variable - AGENTATION_API_KEY=ag_xxx agentation-mcp server -`); -} else { - console.error(`Unknown command: ${command}`); - console.error("Run 'agentation-mcp help' for usage information."); - process.exit(1); -} diff --git a/mcp/src/index.ts b/mcp/src/index.ts deleted file mode 100644 index 8ed1eb7b..00000000 --- a/mcp/src/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Agentation MCP Server - * - * This package provides an MCP server for AI coding agents to interact - * with web page annotations from the Agentation toolbar. - */ - -// Re-export server functions -export { startHttpServer, startMcpServer } from "./server/index.js"; - -// Re-export store functions -export { - getStore, - store, - createSession, - getSession, - getSessionWithAnnotations, - updateSessionStatus, - listSessions, - addAnnotation, - getAnnotation, - updateAnnotation, - updateAnnotationStatus, - addThreadMessage, - getPendingAnnotations, - getSessionAnnotations, - deleteAnnotation, - getEventsSince, - clearAll, -} from "./server/store.js"; - -// Re-export event bus -export { eventBus, userEventBus } from "./server/events.js"; - -// Re-export tenant store -export { - getTenantStore, - resetTenantStore, - hashApiKey, - isValidApiKeyFormat, - createUserContext, - createOrganization, - getOrganization, - createUser, - getUser, - getUserByEmail, - getUsersByOrg, - createApiKey, - getApiKeyByHash, - listApiKeys, - deleteApiKey, - updateApiKeyLastUsed, - createSessionForUser, - listSessionsForUser, - getSessionForUser, - getSessionWithAnnotationsForUser, - getPendingAnnotationsForUser, - getAllPendingForUser, -} from "./server/tenant-store.js"; -export type { TenantStore } from "./server/tenant-store.js"; - -// Re-export types -export type { - Annotation, - AnnotationIntent, - AnnotationSeverity, - AnnotationStatus, - Session, - SessionStatus, - SessionWithAnnotations, - ThreadMessage, - AFSEventType, - ActionRequest, - AFSEvent, - AFSStore, - // Multi-tenant types - Organization, - User, - UserRole, - ApiKey, - UserContext, -} from "./types.js"; diff --git a/mcp/src/server/events.ts b/mcp/src/server/events.ts deleted file mode 100644 index f1b7034b..00000000 --- a/mcp/src/server/events.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * EventBus for real-time event distribution. - * Coordinates SSE streams, MCP notifications, and future webhooks. - */ - -import type { AFSEvent, AFSEventType, Annotation, Session, ThreadMessage, ActionRequest } from "../types.js"; - -type EventHandler = (event: AFSEvent) => void; - -// Global sequence counter for event ordering -let globalSequence = 0; - -/** - * Simple pub/sub event bus for AFS events. - */ -class EventBus { - private handlers = new Set(); - private sessionHandlers = new Map>(); - - /** - * Subscribe to all events. - */ - subscribe(handler: EventHandler): () => void { - this.handlers.add(handler); - return () => this.handlers.delete(handler); - } - - /** - * Subscribe to events for a specific session. - */ - subscribeToSession(sessionId: string, handler: EventHandler): () => void { - if (!this.sessionHandlers.has(sessionId)) { - this.sessionHandlers.set(sessionId, new Set()); - } - this.sessionHandlers.get(sessionId)!.add(handler); - - return () => { - const handlers = this.sessionHandlers.get(sessionId); - if (handlers) { - handlers.delete(handler); - if (handlers.size === 0) { - this.sessionHandlers.delete(sessionId); - } - } - }; - } - - /** - * Emit an event to all subscribers. - */ - emit( - type: AFSEventType, - sessionId: string, - payload: Annotation | Session | ThreadMessage | ActionRequest - ): AFSEvent { - const event: AFSEvent = { - type, - timestamp: new Date().toISOString(), - sessionId, - sequence: ++globalSequence, - payload, - }; - - // Notify global subscribers - for (const handler of this.handlers) { - try { - handler(event); - } catch (err) { - console.error("[EventBus] Handler error:", err); - } - } - - // Notify session-specific subscribers - const sessionHandlers = this.sessionHandlers.get(sessionId); - if (sessionHandlers) { - for (const handler of sessionHandlers) { - try { - handler(event); - } catch (err) { - console.error("[EventBus] Session handler error:", err); - } - } - } - - return event; - } - - /** - * Get current sequence number (for reconnect logic). - */ - getSequence(): number { - return globalSequence; - } - - /** - * Set sequence from persisted state (for server restart). - */ - setSequence(seq: number): void { - globalSequence = seq; - } -} - -// Singleton instance -export const eventBus = new EventBus(); - -// ----------------------------------------------------------------------------- -// User-Scoped Event Bus -// ----------------------------------------------------------------------------- - -/** - * User-scoped event bus that filters events by user ID. - * Prevents data leakage between users in multi-tenant environments. - */ -class UserEventBus { - private userHandlers = new Map>(); - private userSessionHandlers = new Map>>(); - - /** - * Subscribe to all events for a specific user. - */ - subscribeForUser(userId: string, handler: EventHandler): () => void { - if (!this.userHandlers.has(userId)) { - this.userHandlers.set(userId, new Set()); - } - this.userHandlers.get(userId)!.add(handler); - - return () => { - const handlers = this.userHandlers.get(userId); - if (handlers) { - handlers.delete(handler); - if (handlers.size === 0) { - this.userHandlers.delete(userId); - } - } - }; - } - - /** - * Subscribe to events for a specific session of a specific user. - */ - subscribeToSessionForUser( - userId: string, - sessionId: string, - handler: EventHandler - ): () => void { - if (!this.userSessionHandlers.has(userId)) { - this.userSessionHandlers.set(userId, new Map()); - } - const userSessions = this.userSessionHandlers.get(userId)!; - - if (!userSessions.has(sessionId)) { - userSessions.set(sessionId, new Set()); - } - userSessions.get(sessionId)!.add(handler); - - return () => { - const userSessions = this.userSessionHandlers.get(userId); - if (userSessions) { - const handlers = userSessions.get(sessionId); - if (handlers) { - handlers.delete(handler); - if (handlers.size === 0) { - userSessions.delete(sessionId); - } - } - if (userSessions.size === 0) { - this.userSessionHandlers.delete(userId); - } - } - }; - } - - /** - * Emit an event scoped to a specific user. - * Only handlers for that user will receive the event. - */ - emitForUser( - userId: string, - type: AFSEventType, - sessionId: string, - payload: Annotation | Session | ThreadMessage | ActionRequest - ): AFSEvent { - const event: AFSEvent = { - type, - timestamp: new Date().toISOString(), - sessionId, - sequence: ++globalSequence, - payload, - }; - - // Notify user-specific global subscribers - const userHandlers = this.userHandlers.get(userId); - if (userHandlers) { - for (const handler of userHandlers) { - try { - handler(event); - } catch (err) { - console.error("[UserEventBus] Handler error:", err); - } - } - } - - // Notify user-specific session subscribers - const userSessions = this.userSessionHandlers.get(userId); - if (userSessions) { - const sessionHandlers = userSessions.get(sessionId); - if (sessionHandlers) { - for (const handler of sessionHandlers) { - try { - handler(event); - } catch (err) { - console.error("[UserEventBus] Session handler error:", err); - } - } - } - } - - return event; - } - - /** - * Check if a user has any active listeners. - */ - hasListenersForUser(userId: string): boolean { - const hasGlobal = this.userHandlers.has(userId) && this.userHandlers.get(userId)!.size > 0; - const hasSessions = this.userSessionHandlers.has(userId) && this.userSessionHandlers.get(userId)!.size > 0; - return hasGlobal || hasSessions; - } - - /** - * Get count of listeners for a user. - */ - getListenerCountForUser(userId: string): number { - let count = 0; - const handlers = this.userHandlers.get(userId); - if (handlers) count += handlers.size; - - const sessions = this.userSessionHandlers.get(userId); - if (sessions) { - for (const sessionHandlers of sessions.values()) { - count += sessionHandlers.size; - } - } - return count; - } -} - -// Singleton instance for user-scoped events -export const userEventBus = new UserEventBus(); diff --git a/mcp/src/server/http.ts b/mcp/src/server/http.ts deleted file mode 100644 index 49dc629e..00000000 --- a/mcp/src/server/http.ts +++ /dev/null @@ -1,1010 +0,0 @@ -/** - * HTTP server for the Agentation API. - * Uses native Node.js http module - no frameworks. - */ - -import { createServer, type IncomingMessage, type ServerResponse } from "http"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { TOOLS, handleTool, error as toolError } from "./mcp.js"; -import { - createSession, - getSession, - getSessionWithAnnotations, - addAnnotation, - updateAnnotation, - getAnnotation, - deleteAnnotation, - listSessions, - getPendingAnnotations, - getAnnotationsNeedingAttention, - addThreadMessage, - getEventsSince, -} from "./store.js"; -import { eventBus } from "./events.js"; -import type { Annotation, AFSEvent, ActionRequest } from "../types.js"; - -/** - * Log to stderr so diagnostic output never corrupts the MCP stdio channel. - * When `server` runs without --mcp-only, both the HTTP server and MCP stdio - * server share the same process. stdout is reserved for JSON-RPC messages, - * so all logging must go to stderr. - */ -function log(message: string): void { - process.stderr.write(message + "\n"); -} - -// Cloud API configuration -let cloudApiKey: string | undefined; -const CLOUD_API_URL = "https://agentation-mcp-cloud.vercel.app/api"; - -/** - * Set the API key for cloud storage mode. - * When set, the HTTP server proxies requests to the cloud API. - */ -export function setCloudApiKey(key: string | undefined): void { - cloudApiKey = key; -} - -/** - * Check if we're in cloud mode (API key is set). - */ -function isCloudMode(): boolean { - return !!cloudApiKey; -} - -// Track active SSE connections for cleanup -const sseConnections = new Set(); -// Track agent SSE connections separately (for accurate delivery status) -// These are connections from MCP tools (e.g. watch_annotations), not browser toolbars -const agentConnections = new Set(); - -// ----------------------------------------------------------------------------- -// MCP HTTP Transport -// ----------------------------------------------------------------------------- - -// Store transports by session ID for stateful sessions -const mcpTransports = new Map(); - -/** - * Initialize a new MCP server with HTTP transport for a session. - */ -function createMcpSession(): { server: Server; transport: StreamableHTTPServerTransport } { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - }); - - const server = new Server( - { name: "agentation", version: "0.0.1" }, - { capabilities: { tools: {} } } - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); - server.setRequestHandler(CallToolRequestSchema, async (req) => { - try { - return await handleTool(req.params.name, req.params.arguments); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return toolError(message); - } - }); - - server.connect(transport); - return { server, transport }; -} - -// ----------------------------------------------------------------------------- -// Webhook Support -// ----------------------------------------------------------------------------- - -/** - * Get configured webhook URLs from environment variables. - * - * Supports: - * - AGENTATION_WEBHOOK_URL: Single webhook URL - * - AGENTATION_WEBHOOKS: Comma-separated list of webhook URLs - */ -function getWebhookUrls(): string[] { - const urls: string[] = []; - - // Single webhook URL - const singleUrl = process.env.AGENTATION_WEBHOOK_URL; - if (singleUrl) { - urls.push(singleUrl.trim()); - } - - // Multiple webhook URLs (comma-separated) - const multipleUrls = process.env.AGENTATION_WEBHOOKS; - if (multipleUrls) { - const parsed = multipleUrls - .split(",") - .map((url) => url.trim()) - .filter((url) => url.length > 0); - urls.push(...parsed); - } - - return urls; -} - -/** - * Send webhook notification for an action request. - * Fire-and-forget: doesn't wait for response, logs errors but doesn't throw. - */ -function sendWebhooks(actionRequest: ActionRequest): void { - const webhookUrls = getWebhookUrls(); - - if (webhookUrls.length === 0) { - return; - } - - const payload = JSON.stringify(actionRequest); - - for (const url of webhookUrls) { - // Fire and forget - use .then().catch() instead of await - fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "Agentation-Webhook/1.0", - }, - body: payload, - }) - .then((res) => { - log( - `[Webhook] POST ${url} -> ${res.status} ${res.statusText}` - ); - }) - .catch((err) => { - console.error(`[Webhook] POST ${url} failed:`, (err as Error).message); - }); - } - - log( - `[Webhook] Fired ${webhookUrls.length} webhook(s) for session ${actionRequest.sessionId}` - ); -} - -// ----------------------------------------------------------------------------- -// Request Helpers -// ----------------------------------------------------------------------------- - -/** - * Parse JSON body from request. - */ -async function parseBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ""; - req.on("data", (chunk) => (body += chunk)); - req.on("end", () => { - try { - resolve(body ? JSON.parse(body) : {}); - } catch { - reject(new Error("Invalid JSON")); - } - }); - req.on("error", reject); - }); -} - -/** - * Send JSON response. - */ -function sendJson(res: ServerResponse, status: number, data: unknown): void { - res.writeHead(status, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }); - res.end(JSON.stringify(data)); -} - -/** - * Send error response. - */ -function sendError(res: ServerResponse, status: number, message: string): void { - sendJson(res, status, { error: message }); -} - -/** - * Handle CORS preflight. - */ -function handleCors(res: ServerResponse): void { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Accept, Mcp-Session-Id", - "Access-Control-Expose-Headers": "Mcp-Session-Id", - "Access-Control-Max-Age": "86400", - }); - res.end(); -} - -// ----------------------------------------------------------------------------- -// Cloud Proxy -// ----------------------------------------------------------------------------- - -/** - * Proxy a request to the cloud API. - */ -async function proxyToCloud( - req: IncomingMessage, - res: ServerResponse, - pathname: string -): Promise { - const method = req.method || "GET"; - const cloudUrl = `${CLOUD_API_URL}${pathname}`; - - const headers: Record = { - "x-api-key": cloudApiKey!, - }; - - // Forward content-type for requests with body - if (req.headers["content-type"]) { - headers["Content-Type"] = req.headers["content-type"]; - } - - let body: string | undefined; - if (method !== "GET" && method !== "HEAD") { - body = await new Promise((resolve, reject) => { - let data = ""; - req.on("data", (chunk) => (data += chunk)); - req.on("end", () => resolve(data)); - req.on("error", reject); - }); - } - - try { - const cloudRes = await fetch(cloudUrl, { - method, - headers, - body, - }); - - // Handle SSE responses - if (cloudRes.headers.get("content-type")?.includes("text/event-stream")) { - res.writeHead(cloudRes.status, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }); - - const reader = cloudRes.body?.getReader(); - if (reader) { - const pump = async () => { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - res.write(value); - } - res.end(); - }; - pump().catch(() => res.end()); - - req.on("close", () => { - reader.cancel(); - }); - } - return; - } - - // Handle regular JSON responses - const data = await cloudRes.text(); - res.writeHead(cloudRes.status, { - "Content-Type": cloudRes.headers.get("content-type") || "application/json", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }); - res.end(data); - } catch (err) { - console.error("[Cloud Proxy] Error:", err); - sendError(res, 502, `Cloud proxy error: ${(err as Error).message}`); - } -} - -// ----------------------------------------------------------------------------- -// Route Handlers -// ----------------------------------------------------------------------------- - -type RouteHandler = ( - req: IncomingMessage, - res: ServerResponse, - params: Record -) => Promise; - -/** - * POST /sessions - Create a new session. - */ -const createSessionHandler: RouteHandler = async (req, res) => { - try { - const body = await parseBody<{ url: string; projectId?: string }>(req); - - if (!body.url) { - return sendError(res, 400, "url is required"); - } - - const session = createSession(body.url, body.projectId); - sendJson(res, 201, session); - } catch (err) { - sendError(res, 400, (err as Error).message); - } -}; - -/** - * GET /sessions - List all sessions. - */ -const listSessionsHandler: RouteHandler = async (_req, res) => { - const sessions = listSessions(); - sendJson(res, 200, sessions); -}; - -/** - * GET /sessions/:id - Get a session with annotations. - */ -const getSessionHandler: RouteHandler = async (_req, res, params) => { - const session = getSessionWithAnnotations(params.id); - - if (!session) { - return sendError(res, 404, "Session not found"); - } - - sendJson(res, 200, session); -}; - -/** - * POST /sessions/:id/annotations - Add annotation to session. - */ -const addAnnotationHandler: RouteHandler = async (req, res, params) => { - try { - const body = await parseBody>(req); - - if (!body.comment || !body.element || !body.elementPath) { - return sendError(res, 400, "comment, element, and elementPath are required"); - } - - const annotation = addAnnotation(params.id, body); - - if (!annotation) { - return sendError(res, 404, "Session not found"); - } - - sendJson(res, 201, annotation); - } catch (err) { - sendError(res, 400, (err as Error).message); - } -}; - -/** - * PATCH /annotations/:id - Update an annotation. - */ -const updateAnnotationHandler: RouteHandler = async (req, res, params) => { - try { - const body = await parseBody>(req); - - // Check if annotation exists - const existing = getAnnotation(params.id); - if (!existing) { - return sendError(res, 404, "Annotation not found"); - } - - const annotation = updateAnnotation(params.id, body); - sendJson(res, 200, annotation); - } catch (err) { - sendError(res, 400, (err as Error).message); - } -}; - -/** - * GET /annotations/:id - Get an annotation. - */ -const getAnnotationHandler: RouteHandler = async (_req, res, params) => { - const annotation = getAnnotation(params.id); - - if (!annotation) { - return sendError(res, 404, "Annotation not found"); - } - - sendJson(res, 200, annotation); -}; - -/** - * DELETE /annotations/:id - Delete an annotation. - */ -const deleteAnnotationHandler: RouteHandler = async (_req, res, params) => { - const annotation = deleteAnnotation(params.id); - - if (!annotation) { - return sendError(res, 404, "Annotation not found"); - } - - sendJson(res, 200, { deleted: true, annotationId: params.id }); -}; - -/** - * GET /sessions/:id/pending - Get annotations needing agent attention for a session. - * Returns both pending (unacknowledged) annotations AND non-pending annotations - * where the last thread message is from a human (unread human replies). - */ -const getPendingHandler: RouteHandler = async (_req, res, params) => { - const pending = getAnnotationsNeedingAttention(params.id); - sendJson(res, 200, { count: pending.length, annotations: pending }); -}; - -/** - * GET /pending - Get all annotations needing agent attention across all sessions. - * Returns both pending annotations AND annotations with unread human replies. - */ -const getAllPendingHandler: RouteHandler = async (_req, res) => { - const sessions = listSessions(); - const allPending = sessions.flatMap((session) => getAnnotationsNeedingAttention(session.id)); - sendJson(res, 200, { count: allPending.length, annotations: allPending }); -}; - -/** - * POST /sessions/:id/action - Request agent action on annotations. - * - * Emits an action.requested event via SSE with the current annotations - * and formatted output. The agent can listen for this event to know - * when the user wants action taken. - * - * Also sends webhooks to configured URLs (via AGENTATION_WEBHOOK_URL or - * AGENTATION_WEBHOOKS environment variables). - */ -const requestActionHandler: RouteHandler = async (req, res, params) => { - try { - const sessionId = params.id; - const body = await parseBody<{ output: string }>(req); - - // Verify session exists - const session = getSessionWithAnnotations(sessionId); - if (!session) { - return sendError(res, 404, "Session not found"); - } - - if (!body.output) { - return sendError(res, 400, "output is required"); - } - - // Build action request payload - const actionRequest: ActionRequest = { - sessionId, - annotations: session.annotations, - output: body.output, - timestamp: new Date().toISOString(), - }; - - // Emit event (will be sent to all SSE subscribers) - eventBus.emit("action.requested", sessionId, actionRequest); - - // Send webhooks (fire and forget, non-blocking) - const webhookUrls = getWebhookUrls(); - sendWebhooks(actionRequest); - - // Return delivery info so client knows if anyone received it - // Only count agent connections (with ?agent=true), not browser toolbar connections - const agentListeners = agentConnections.size; - const webhooks = webhookUrls.length; - - sendJson(res, 200, { - success: true, - annotationCount: session.annotations.length, - delivered: { - sseListeners: agentListeners, - webhooks: webhooks, - total: agentListeners + webhooks, - }, - }); - } catch (err) { - sendError(res, 400, (err as Error).message); - } -}; - -/** - * POST /annotations/:id/thread - Add a thread message. - */ -const addThreadHandler: RouteHandler = async (req, res, params) => { - try { - const body = await parseBody<{ role: "human" | "agent"; content: string }>(req); - - if (!body.role || !body.content) { - return sendError(res, 400, "role and content are required"); - } - - const annotation = addThreadMessage(params.id, body.role, body.content); - - if (!annotation) { - return sendError(res, 404, "Annotation not found"); - } - - sendJson(res, 201, annotation); - } catch (err) { - sendError(res, 400, (err as Error).message); - } -}; - -/** - * GET /sessions/:id/events - SSE stream of events for a session. - * - * Supports reconnection via Last-Event-ID header. - * Events are streamed in real-time as they occur. - */ -const sseHandler: RouteHandler = async (req, res, params) => { - const sessionId = params.id; - const url = new URL(req.url || "/", "http://localhost"); - const isAgent = url.searchParams.get("agent") === "true"; - - // Verify session exists - const session = getSessionWithAnnotations(sessionId); - if (!session) { - return sendError(res, 404, "Session not found"); - } - - // Set up SSE headers - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }); - - // Track this connection - sseConnections.add(res); - if (isAgent) { - agentConnections.add(res); - } - - // Send initial comment to establish connection - res.write(": connected\n\n"); - - // Check for Last-Event-ID for replay - const lastEventId = req.headers["last-event-id"]; - if (lastEventId) { - const lastSequence = parseInt(lastEventId as string, 10); - if (!isNaN(lastSequence)) { - // Replay missed events - const missedEvents = getEventsSince(sessionId, lastSequence); - for (const event of missedEvents) { - sendSSEEvent(res, event); - } - } - } - - // Subscribe to new events - const unsubscribe = eventBus.subscribeToSession(sessionId, (event: AFSEvent) => { - sendSSEEvent(res, event); - }); - - // Keep connection alive with periodic comments - const keepAlive = setInterval(() => { - res.write(": ping\n\n"); - }, 30000); - - // Clean up on disconnect - req.on("close", () => { - clearInterval(keepAlive); - unsubscribe(); - sseConnections.delete(res); - agentConnections.delete(res); - }); -}; - -/** - * Send an SSE event to a response stream. - */ -function sendSSEEvent(res: ServerResponse, event: AFSEvent): void { - res.write(`event: ${event.type}\n`); - res.write(`id: ${event.sequence}\n`); - res.write(`data: ${JSON.stringify(event)}\n\n`); -} - -/** - * GET /events - Global SSE stream. - * - * Optionally filter by domain: GET /events?domain=example.com - * Without domain, streams ALL events across all sessions. - * Useful for agents that need to track feedback across page navigations. - */ -const globalSseHandler: RouteHandler = async (req, res) => { - const url = new URL(req.url || "/", "http://localhost"); - const domain = url.searchParams.get("domain"); - const isAgent = url.searchParams.get("agent") === "true"; - - // Set up SSE headers - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }); - - // Track this connection - sseConnections.add(res); - if (isAgent) { - agentConnections.add(res); - } - - // Send initial comment to establish connection - res.write(`: connected${domain ? ` to domain ${domain}` : ""}\n\n`); - - // Send all annotations needing attention on connect (initial sync for agents) - if (isAgent) { - let syncCount = 0; - const sessions = listSessions(); - for (const session of sessions) { - try { - // If domain is specified, filter by it; otherwise include all sessions - if (domain) { - const sessionHost = new URL(session.url).host; - if (sessionHost !== domain) continue; - } - const needsAttention = getAnnotationsNeedingAttention(session.id); - for (const annotation of needsAttention) { - // Send as annotation.created events so agents see existing annotations - // Use sequence 0 for initial sync events (they're historical, not new) - sendSSEEvent(res, { - type: "annotation.created", - sessionId: session.id, - timestamp: annotation.createdAt || new Date().toISOString(), - sequence: 0, - payload: annotation, - }); - syncCount++; - } - } catch { - // Invalid URL, skip - } - } - // Send a sync.complete event so agents know initial sync is done - res.write(`event: sync.complete\ndata: ${JSON.stringify({ domain: domain ?? "all", count: syncCount, timestamp: new Date().toISOString() })}\n\n`); - } - - // Subscribe to all events, optionally filter by domain - const unsubscribe = eventBus.subscribe((event: AFSEvent) => { - if (!domain) { - // No domain filter -- stream all events - sendSSEEvent(res, event); - return; - } - const session = getSession(event.sessionId); - if (session) { - try { - const sessionHost = new URL(session.url).host; - if (sessionHost === domain) { - sendSSEEvent(res, event); - } - } catch { - // Invalid URL, skip - } - } - }); - - // Keep connection alive with periodic comments - const keepAlive = setInterval(() => { - res.write(": ping\n\n"); - }, 30000); - - // Clean up on disconnect - req.on("close", () => { - clearInterval(keepAlive); - unsubscribe(); - sseConnections.delete(res); - agentConnections.delete(res); - }); -}; - -/** - * Handle MCP protocol requests at /mcp endpoint. - * Supports POST (requests), GET (SSE stream), and DELETE (session cleanup). - */ -async function handleMcp(req: IncomingMessage, res: ServerResponse): Promise { - const method = req.method || "GET"; - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - // Add CORS headers to all responses - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id"); - res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); - - // POST: Handle JSON-RPC requests - if (method === "POST") { - let transport: StreamableHTTPServerTransport; - - if (sessionId) { - // Session ID provided - must exist in our map - if (!mcpTransports.has(sessionId)) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - error: { code: -32000, message: "Session not found. Please re-initialize." }, - id: null - })); - return; - } - transport = mcpTransports.get(sessionId)!; - } else { - // No session ID - this should be an initialize request, create new session - const { transport: newTransport } = createMcpSession(); - transport = newTransport; - } - - try { - // Read the request body - const body = await new Promise((resolve, reject) => { - let data = ""; - req.on("data", (chunk) => (data += chunk)); - req.on("end", () => resolve(data)); - req.on("error", reject); - }); - - const parsedBody = body ? JSON.parse(body) : undefined; - - // Handle the request through the transport (it writes directly to res) - await transport.handleRequest(req, res, parsedBody); - - // Store the transport with its session ID after the request is handled (for new sessions) - const newSessionId = transport.sessionId; - if (newSessionId && !mcpTransports.has(newSessionId)) { - mcpTransports.set(newSessionId, transport); - log(`[MCP HTTP] New session created: ${newSessionId}`); - } - } catch (err) { - console.error("[MCP HTTP] Error handling request:", err); - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Internal server error" })); - } - } - return; - } - - // GET: SSE stream for notifications - if (method === "GET") { - if (!sessionId || !mcpTransports.has(sessionId)) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing or invalid Mcp-Session-Id" })); - return; - } - - const transport = mcpTransports.get(sessionId)!; - - try { - // Handle the SSE request (transport writes directly to res) - await transport.handleRequest(req, res); - } catch (err) { - console.error("[MCP HTTP] Error handling SSE:", err); - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Internal server error" })); - } - } - return; - } - - // DELETE: Session cleanup - if (method === "DELETE") { - if (sessionId && mcpTransports.has(sessionId)) { - const transport = mcpTransports.get(sessionId)!; - await transport.close(); - mcpTransports.delete(sessionId); - res.writeHead(204); - res.end(); - } else { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Session not found" })); - } - return; - } - - // Method not allowed - res.writeHead(405, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Method not allowed" })); -} - -// ----------------------------------------------------------------------------- -// Router -// ----------------------------------------------------------------------------- - -type Route = { - method: string; - pattern: RegExp; - handler: RouteHandler; - paramNames: string[]; -}; - -const routes: Route[] = [ - { - method: "GET", - pattern: /^\/events$/, - handler: globalSseHandler, - paramNames: [], - }, - { - method: "GET", - pattern: /^\/pending$/, - handler: getAllPendingHandler, - paramNames: [], - }, - { - method: "GET", - pattern: /^\/sessions$/, - handler: listSessionsHandler, - paramNames: [], - }, - { - method: "POST", - pattern: /^\/sessions$/, - handler: createSessionHandler, - paramNames: [], - }, - { - method: "GET", - pattern: /^\/sessions\/([^/]+)$/, - handler: getSessionHandler, - paramNames: ["id"], - }, - { - method: "GET", - pattern: /^\/sessions\/([^/]+)\/events$/, - handler: sseHandler, - paramNames: ["id"], - }, - { - method: "GET", - pattern: /^\/sessions\/([^/]+)\/pending$/, - handler: getPendingHandler, - paramNames: ["id"], - }, - { - method: "POST", - pattern: /^\/sessions\/([^/]+)\/action$/, - handler: requestActionHandler, - paramNames: ["id"], - }, - { - method: "POST", - pattern: /^\/sessions\/([^/]+)\/annotations$/, - handler: addAnnotationHandler, - paramNames: ["id"], - }, - { - method: "PATCH", - pattern: /^\/annotations\/([^/]+)$/, - handler: updateAnnotationHandler, - paramNames: ["id"], - }, - { - method: "GET", - pattern: /^\/annotations\/([^/]+)$/, - handler: getAnnotationHandler, - paramNames: ["id"], - }, - { - method: "DELETE", - pattern: /^\/annotations\/([^/]+)$/, - handler: deleteAnnotationHandler, - paramNames: ["id"], - }, - { - method: "POST", - pattern: /^\/annotations\/([^/]+)\/thread$/, - handler: addThreadHandler, - paramNames: ["id"], - }, -]; - -/** - * Match a request to a route. - */ -function matchRoute( - method: string, - pathname: string -): { handler: RouteHandler; params: Record } | null { - for (const route of routes) { - if (route.method !== method) continue; - - const match = pathname.match(route.pattern); - if (match) { - const params: Record = {}; - route.paramNames.forEach((name, i) => { - params[name] = match[i + 1]; - }); - return { handler: route.handler, params }; - } - } - return null; -} - -// ----------------------------------------------------------------------------- -// Server -// ----------------------------------------------------------------------------- - -/** - * Create and start the HTTP server. - * @param port - Port to listen on - * @param apiKey - Optional API key for cloud storage mode - */ -export function startHttpServer(port: number, apiKey?: string): void { - // Set cloud mode if API key provided - if (apiKey) { - setCloudApiKey(apiKey); - } - - const server = createServer(async (req, res) => { - const url = new URL(req.url || "/", `http://localhost:${port}`); - const pathname = url.pathname; - const method = req.method || "GET"; - - // Log all requests for debugging - if (method !== "OPTIONS" && pathname !== "/health") { - log(`[HTTP] ${method} ${pathname}`); - } - - // Handle CORS preflight - if (method === "OPTIONS") { - return handleCors(res); - } - - // Health check (always local) - if (pathname === "/health" && method === "GET") { - return sendJson(res, 200, { status: "ok", mode: isCloudMode() ? "cloud" : "local" }); - } - - // Status endpoint (always local) - if (pathname === "/status" && method === "GET") { - const webhookUrls = getWebhookUrls(); - return sendJson(res, 200, { - mode: isCloudMode() ? "cloud" : "local", - webhooksConfigured: webhookUrls.length > 0, - webhookCount: webhookUrls.length, - activeListeners: sseConnections.size, - agentListeners: agentConnections.size, - }); - } - - // MCP protocol endpoint (always local - allows Claude Code to connect) - if (pathname === "/mcp") { - return handleMcp(req, res); - } - - // Cloud mode: proxy all other requests to cloud API - if (isCloudMode()) { - return proxyToCloud(req, res, pathname + url.search); - } - - // Local mode: use local store - const match = matchRoute(method, pathname); - if (!match) { - return sendError(res, 404, "Not found"); - } - - try { - await match.handler(req, res, match.params); - } catch (err) { - console.error("Request error:", err); - sendError(res, 500, "Internal server error"); - } - }); - - server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - log(`[HTTP] Port ${port} already in use — skipping HTTP server (MCP stdio still active)`); - } else { - log(`[HTTP] Server error: ${err.message}`); - } - }); - - server.listen(port, () => { - if (isCloudMode()) { - log(`[HTTP] Agentation server listening on http://localhost:${port} (cloud mode)`); - } else { - log(`[HTTP] Agentation server listening on http://localhost:${port}`); - } - }); -} diff --git a/mcp/src/server/index.ts b/mcp/src/server/index.ts deleted file mode 100644 index 471d9d55..00000000 --- a/mcp/src/server/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -/** - * Agentation Server - * - * Runs both: - * - HTTP server for the React component to POST annotations - * - MCP server for Claude Code to read and act on annotations - * - * Usage: - * npx agentation-mcp server [--port 4747] [--mcp-only] [--http-url URL] - * agentation-mcp server [--port 4747] [--mcp-only] [--http-url URL] - * - * Options: - * --port HTTP server port (default: 4747) - * --mcp-only Skip HTTP server, only run MCP on stdio (for Claude Code MCP config) - * --http-url HTTP server URL for MCP to fetch from (default: http://localhost:4747) - */ - -import { startHttpServer } from "./http.js"; -import { startMcpServer, setApiKey } from "./mcp.js"; - -// Re-export for programmatic use -export { startHttpServer, setCloudApiKey } from "./http.js"; -export { startMcpServer, setApiKey } from "./mcp.js"; -export * from "./store.js"; - -// ----------------------------------------------------------------------------- -// CLI Argument Parsing -// ----------------------------------------------------------------------------- - -function parseArgs(): { port: number; mcpOnly: boolean; httpUrl: string } { - const args = process.argv.slice(2); - let port = 4747; - let mcpOnly = false; - let httpUrl = "http://localhost:4747"; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--port" && args[i + 1]) { - const parsed = parseInt(args[i + 1], 10); - if (!isNaN(parsed) && parsed > 0 && parsed < 65536) { - port = parsed; - // Also update httpUrl if port changes and httpUrl wasn't explicitly set - if (!args.includes("--http-url")) { - httpUrl = `http://localhost:${port}`; - } - } - i++; - } - if (args[i] === "--mcp-only") { - mcpOnly = true; - } - if (args[i] === "--http-url" && args[i + 1]) { - httpUrl = args[i + 1]; - i++; - } - } - - return { port, mcpOnly, httpUrl }; -} - -// ----------------------------------------------------------------------------- -// Main -// ----------------------------------------------------------------------------- - -async function main(): Promise { - const { port, mcpOnly, httpUrl } = parseArgs(); - - // Start HTTP server (for browser clients) - skip if --mcp-only - if (!mcpOnly) { - startHttpServer(port); - } - - // Start MCP server (for Claude Code via stdio) - // MCP fetches from HTTP server (single source of truth) - await startMcpServer(httpUrl); -} - diff --git a/mcp/src/server/mcp.ts b/mcp/src/server/mcp.ts deleted file mode 100644 index a213c42f..00000000 --- a/mcp/src/server/mcp.ts +++ /dev/null @@ -1,787 +0,0 @@ -/** - * MCP server for Agentation. - * Exposes tools for AI agents to interact with annotations. - * - * This server fetches data from the HTTP API (single source of truth) - * rather than maintaining its own store. - */ - -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import type { ActionRequest } from "../types.js"; - -// ----------------------------------------------------------------------------- -// Configuration -// ----------------------------------------------------------------------------- - -let httpBaseUrl = "http://localhost:4747"; -let apiKey: string | undefined; - -/** - * Set the HTTP server URL that this MCP server will fetch from. - */ -export function setHttpBaseUrl(url: string): void { - httpBaseUrl = url; -} - -/** - * Set the API key for authenticating with the cloud backend. - */ -export function setApiKey(key: string): void { - apiKey = key; -} - -// ----------------------------------------------------------------------------- -// HTTP Client -// ----------------------------------------------------------------------------- - -async function httpGet(path: string): Promise { - const headers: Record = {}; - if (apiKey) { - headers["x-api-key"] = apiKey; - } - const res = await fetch(`${httpBaseUrl}${path}`, { headers }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`HTTP ${res.status}: ${body}`); - } - return res.json() as Promise; -} - -async function httpPatch(path: string, body: unknown): Promise { - const headers: Record = { "Content-Type": "application/json" }; - if (apiKey) { - headers["x-api-key"] = apiKey; - } - const res = await fetch(`${httpBaseUrl}${path}`, { - method: "PATCH", - headers, - body: JSON.stringify(body), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text}`); - } - return res.json() as Promise; -} - -async function httpPost(path: string, body: unknown): Promise { - const headers: Record = { "Content-Type": "application/json" }; - if (apiKey) { - headers["x-api-key"] = apiKey; - } - const res = await fetch(`${httpBaseUrl}${path}`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text}`); - } - return res.json() as Promise; -} - -// ----------------------------------------------------------------------------- -// Tool Schemas -// ----------------------------------------------------------------------------- - -const GetPendingSchema = z.object({ - sessionId: z.string().describe("The session ID to get pending annotations for"), -}); - -const AcknowledgeSchema = z.object({ - annotationId: z.string().describe("The annotation ID to acknowledge"), -}); - -const ResolveSchema = z.object({ - annotationId: z.string().describe("The annotation ID to resolve"), - summary: z.string().optional().describe("Optional summary of how it was resolved"), -}); - -const DismissSchema = z.object({ - annotationId: z.string().describe("The annotation ID to dismiss"), - reason: z.string().describe("Reason for dismissing this annotation"), -}); - -const ReplySchema = z.object({ - annotationId: z.string().describe("The annotation ID to reply to"), - message: z.string().describe("The reply message"), -}); - -const GetSessionSchema = z.object({ - sessionId: z.string().describe("The session ID to get"), -}); - -const WatchAnnotationsSchema = z.object({ - sessionId: z.string().optional().describe("Optional session ID to filter. If not provided, watches ALL sessions."), - batchWindowSeconds: z.number().optional().default(10).describe("Seconds to wait after first annotation before returning batch (default: 10, max: 60)"), - timeoutSeconds: z.number().optional().default(120).describe("Max seconds to wait for first annotation (default: 120, max: 300)"), -}); - -// ----------------------------------------------------------------------------- -// Tool Definitions -// ----------------------------------------------------------------------------- - -export const TOOLS = [ - { - name: "agentation_list_sessions", - description: "List all active annotation sessions", - inputSchema: { - type: "object" as const, - properties: {}, - required: [], - }, - }, - { - name: "agentation_get_session", - description: "Get a session with all its annotations", - inputSchema: { - type: "object" as const, - properties: { - sessionId: { - type: "string", - description: "The session ID to get", - }, - }, - required: ["sessionId"], - }, - }, - { - name: "agentation_get_pending", - description: - "Get all annotations needing attention for a session. Returns pending (unacknowledged) annotations AND annotations with unread human thread replies.", - inputSchema: { - type: "object" as const, - properties: { - sessionId: { - type: "string", - description: "The session ID to get pending annotations for", - }, - }, - required: ["sessionId"], - }, - }, - { - name: "agentation_get_all_pending", - description: - "Get all annotations needing attention across ALL sessions. Returns pending annotations AND annotations with unread human thread replies.", - inputSchema: { - type: "object" as const, - properties: {}, - required: [], - }, - }, - { - name: "agentation_acknowledge", - description: - "Mark an annotation as acknowledged. Use this to let the human know you've seen their feedback and will address it.", - inputSchema: { - type: "object" as const, - properties: { - annotationId: { - type: "string", - description: "The annotation ID to acknowledge", - }, - }, - required: ["annotationId"], - }, - }, - { - name: "agentation_resolve", - description: - "Mark an annotation as resolved. Use this after you've addressed the feedback. Optionally include a summary of what you did.", - inputSchema: { - type: "object" as const, - properties: { - annotationId: { - type: "string", - description: "The annotation ID to resolve", - }, - summary: { - type: "string", - description: "Optional summary of how it was resolved", - }, - }, - required: ["annotationId"], - }, - }, - { - name: "agentation_dismiss", - description: - "Dismiss an annotation. Use this when you've decided not to address the feedback, with a reason why.", - inputSchema: { - type: "object" as const, - properties: { - annotationId: { - type: "string", - description: "The annotation ID to dismiss", - }, - reason: { - type: "string", - description: "Reason for dismissing this annotation", - }, - }, - required: ["annotationId", "reason"], - }, - }, - { - name: "agentation_reply", - description: - "Add a reply to an annotation's thread. Use this to ask clarifying questions or provide updates to the human.", - inputSchema: { - type: "object" as const, - properties: { - annotationId: { - type: "string", - description: "The annotation ID to reply to", - }, - message: { - type: "string", - description: "The reply message", - }, - }, - required: ["annotationId", "message"], - }, - }, - { - name: "agentation_watch_annotations", - description: - "Block until new annotations or human thread replies appear, then collect a batch and return them. " + - "Triggers automatically when annotations are created or when a human replies to a thread — " + - "the user just annotates in the browser and the agent picks them up. " + - "After detecting the first event, waits for a batch window to collect more before returning. " + - "Use in a loop for hands-free processing. " + - "After addressing each annotation, call agentation_resolve with the annotation ID and a summary " + - "of what you did. Only resolve annotations the user accepted — if the user rejects your change, " + - "leave the annotation open.", - inputSchema: { - type: "object" as const, - properties: { - sessionId: { - type: "string", - description: "Optional session ID to filter. If not provided, watches ALL sessions.", - }, - batchWindowSeconds: { - type: "number", - description: "Seconds to wait after first annotation before returning batch (default: 10, max: 60)", - }, - timeoutSeconds: { - type: "number", - description: "Max seconds to wait for first annotation (default: 120, max: 300)", - }, - }, - required: [], - }, - }, -]; - -// ----------------------------------------------------------------------------- -// Types -// ----------------------------------------------------------------------------- - -type Session = { - id: string; - url: string; - status: string; - createdAt: string; -}; - -type ThreadMessage = { - id: string; - role: "human" | "agent"; - content: string; - timestamp: number; -}; - -type Annotation = { - id: string; - sessionId: string; - comment: string; - element: string; - elementPath: string; - url?: string; - intent?: string; - severity?: string; - timestamp?: number; - nearbyText?: string; - reactComponents?: string; - status: string; - thread?: ThreadMessage[]; -}; - -type SessionWithAnnotations = Session & { - annotations: Annotation[]; -}; - -type PendingResponse = { - count: number; - annotations: Annotation[]; -}; - -// ----------------------------------------------------------------------------- -// Tool Handlers -// ----------------------------------------------------------------------------- - -type ToolResult = { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; -}; - -export function success(data: unknown): ToolResult { - return { - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], - }; -} - -export function error(message: string): ToolResult { - return { - content: [{ type: "text", text: message }], - isError: true, - }; -} - -/** - * Result from watchForAnnotations - */ -type WatchAnnotationsResult = - | { type: "annotations"; annotations: Annotation[]; sessions: string[] } - | { type: "timeout" } - | { type: "error"; message: string }; - -/** - * Watch for new annotation.created and thread.message events via SSE from the HTTP server. - * When the first event is detected, waits for a batch window to collect - * additional annotations directly from SSE event payloads. - * - * Initial sync events (sequence 0) are ignored to prevent false triggers - * from pre-existing pending annotations when the SSE connection opens. - * - * Watches for new annotations and human thread replies via SSE and collects them into a batch. - */ -function watchForAnnotations( - sessionId: string | undefined, - batchWindowMs: number, - timeoutMs: number -): Promise { - return new Promise((resolve) => { - let aborted = false; - const controller = new AbortController(); - let batchTimeout: ReturnType | null = null; - const detectedSessions = new Set(); - const collectedAnnotations: Annotation[] = []; - - const cleanup = () => { - aborted = true; - controller.abort(); - if (batchTimeout) clearTimeout(batchTimeout); - }; - - // Set overall timeout - const timeoutId = setTimeout(() => { - cleanup(); - resolve({ type: "timeout" }); - }, timeoutMs); - - // Connect to SSE endpoint with agent=true to be counted as an agent listener - const sseUrl = sessionId - ? `${httpBaseUrl}/sessions/${sessionId}/events?agent=true` - : `${httpBaseUrl}/events?agent=true`; - - const sseHeaders: Record = { Accept: "text/event-stream" }; - if (apiKey) { - sseHeaders["x-api-key"] = apiKey; - } - - fetch(sseUrl, { - signal: controller.signal, - headers: sseHeaders, - }) - .then(async (res) => { - if (!res.ok) { - clearTimeout(timeoutId); - cleanup(); - resolve({ type: "error", message: `HTTP server returned ${res.status}: ${res.statusText}` }); - return; - } - if (!res.body) { - clearTimeout(timeoutId); - cleanup(); - resolve({ type: "error", message: "No response body from SSE endpoint" }); - return; - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (!aborted) { - const { done, value } = await reader.read(); - if (done) { - if (!aborted) { - clearTimeout(timeoutId); - cleanup(); - if (collectedAnnotations.length > 0) { - resolve({ - type: "annotations", - annotations: collectedAnnotations, - sessions: Array.from(detectedSessions), - }); - } else { - resolve({ type: "error", message: "SSE connection closed unexpectedly. The agentation server may have restarted." }); - } - } - return; - } - - 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 event = JSON.parse(line.slice(6)); - - // Skip initial sync events (sequence 0) — historical replay, not new - if (event.sequence === 0) continue; - - // If filtering by session, check it matches - if (sessionId && event.sessionId !== sessionId) continue; - - if (event.type === "annotation.created") { - detectedSessions.add(event.sessionId); - collectedAnnotations.push(event.payload as Annotation); - } else if (event.type === "thread.message") { - // Only surface human thread replies (not agent's own replies) - const payload = event.payload as Annotation; - if (payload.thread && payload.thread.length > 0) { - const lastMessage = payload.thread[payload.thread.length - 1]; - if (lastMessage.role === "human") { - detectedSessions.add(event.sessionId); - // Avoid duplicates if the same annotation already in the batch - const existingIdx = collectedAnnotations.findIndex( - (a) => a.id === payload.id - ); - if (existingIdx >= 0) { - collectedAnnotations[existingIdx] = payload; - } else { - collectedAnnotations.push(payload); - } - } - } - } else { - continue; - } - - // First event detected — start batch window - if (!batchTimeout && collectedAnnotations.length > 0) { - batchTimeout = setTimeout(() => { - clearTimeout(timeoutId); - cleanup(); - resolve({ - type: "annotations", - annotations: collectedAnnotations, - sessions: Array.from(detectedSessions), - }); - }, batchWindowMs); - } - } catch { - // Ignore parse errors for individual events - } - } - } - } - }) - .catch((err) => { - // Connection error or aborted - if (!aborted) { - clearTimeout(timeoutId); - const message = err instanceof Error ? err.message : "Unknown connection error"; - // Check for common connection errors - if (message.includes("ECONNREFUSED") || message.includes("fetch failed")) { - resolve({ type: "error", message: `Cannot connect to HTTP server at ${httpBaseUrl}. Is the agentation server running?` }); - } else if (message.includes("abort")) { - // Aborted by timeout - already handled - resolve({ type: "timeout" }); - } else { - resolve({ type: "error", message: `Connection error: ${message}` }); - } - } - }); - }); -} - -export async function handleTool(name: string, args: unknown): Promise { - switch (name) { - case "agentation_list_sessions": { - const sessions = await httpGet("/sessions"); - return success({ - sessions: sessions.map((s) => ({ - id: s.id, - url: s.url, - status: s.status, - createdAt: s.createdAt, - })), - }); - } - - case "agentation_get_session": { - const { sessionId } = GetSessionSchema.parse(args); - try { - const session = await httpGet(`/sessions/${sessionId}`); - return success(session); - } catch (err) { - if ((err as Error).message.includes("404")) { - return error(`Session not found: ${sessionId}`); - } - throw err; - } - } - - case "agentation_get_pending": { - const { sessionId } = GetPendingSchema.parse(args); - const response = await httpGet(`/sessions/${sessionId}/pending`); - return success({ - count: response.count, - annotations: response.annotations.map((a) => ({ - id: a.id, - comment: a.comment, - element: a.element, - elementPath: a.elementPath, - url: a.url, - intent: a.intent, - severity: a.severity, - timestamp: a.timestamp, - nearbyText: a.nearbyText, - reactComponents: a.reactComponents, - })), - }); - } - - case "agentation_get_all_pending": { - const response = await httpGet("/pending"); - return success({ - count: response.count, - annotations: response.annotations.map((a) => ({ - id: a.id, - comment: a.comment, - element: a.element, - elementPath: a.elementPath, - url: a.url, - intent: a.intent, - severity: a.severity, - timestamp: a.timestamp, - nearbyText: a.nearbyText, - reactComponents: a.reactComponents, - })), - }); - } - - case "agentation_acknowledge": { - const { annotationId } = AcknowledgeSchema.parse(args); - try { - await httpPatch(`/annotations/${annotationId}`, { status: "acknowledged" }); - return success({ acknowledged: true, annotationId }); - } catch (err) { - if ((err as Error).message.includes("404")) { - return error(`Annotation not found: ${annotationId}`); - } - throw err; - } - } - - case "agentation_resolve": { - const { annotationId, summary } = ResolveSchema.parse(args); - try { - await httpPatch(`/annotations/${annotationId}`, { - status: "resolved", - resolvedBy: "agent", - }); - if (summary) { - await httpPost(`/annotations/${annotationId}/thread`, { - role: "agent", - content: `Resolved: ${summary}`, - }); - } - return success({ resolved: true, annotationId, summary }); - } catch (err) { - if ((err as Error).message.includes("404")) { - return error(`Annotation not found: ${annotationId}`); - } - throw err; - } - } - - case "agentation_dismiss": { - const { annotationId, reason } = DismissSchema.parse(args); - try { - await httpPatch(`/annotations/${annotationId}`, { - status: "dismissed", - resolvedBy: "agent", - }); - await httpPost(`/annotations/${annotationId}/thread`, { - role: "agent", - content: `Dismissed: ${reason}`, - }); - return success({ dismissed: true, annotationId, reason }); - } catch (err) { - if ((err as Error).message.includes("404")) { - return error(`Annotation not found: ${annotationId}`); - } - throw err; - } - } - - case "agentation_reply": { - const { annotationId, message } = ReplySchema.parse(args); - try { - await httpPost(`/annotations/${annotationId}/thread`, { - role: "agent", - content: message, - }); - return success({ replied: true, annotationId, message }); - } catch (err) { - if ((err as Error).message.includes("404")) { - return error(`Annotation not found: ${annotationId}`); - } - throw err; - } - } - - case "agentation_watch_annotations": { - const parsed = WatchAnnotationsSchema.parse(args); - const sessionId = parsed.sessionId; - const batchWindowSeconds = Math.min(60, Math.max(1, parsed.batchWindowSeconds ?? 10)); - const timeoutSeconds = Math.min(300, Math.max(1, parsed.timeoutSeconds ?? 120)); - - // Drain: return any pending annotations immediately before blocking on SSE. - // This catches annotations that arrived while the caller was busy processing - // the previous batch (when watch_annotations wasn't running). - try { - const pendingPath = sessionId ? `/sessions/${sessionId}/pending` : "/pending"; - const pending = await httpGet(pendingPath); - if (pending.count > 0) { - const sessions = [...new Set(pending.annotations.map((a) => a.sessionId))]; - return success({ - timeout: false, - count: pending.count, - sessions, - annotations: pending.annotations.map((a) => ({ - id: a.id, - comment: a.comment, - element: a.element, - elementPath: a.elementPath, - url: a.url, - intent: a.intent, - severity: a.severity, - timestamp: a.timestamp, - nearbyText: a.nearbyText, - reactComponents: a.reactComponents, - })), - }); - } - } catch (err) { - console.error("[MCP] Pending drain failed, falling through to SSE watch:", err); - } - - const result = await watchForAnnotations( - sessionId, - batchWindowSeconds * 1000, - timeoutSeconds * 1000 - ); - - switch (result.type) { - case "annotations": - return success({ - timeout: false, - count: result.annotations.length, - sessions: result.sessions, - annotations: result.annotations.map((a) => ({ - id: a.id, - comment: a.comment, - element: a.element, - elementPath: a.elementPath, - url: a.url, - intent: a.intent, - severity: a.severity, - timestamp: a.timestamp, - nearbyText: a.nearbyText, - reactComponents: a.reactComponents, - })), - }); - case "timeout": - return success({ - timeout: true, - message: `No new annotations within ${timeoutSeconds} seconds`, - }); - case "error": - return error(result.message); - } - } - - default: - return error(`Unknown tool: ${name}`); - } -} - -// ----------------------------------------------------------------------------- -// Server -// ----------------------------------------------------------------------------- - -/** - * Create and start the MCP server on stdio. - * @param baseUrl - Optional HTTP server URL to fetch from (default: http://localhost:4747) - */ -export async function startMcpServer(baseUrl?: string): Promise { - if (baseUrl) { - setHttpBaseUrl(baseUrl); - } - - const server = new Server( - { - name: "agentation", - version: "0.0.1", - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // List available tools - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools: TOOLS }; - }); - - // Handle tool calls - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - try { - return await handleTool(name, args); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return error(message); - } - }); - - // Connect via stdio - const transport = new StdioServerTransport(); - await server.connect(transport); - - // Log startup message with connection details - const isRemote = httpBaseUrl.startsWith("https://") || (!httpBaseUrl.includes("localhost") && !httpBaseUrl.includes("127.0.0.1")); - if (isRemote && apiKey) { - console.error(`[MCP] Agentation MCP server started on stdio (Remote: ${httpBaseUrl}, API key: configured)`); - } else if (isRemote) { - console.error(`[MCP] Agentation MCP server started on stdio (Remote: ${httpBaseUrl}, API key: not configured)`); - } else { - console.error(`[MCP] Agentation MCP server started on stdio (HTTP: ${httpBaseUrl})`); - } -} diff --git a/mcp/src/server/sqlite.ts b/mcp/src/server/sqlite.ts deleted file mode 100644 index ca7d32b4..00000000 --- a/mcp/src/server/sqlite.ts +++ /dev/null @@ -1,867 +0,0 @@ -/** - * SQLite-backed store for sessions, annotations, and events. - * Provides persistence across server restarts. - */ - -import Database from "better-sqlite3"; -import { createHash, randomBytes } from "crypto"; -import { mkdirSync, existsSync } from "fs"; -import { join } from "path"; -import { homedir } from "os"; -import type { - AFSStore, - AFSEvent, - AFSEventType, - Session, - SessionStatus, - SessionWithAnnotations, - Annotation, - AnnotationStatus, - ThreadMessage, - Organization, - User, - UserRole, - ApiKey, - UserContext, -} from "../types.js"; -import { eventBus } from "./events.js"; - -// ----------------------------------------------------------------------------- -// Database Setup -// ----------------------------------------------------------------------------- - -function getDbPath(): string { - const dataDir = join(homedir(), ".agentation"); - if (!existsSync(dataDir)) { - mkdirSync(dataDir, { recursive: true }); - } - return join(dataDir, "store.db"); -} - -function initDatabase(db: Database.Database): void { - db.exec(` - -- Multi-tenant tables - CREATE TABLE IF NOT EXISTS organizations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT - ); - - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - org_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'member', - created_at TEXT NOT NULL, - updated_at TEXT, - FOREIGN KEY (org_id) REFERENCES organizations(id) - ); - - CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - key_prefix TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT, - last_used_at TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) - ); - - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - url TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - updated_at TEXT, - project_id TEXT, - metadata TEXT, - user_id TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) - ); - - CREATE TABLE IF NOT EXISTS annotations ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - x REAL NOT NULL, - y REAL NOT NULL, - comment TEXT NOT NULL, - element TEXT NOT NULL, - element_path TEXT NOT NULL, - timestamp INTEGER NOT NULL, - selected_text TEXT, - bounding_box TEXT, - nearby_text TEXT, - css_classes TEXT, - nearby_elements TEXT, - computed_styles TEXT, - full_path TEXT, - accessibility TEXT, - is_multi_select INTEGER DEFAULT 0, - is_fixed INTEGER DEFAULT 0, - react_components TEXT, - url TEXT, - intent TEXT, - severity TEXT, - status TEXT DEFAULT 'pending', - thread TEXT, - created_at TEXT NOT NULL, - updated_at TEXT, - resolved_at TEXT, - resolved_by TEXT, - author_id TEXT, - FOREIGN KEY (session_id) REFERENCES sessions(id) - ); - - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - timestamp TEXT NOT NULL, - session_id TEXT NOT NULL, - sequence INTEGER NOT NULL UNIQUE, - payload TEXT NOT NULL, - user_id TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) - ); - - -- Indexes - CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id); - CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id); - CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); - CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); - CREATE INDEX IF NOT EXISTS idx_annotations_session ON annotations(session_id); - CREATE INDEX IF NOT EXISTS idx_events_session_seq ON events(session_id, sequence); - CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id); - `); -} - -// ----------------------------------------------------------------------------- -// Helpers -// ----------------------------------------------------------------------------- - -function generateId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -function rowToSession(row: Record): Session & { userId?: string } { - return { - id: row.id as string, - url: row.url as string, - status: row.status as SessionStatus, - createdAt: row.created_at as string, - updatedAt: row.updated_at as string | undefined, - projectId: row.project_id as string | undefined, - metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined, - userId: row.user_id as string | undefined, - }; -} - -function rowToOrganization(row: Record): Organization { - return { - id: row.id as string, - name: row.name as string, - createdAt: row.created_at as string, - updatedAt: row.updated_at as string | undefined, - }; -} - -function rowToUser(row: Record): User { - return { - id: row.id as string, - email: row.email as string, - orgId: row.org_id as string, - role: row.role as UserRole, - createdAt: row.created_at as string, - updatedAt: row.updated_at as string | undefined, - }; -} - -function rowToApiKey(row: Record): ApiKey { - return { - id: row.id as string, - keyPrefix: row.key_prefix as string, - keyHash: row.key_hash as string, - userId: row.user_id as string, - name: row.name as string, - createdAt: row.created_at as string, - expiresAt: row.expires_at as string | undefined, - lastUsedAt: row.last_used_at as string | undefined, - }; -} - -function rowToAnnotation(row: Record): Annotation { - return { - id: row.id as string, - sessionId: row.session_id as string, - x: row.x as number, - y: row.y as number, - comment: row.comment as string, - element: row.element as string, - elementPath: row.element_path as string, - timestamp: row.timestamp as number, - selectedText: row.selected_text as string | undefined, - boundingBox: row.bounding_box ? JSON.parse(row.bounding_box as string) : undefined, - nearbyText: row.nearby_text as string | undefined, - cssClasses: row.css_classes as string | undefined, - nearbyElements: row.nearby_elements as string | undefined, - computedStyles: row.computed_styles as string | undefined, - fullPath: row.full_path as string | undefined, - accessibility: row.accessibility as string | undefined, - isMultiSelect: Boolean(row.is_multi_select), - isFixed: Boolean(row.is_fixed), - reactComponents: row.react_components as string | undefined, - url: row.url as string | undefined, - intent: row.intent as Annotation["intent"], - severity: row.severity as Annotation["severity"], - status: row.status as AnnotationStatus | undefined, - thread: row.thread ? JSON.parse(row.thread as string) : undefined, - createdAt: row.created_at as string | undefined, - updatedAt: row.updated_at as string | undefined, - resolvedAt: row.resolved_at as string | undefined, - resolvedBy: row.resolved_by as Annotation["resolvedBy"], - authorId: row.author_id as string | undefined, - }; -} - -// ----------------------------------------------------------------------------- -// SQLite Store Implementation -// ----------------------------------------------------------------------------- - -export function createSQLiteStore(dbPath?: string): AFSStore { - const db = new Database(dbPath ?? getDbPath()); - db.pragma("journal_mode = WAL"); - initDatabase(db); - - // Restore event sequence from last event - const lastEvent = db.prepare("SELECT MAX(sequence) as seq FROM events").get() as { seq: number | null }; - if (lastEvent?.seq) { - eventBus.setSequence(lastEvent.seq); - } - - // Prepared statements - const stmts = { - // Sessions - insertSession: db.prepare(` - INSERT INTO sessions (id, url, status, created_at, project_id, metadata) - VALUES (@id, @url, @status, @createdAt, @projectId, @metadata) - `), - getSession: db.prepare("SELECT * FROM sessions WHERE id = ?"), - updateSessionStatus: db.prepare(` - UPDATE sessions SET status = @status, updated_at = @updatedAt WHERE id = @id - `), - listSessions: db.prepare("SELECT * FROM sessions ORDER BY created_at DESC"), - - // Annotations - insertAnnotation: db.prepare(` - INSERT INTO annotations ( - id, session_id, x, y, comment, element, element_path, timestamp, - selected_text, bounding_box, nearby_text, css_classes, nearby_elements, - computed_styles, full_path, accessibility, is_multi_select, is_fixed, - react_components, url, intent, severity, status, thread, created_at, - updated_at, resolved_at, resolved_by, author_id - ) VALUES ( - @id, @sessionId, @x, @y, @comment, @element, @elementPath, @timestamp, - @selectedText, @boundingBox, @nearbyText, @cssClasses, @nearbyElements, - @computedStyles, @fullPath, @accessibility, @isMultiSelect, @isFixed, - @reactComponents, @url, @intent, @severity, @status, @thread, @createdAt, - @updatedAt, @resolvedAt, @resolvedBy, @authorId - ) - `), - getAnnotation: db.prepare("SELECT * FROM annotations WHERE id = ?"), - getAnnotationsBySession: db.prepare("SELECT * FROM annotations WHERE session_id = ? ORDER BY timestamp"), - getPendingAnnotations: db.prepare("SELECT * FROM annotations WHERE session_id = ? AND status = 'pending' ORDER BY timestamp"), - getAnnotationsNeedingAttention: db.prepare("SELECT * FROM annotations WHERE session_id = ? AND (status = 'pending' OR (thread IS NOT NULL AND thread != '[]')) ORDER BY timestamp"), - deleteAnnotation: db.prepare("DELETE FROM annotations WHERE id = ?"), - updateAnnotation: db.prepare(` - UPDATE annotations SET - comment = COALESCE(@comment, comment), - status = COALESCE(@status, status), - updated_at = @updatedAt, - resolved_at = COALESCE(@resolvedAt, resolved_at), - resolved_by = COALESCE(@resolvedBy, resolved_by), - thread = COALESCE(@thread, thread), - intent = COALESCE(@intent, intent), - severity = COALESCE(@severity, severity) - WHERE id = @id - `), - - // Events - insertEvent: db.prepare(` - INSERT INTO events (type, timestamp, session_id, sequence, payload) - VALUES (@type, @timestamp, @sessionId, @sequence, @payload) - `), - getEventsSince: db.prepare(` - SELECT * FROM events WHERE session_id = ? AND sequence > ? ORDER BY sequence - `), - pruneOldEvents: db.prepare(` - DELETE FROM events WHERE timestamp < ? - `), - }; - - // Prune events older than retention period on startup - const retentionDays = parseInt(process.env.AGENTATION_EVENT_RETENTION_DAYS || "7", 10); - const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); - stmts.pruneOldEvents.run(cutoff); - - function persistEvent(event: AFSEvent): void { - stmts.insertEvent.run({ - type: event.type, - timestamp: event.timestamp, - sessionId: event.sessionId, - sequence: event.sequence, - payload: JSON.stringify(event.payload), - }); - } - - return { - // Sessions - createSession(url: string, projectId?: string): Session { - const session: Session = { - id: generateId(), - url, - status: "active", - createdAt: new Date().toISOString(), - projectId, - }; - - stmts.insertSession.run({ - id: session.id, - url: session.url, - status: session.status, - createdAt: session.createdAt, - projectId: session.projectId ?? null, - metadata: null, - }); - - const event = eventBus.emit("session.created", session.id, session); - persistEvent(event); - - return session; - }, - - getSession(id: string): Session | undefined { - const row = stmts.getSession.get(id) as Record | undefined; - return row ? rowToSession(row) : undefined; - }, - - getSessionWithAnnotations(id: string): SessionWithAnnotations | undefined { - const sessionRow = stmts.getSession.get(id) as Record | undefined; - if (!sessionRow) return undefined; - - const annotationRows = stmts.getAnnotationsBySession.all(id) as Record[]; - - return { - ...rowToSession(sessionRow), - annotations: annotationRows.map(rowToAnnotation), - }; - }, - - updateSessionStatus(id: string, status: SessionStatus): Session | undefined { - const updatedAt = new Date().toISOString(); - const result = stmts.updateSessionStatus.run({ id, status, updatedAt }); - if (result.changes === 0) return undefined; - - const session = this.getSession(id); - if (session) { - const eventType: AFSEventType = status === "closed" ? "session.closed" : "session.updated"; - const event = eventBus.emit(eventType, id, session); - persistEvent(event); - } - return session; - }, - - listSessions(): Session[] { - const rows = stmts.listSessions.all() as Record[]; - return rows.map(rowToSession); - }, - - // Annotations - addAnnotation( - sessionId: string, - data: Omit - ): Annotation | undefined { - const session = this.getSession(sessionId); - if (!session) return undefined; - - const annotation: Annotation = { - ...data, - id: generateId(), - sessionId, - status: "pending", - createdAt: new Date().toISOString(), - }; - - stmts.insertAnnotation.run({ - id: annotation.id, - sessionId: annotation.sessionId, - x: annotation.x, - y: annotation.y, - comment: annotation.comment, - element: annotation.element, - elementPath: annotation.elementPath, - timestamp: annotation.timestamp, - selectedText: annotation.selectedText ?? null, - boundingBox: annotation.boundingBox ? JSON.stringify(annotation.boundingBox) : null, - nearbyText: annotation.nearbyText ?? null, - cssClasses: annotation.cssClasses ?? null, - nearbyElements: annotation.nearbyElements ?? null, - computedStyles: annotation.computedStyles ?? null, - fullPath: annotation.fullPath ?? null, - accessibility: annotation.accessibility ?? null, - isMultiSelect: annotation.isMultiSelect ? 1 : 0, - isFixed: annotation.isFixed ? 1 : 0, - reactComponents: annotation.reactComponents ?? null, - url: annotation.url ?? null, - intent: annotation.intent ?? null, - severity: annotation.severity ?? null, - status: annotation.status ?? "pending", - thread: annotation.thread ? JSON.stringify(annotation.thread) : null, - createdAt: annotation.createdAt ?? new Date().toISOString(), - updatedAt: null, - resolvedAt: null, - resolvedBy: null, - authorId: annotation.authorId ?? null, - }); - - const event = eventBus.emit("annotation.created", sessionId, annotation); - persistEvent(event); - - return annotation; - }, - - getAnnotation(id: string): Annotation | undefined { - const row = stmts.getAnnotation.get(id) as Record | undefined; - return row ? rowToAnnotation(row) : undefined; - }, - - updateAnnotation( - id: string, - data: Partial> - ): Annotation | undefined { - const existing = this.getAnnotation(id); - if (!existing) return undefined; - - stmts.updateAnnotation.run({ - id, - comment: data.comment ?? null, - status: data.status ?? null, - updatedAt: new Date().toISOString(), - resolvedAt: data.resolvedAt ?? null, - resolvedBy: data.resolvedBy ?? null, - thread: data.thread ? JSON.stringify(data.thread) : null, - intent: data.intent ?? null, - severity: data.severity ?? null, - }); - - const updated = this.getAnnotation(id); - if (updated && existing.sessionId) { - const event = eventBus.emit("annotation.updated", existing.sessionId, updated); - persistEvent(event); - } - return updated; - }, - - updateAnnotationStatus( - id: string, - status: AnnotationStatus, - resolvedBy?: "human" | "agent" - ): Annotation | undefined { - const isResolved = status === "resolved" || status === "dismissed"; - return this.updateAnnotation(id, { - status, - resolvedAt: isResolved ? new Date().toISOString() : undefined, - resolvedBy: isResolved ? (resolvedBy || "agent") : undefined, - }); - }, - - addThreadMessage( - annotationId: string, - role: "human" | "agent", - content: string - ): Annotation | undefined { - const existing = this.getAnnotation(annotationId); - if (!existing) return undefined; - - const message: ThreadMessage = { - id: generateId(), - role, - content, - timestamp: Date.now(), - }; - - const thread = [...(existing.thread || []), message]; - const updated = this.updateAnnotation(annotationId, { thread }); - - if (updated && existing.sessionId) { - const event = eventBus.emit("thread.message", existing.sessionId, updated); - persistEvent(event); - } - - return updated; - }, - - getPendingAnnotations(sessionId: string): Annotation[] { - const rows = stmts.getPendingAnnotations.all(sessionId) as Record[]; - return rows.map(rowToAnnotation); - }, - - getAnnotationsNeedingAttention(sessionId: string): Annotation[] { - const rows = stmts.getAnnotationsNeedingAttention.all(sessionId) as Record[]; - return rows.map(rowToAnnotation).filter((a) => { - // Pending annotations always need attention - if (a.status === "pending") return true; - // Non-pending annotations need attention only if last thread message is from a human - if (a.thread && a.thread.length > 0) { - const lastMessage = a.thread[a.thread.length - 1]; - return lastMessage.role === "human"; - } - return false; - }); - }, - - getSessionAnnotations(sessionId: string): Annotation[] { - const rows = stmts.getAnnotationsBySession.all(sessionId) as Record[]; - return rows.map(rowToAnnotation); - }, - - deleteAnnotation(id: string): Annotation | undefined { - const existing = this.getAnnotation(id); - if (!existing) return undefined; - - stmts.deleteAnnotation.run(id); - - if (existing.sessionId) { - const event = eventBus.emit("annotation.deleted", existing.sessionId, existing); - persistEvent(event); - } - - return existing; - }, - - // Events - getEventsSince(sessionId: string, sequence: number): AFSEvent[] { - const rows = stmts.getEventsSince.all(sessionId, sequence) as Record[]; - return rows.map((row) => ({ - type: row.type as AFSEventType, - timestamp: row.timestamp as string, - sessionId: row.session_id as string, - sequence: row.sequence as number, - payload: JSON.parse(row.payload as string), - })); - }, - - // Lifecycle - close(): void { - db.close(); - }, - }; -} - -// ----------------------------------------------------------------------------- -// Tenant Store Interface -// ----------------------------------------------------------------------------- - -export interface TenantStore { - // Organizations - createOrganization(name: string): Organization; - getOrganization(id: string): Organization | undefined; - - // Users - createUser(email: string, orgId: string, role?: UserRole): User; - getUser(id: string): User | undefined; - getUserByEmail(email: string): User | undefined; - getUsersByOrg(orgId: string): User[]; - - // API Keys - createApiKey(userId: string, name: string, expiresAt?: string): { apiKey: ApiKey; rawKey: string }; - getApiKeyByHash(hash: string): ApiKey | undefined; - listApiKeys(userId: string): ApiKey[]; - deleteApiKey(id: string): boolean; - updateApiKeyLastUsed(id: string): void; - - // User-scoped sessions - createSessionForUser(userId: string, url: string, projectId?: string): Session; - listSessionsForUser(userId: string): Session[]; - getSessionForUser(userId: string, sessionId: string): Session | undefined; - getSessionWithAnnotationsForUser(userId: string, sessionId: string): SessionWithAnnotations | undefined; - - // User-scoped annotations - getPendingAnnotationsForUser(userId: string, sessionId: string): Annotation[]; - getAllPendingForUser(userId: string): Annotation[]; - - // Lifecycle - close(): void; -} - -// ----------------------------------------------------------------------------- -// Tenant Store Implementation -// ----------------------------------------------------------------------------- - -export function createTenantStore(dbPath?: string): TenantStore { - const db = new Database(dbPath ?? getDbPath()); - db.pragma("journal_mode = WAL"); - initDatabase(db); - - // Restore event sequence from last event - const lastEvent = db.prepare("SELECT MAX(sequence) as seq FROM events").get() as { seq: number | null }; - if (lastEvent?.seq) { - eventBus.setSequence(lastEvent.seq); - } - - // Prepared statements for tenant operations - const tenantStmts = { - // Organizations - insertOrg: db.prepare(` - INSERT INTO organizations (id, name, created_at) - VALUES (@id, @name, @createdAt) - `), - getOrg: db.prepare("SELECT * FROM organizations WHERE id = ?"), - - // Users - insertUser: db.prepare(` - INSERT INTO users (id, email, org_id, role, created_at) - VALUES (@id, @email, @orgId, @role, @createdAt) - `), - getUser: db.prepare("SELECT * FROM users WHERE id = ?"), - getUserByEmail: db.prepare("SELECT * FROM users WHERE email = ?"), - getUsersByOrg: db.prepare("SELECT * FROM users WHERE org_id = ?"), - - // API Keys - insertApiKey: db.prepare(` - INSERT INTO api_keys (id, key_prefix, key_hash, user_id, name, created_at, expires_at) - VALUES (@id, @keyPrefix, @keyHash, @userId, @name, @createdAt, @expiresAt) - `), - getApiKeyByHash: db.prepare("SELECT * FROM api_keys WHERE key_hash = ?"), - listApiKeys: db.prepare("SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at DESC"), - deleteApiKey: db.prepare("DELETE FROM api_keys WHERE id = ?"), - updateApiKeyLastUsed: db.prepare("UPDATE api_keys SET last_used_at = ? WHERE id = ?"), - - // User-scoped sessions - insertSessionForUser: db.prepare(` - INSERT INTO sessions (id, url, status, created_at, project_id, metadata, user_id) - VALUES (@id, @url, @status, @createdAt, @projectId, @metadata, @userId) - `), - listSessionsForUser: db.prepare("SELECT * FROM sessions WHERE user_id = ? ORDER BY created_at DESC"), - getSessionForUser: db.prepare("SELECT * FROM sessions WHERE id = ? AND user_id = ?"), - getAnnotationsBySession: db.prepare("SELECT * FROM annotations WHERE session_id = ? ORDER BY timestamp"), - getPendingAnnotationsForSession: db.prepare("SELECT * FROM annotations WHERE session_id = ? AND status = 'pending' ORDER BY timestamp"), - - // Get all pending for a user (across all their sessions) - getAllPendingForUser: db.prepare(` - SELECT a.* FROM annotations a - JOIN sessions s ON a.session_id = s.id - WHERE s.user_id = ? AND a.status = 'pending' - ORDER BY a.timestamp - `), - - // Events - insertEvent: db.prepare(` - INSERT INTO events (type, timestamp, session_id, sequence, payload, user_id) - VALUES (@type, @timestamp, @sessionId, @sequence, @payload, @userId) - `), - - // Prune old events - pruneOldEvents: db.prepare(` - DELETE FROM events WHERE timestamp < ? - `), - }; - - // Prune events older than retention period on startup - const retentionDays = parseInt(process.env.AGENTATION_EVENT_RETENTION_DAYS || "7", 10); - const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); - tenantStmts.pruneOldEvents.run(cutoff); - - function persistEventForUser(event: AFSEvent, userId: string): void { - tenantStmts.insertEvent.run({ - type: event.type, - timestamp: event.timestamp, - sessionId: event.sessionId, - sequence: event.sequence, - payload: JSON.stringify(event.payload), - userId, - }); - } - - return { - // Organizations - createOrganization(name: string): Organization { - const org: Organization = { - id: `org_${generateId()}`, - name, - createdAt: new Date().toISOString(), - }; - - tenantStmts.insertOrg.run({ - id: org.id, - name: org.name, - createdAt: org.createdAt, - }); - - return org; - }, - - getOrganization(id: string): Organization | undefined { - const row = tenantStmts.getOrg.get(id) as Record | undefined; - return row ? rowToOrganization(row) : undefined; - }, - - // Users - createUser(email: string, orgId: string, role: UserRole = "member"): User { - const user: User = { - id: `user_${generateId()}`, - email, - orgId, - role, - createdAt: new Date().toISOString(), - }; - - tenantStmts.insertUser.run({ - id: user.id, - email: user.email, - orgId: user.orgId, - role: user.role, - createdAt: user.createdAt, - }); - - return user; - }, - - getUser(id: string): User | undefined { - const row = tenantStmts.getUser.get(id) as Record | undefined; - return row ? rowToUser(row) : undefined; - }, - - getUserByEmail(email: string): User | undefined { - const row = tenantStmts.getUserByEmail.get(email) as Record | undefined; - return row ? rowToUser(row) : undefined; - }, - - getUsersByOrg(orgId: string): User[] { - const rows = tenantStmts.getUsersByOrg.all(orgId) as Record[]; - return rows.map(rowToUser); - }, - - // API Keys - createApiKey(userId: string, name: string, expiresAt?: string): { apiKey: ApiKey; rawKey: string } { - const id = `key_${generateId()}`; - // Generate a secure random key - const rawKey = `sk_live_${randomBytes(32).toString("base64url")}`; - const keyPrefix = rawKey.substring(0, 12); // "sk_live_xxxx" - - // Hash the key for storage - const keyHash = createHash("sha256").update(rawKey).digest("hex"); - - const apiKey: ApiKey = { - id, - keyPrefix, - keyHash, - userId, - name, - createdAt: new Date().toISOString(), - expiresAt, - }; - - tenantStmts.insertApiKey.run({ - id: apiKey.id, - keyPrefix: apiKey.keyPrefix, - keyHash: apiKey.keyHash, - userId: apiKey.userId, - name: apiKey.name, - createdAt: apiKey.createdAt, - expiresAt: apiKey.expiresAt ?? null, - }); - - return { apiKey, rawKey }; - }, - - getApiKeyByHash(hash: string): ApiKey | undefined { - const row = tenantStmts.getApiKeyByHash.get(hash) as Record | undefined; - return row ? rowToApiKey(row) : undefined; - }, - - listApiKeys(userId: string): ApiKey[] { - const rows = tenantStmts.listApiKeys.all(userId) as Record[]; - return rows.map(rowToApiKey); - }, - - deleteApiKey(id: string): boolean { - const result = tenantStmts.deleteApiKey.run(id); - return result.changes > 0; - }, - - updateApiKeyLastUsed(id: string): void { - tenantStmts.updateApiKeyLastUsed.run(new Date().toISOString(), id); - }, - - // User-scoped sessions - createSessionForUser(userId: string, url: string, projectId?: string): Session { - const session: Session = { - id: generateId(), - url, - status: "active", - createdAt: new Date().toISOString(), - projectId, - }; - - tenantStmts.insertSessionForUser.run({ - id: session.id, - url: session.url, - status: session.status, - createdAt: session.createdAt, - projectId: session.projectId ?? null, - metadata: null, - userId, - }); - - const event = eventBus.emit("session.created", session.id, session); - persistEventForUser(event, userId); - - return session; - }, - - listSessionsForUser(userId: string): Session[] { - const rows = tenantStmts.listSessionsForUser.all(userId) as Record[]; - return rows.map(rowToSession); - }, - - getSessionForUser(userId: string, sessionId: string): Session | undefined { - const row = tenantStmts.getSessionForUser.get(sessionId, userId) as Record | undefined; - return row ? rowToSession(row) : undefined; - }, - - getSessionWithAnnotationsForUser(userId: string, sessionId: string): SessionWithAnnotations | undefined { - const sessionRow = tenantStmts.getSessionForUser.get(sessionId, userId) as Record | undefined; - if (!sessionRow) return undefined; - - const annotationRows = tenantStmts.getAnnotationsBySession.all(sessionId) as Record[]; - - return { - ...rowToSession(sessionRow), - annotations: annotationRows.map(rowToAnnotation), - }; - }, - - // User-scoped annotations - getPendingAnnotationsForUser(userId: string, sessionId: string): Annotation[] { - // First verify the session belongs to the user - const session = this.getSessionForUser(userId, sessionId); - if (!session) return []; - - const rows = tenantStmts.getPendingAnnotationsForSession.all(sessionId) as Record[]; - return rows.map(rowToAnnotation); - }, - - getAllPendingForUser(userId: string): Annotation[] { - const rows = tenantStmts.getAllPendingForUser.all(userId) as Record[]; - return rows.map(rowToAnnotation); - }, - - // Lifecycle - close(): void { - db.close(); - }, - }; -} diff --git a/mcp/src/server/store.ts b/mcp/src/server/store.ts deleted file mode 100644 index b1f066f2..00000000 --- a/mcp/src/server/store.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * Store module - provides persistence for sessions and annotations. - * - * By default uses SQLite (~/.agentation/store.db). - * Falls back to in-memory storage if SQLite fails to initialize. - * - * Usage: - * import { store } from './store.js'; - * const session = store.createSession('http://localhost:3000'); - */ - -import type { - AFSStore, - AFSEvent, - Session, - SessionStatus, - SessionWithAnnotations, - Annotation, - AnnotationStatus, - ThreadMessage, -} from "../types.js"; -import { eventBus } from "./events.js"; - -// ----------------------------------------------------------------------------- -// Store Singleton -// ----------------------------------------------------------------------------- - -let _store: AFSStore | null = null; - -/** - * Get the store instance. Lazily initializes on first access. - */ -export function getStore(): AFSStore { - if (!_store) { - _store = initializeStore(); - } - return _store; -} - -/** - * Initialize the store. Tries SQLite first, falls back to in-memory. - */ -function initializeStore(): AFSStore { - // Check if we should use in-memory only - if (process.env.AGENTATION_STORE === "memory") { - process.stderr.write("[Store] Using in-memory store (AGENTATION_STORE=memory)\n"); - return createMemoryStore(); - } - - try { - // Dynamic import to avoid issues if better-sqlite3 isn't available - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { createSQLiteStore } = require("./sqlite.js"); - const store = createSQLiteStore(); - process.stderr.write("[Store] Using SQLite store (~/.agentation/store.db)\n"); - return store; - } catch (err) { - console.warn("[Store] SQLite unavailable, falling back to in-memory:", (err as Error).message); - return createMemoryStore(); - } -} - -// ----------------------------------------------------------------------------- -// In-Memory Store (fallback) -// ----------------------------------------------------------------------------- - -function createMemoryStore(): AFSStore { - const sessions = new Map(); - const annotations = new Map(); - const events: AFSEvent[] = []; - - function generateId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - } - - return { - createSession(url: string, projectId?: string): Session { - const session: Session = { - id: generateId(), - url, - status: "active", - createdAt: new Date().toISOString(), - projectId, - }; - sessions.set(session.id, session); - - const event = eventBus.emit("session.created", session.id, session); - events.push(event); - - return session; - }, - - getSession(id: string): Session | undefined { - return sessions.get(id); - }, - - getSessionWithAnnotations(id: string): SessionWithAnnotations | undefined { - const session = sessions.get(id); - if (!session) return undefined; - - const sessionAnnotations = Array.from(annotations.values()).filter( - (a) => a.sessionId === id - ); - - return { - ...session, - annotations: sessionAnnotations, - }; - }, - - updateSessionStatus(id: string, status: SessionStatus): Session | undefined { - const session = sessions.get(id); - if (!session) return undefined; - - session.status = status; - session.updatedAt = new Date().toISOString(); - - const eventType = status === "closed" ? "session.closed" : "session.updated"; - const event = eventBus.emit(eventType, id, session); - events.push(event); - - return session; - }, - - listSessions(): Session[] { - return Array.from(sessions.values()); - }, - - addAnnotation( - sessionId: string, - data: Omit - ): Annotation | undefined { - const session = sessions.get(sessionId); - if (!session) return undefined; - - const annotation: Annotation = { - ...data, - id: generateId(), - sessionId, - status: "pending", - createdAt: new Date().toISOString(), - }; - - annotations.set(annotation.id, annotation); - - const event = eventBus.emit("annotation.created", sessionId, annotation); - events.push(event); - - return annotation; - }, - - getAnnotation(id: string): Annotation | undefined { - return annotations.get(id); - }, - - updateAnnotation( - id: string, - data: Partial> - ): Annotation | undefined { - const annotation = annotations.get(id); - if (!annotation) return undefined; - - Object.assign(annotation, data, { updatedAt: new Date().toISOString() }); - - if (annotation.sessionId) { - const event = eventBus.emit("annotation.updated", annotation.sessionId, annotation); - events.push(event); - } - - return annotation; - }, - - updateAnnotationStatus( - id: string, - status: AnnotationStatus, - resolvedBy?: "human" | "agent" - ): Annotation | undefined { - const annotation = annotations.get(id); - if (!annotation) return undefined; - - annotation.status = status; - annotation.updatedAt = new Date().toISOString(); - - if (status === "resolved" || status === "dismissed") { - annotation.resolvedAt = new Date().toISOString(); - annotation.resolvedBy = resolvedBy || "agent"; - } - - if (annotation.sessionId) { - const event = eventBus.emit("annotation.updated", annotation.sessionId, annotation); - events.push(event); - } - - return annotation; - }, - - addThreadMessage( - annotationId: string, - role: "human" | "agent", - content: string - ): Annotation | undefined { - const annotation = annotations.get(annotationId); - if (!annotation) return undefined; - - const message: ThreadMessage = { - id: generateId(), - role, - content, - timestamp: Date.now(), - }; - - if (!annotation.thread) { - annotation.thread = []; - } - annotation.thread.push(message); - annotation.updatedAt = new Date().toISOString(); - - if (annotation.sessionId) { - const event = eventBus.emit("thread.message", annotation.sessionId, annotation); - events.push(event); - } - - return annotation; - }, - - getPendingAnnotations(sessionId: string): Annotation[] { - return Array.from(annotations.values()).filter( - (a) => a.sessionId === sessionId && a.status === "pending" - ); - }, - - getAnnotationsNeedingAttention(sessionId: string): Annotation[] { - return Array.from(annotations.values()).filter((a) => { - if (a.sessionId !== sessionId) return false; - // Pending annotations always need attention - if (a.status === "pending") return true; - // Non-pending annotations need attention if the last thread message is from a human - if (a.thread && a.thread.length > 0) { - const lastMessage = a.thread[a.thread.length - 1]; - return lastMessage.role === "human"; - } - return false; - }); - }, - - getSessionAnnotations(sessionId: string): Annotation[] { - return Array.from(annotations.values()).filter( - (a) => a.sessionId === sessionId - ); - }, - - deleteAnnotation(id: string): Annotation | undefined { - const annotation = annotations.get(id); - if (!annotation) return undefined; - - annotations.delete(id); - - if (annotation.sessionId) { - const event = eventBus.emit("annotation.deleted", annotation.sessionId, annotation); - events.push(event); - } - - return annotation; - }, - - getEventsSince(sessionId: string, sequence: number): AFSEvent[] { - return events.filter( - (e) => e.sessionId === sessionId && e.sequence > sequence - ); - }, - - close(): void { - sessions.clear(); - annotations.clear(); - events.length = 0; - }, - }; -} - -// ----------------------------------------------------------------------------- -// Convenience Exports (delegate to singleton) -// ----------------------------------------------------------------------------- - -export const store = { - get instance() { - return getStore(); - }, -}; - -// Direct function exports for backwards compatibility -export function createSession(url: string, projectId?: string): Session { - return getStore().createSession(url, projectId); -} - -export function getSession(id: string): Session | undefined { - return getStore().getSession(id); -} - -export function getSessionWithAnnotations(id: string): SessionWithAnnotations | undefined { - return getStore().getSessionWithAnnotations(id); -} - -export function updateSessionStatus(id: string, status: SessionStatus): Session | undefined { - return getStore().updateSessionStatus(id, status); -} - -export function listSessions(): Session[] { - return getStore().listSessions(); -} - -export function addAnnotation( - sessionId: string, - data: Omit -): Annotation | undefined { - return getStore().addAnnotation(sessionId, data); -} - -export function getAnnotation(id: string): Annotation | undefined { - return getStore().getAnnotation(id); -} - -export function updateAnnotation( - id: string, - data: Partial> -): Annotation | undefined { - return getStore().updateAnnotation(id, data); -} - -export function updateAnnotationStatus( - id: string, - status: AnnotationStatus, - resolvedBy?: "human" | "agent" -): Annotation | undefined { - return getStore().updateAnnotationStatus(id, status, resolvedBy); -} - -export function addThreadMessage( - annotationId: string, - role: "human" | "agent", - content: string -): Annotation | undefined { - return getStore().addThreadMessage(annotationId, role, content); -} - -export function getPendingAnnotations(sessionId: string): Annotation[] { - return getStore().getPendingAnnotations(sessionId); -} - -export function getAnnotationsNeedingAttention(sessionId: string): Annotation[] { - return getStore().getAnnotationsNeedingAttention(sessionId); -} - -export function getSessionAnnotations(sessionId: string): Annotation[] { - return getStore().getSessionAnnotations(sessionId); -} - -export function deleteAnnotation(id: string): Annotation | undefined { - return getStore().deleteAnnotation(id); -} - -export function getEventsSince(sessionId: string, sequence: number): AFSEvent[] { - return getStore().getEventsSince(sessionId, sequence); -} - -/** - * Clear all data and reset the store. - */ -export function clearAll(): void { - getStore().close(); - _store = null; -} diff --git a/mcp/src/server/tenant-store.ts b/mcp/src/server/tenant-store.ts deleted file mode 100644 index c8962f03..00000000 --- a/mcp/src/server/tenant-store.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Tenant Store - Multi-tenant wrapper for the base store. - * - * Provides user-scoped access to sessions and annotations, - * plus management of organizations, users, and API keys. - * - * Usage: - * import { getTenantStore, hashApiKey } from './tenant-store.js'; - * const store = getTenantStore(); - * const user = store.getUser(userId); - */ - -import { createHash } from "crypto"; -import type { - Organization, - User, - UserRole, - ApiKey, - UserContext, - Session, - SessionWithAnnotations, - Annotation, -} from "../types.js"; - -// Re-export TenantStore type -export type { TenantStore } from "./sqlite.js"; - -// ----------------------------------------------------------------------------- -// Store Singleton -// ----------------------------------------------------------------------------- - -let _tenantStore: import("./sqlite.js").TenantStore | null = null; - -/** - * Get the tenant store instance. Lazily initializes on first access. - */ -export function getTenantStore(): import("./sqlite.js").TenantStore { - if (!_tenantStore) { - _tenantStore = initializeTenantStore(); - } - return _tenantStore; -} - -/** - * Initialize the tenant store. - */ -function initializeTenantStore(): import("./sqlite.js").TenantStore { - try { - // Dynamic import to avoid issues if better-sqlite3 isn't available - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { createTenantStore } = require("./sqlite.js"); - const store = createTenantStore(); - process.stderr.write("[TenantStore] Initialized tenant store\n"); - return store; - } catch (err) { - console.error("[TenantStore] Failed to initialize:", (err as Error).message); - throw err; - } -} - -/** - * Reset the tenant store singleton (for testing). - */ -export function resetTenantStore(): void { - if (_tenantStore) { - _tenantStore.close(); - _tenantStore = null; - } -} - -// ----------------------------------------------------------------------------- -// API Key Utilities -// ----------------------------------------------------------------------------- - -/** - * Hash an API key for lookup. - * Used by auth middleware to find the key in the database. - */ -export function hashApiKey(rawKey: string): string { - return createHash("sha256").update(rawKey).digest("hex"); -} - -/** - * Validate API key format. - */ -export function isValidApiKeyFormat(key: string): boolean { - return key.startsWith("sk_live_") && key.length > 20; -} - -// ----------------------------------------------------------------------------- -// User Context Utilities -// ----------------------------------------------------------------------------- - -/** - * Create a UserContext from a User and Organization. - */ -export function createUserContext(user: User): UserContext { - return { - userId: user.id, - orgId: user.orgId, - email: user.email, - role: user.role, - }; -} - -// ----------------------------------------------------------------------------- -// Convenience Exports (delegate to singleton) -// ----------------------------------------------------------------------------- - -// Organizations -export function createOrganization(name: string): Organization { - return getTenantStore().createOrganization(name); -} - -export function getOrganization(id: string): Organization | undefined { - return getTenantStore().getOrganization(id); -} - -// Users -export function createUser(email: string, orgId: string, role?: UserRole): User { - return getTenantStore().createUser(email, orgId, role); -} - -export function getUser(id: string): User | undefined { - return getTenantStore().getUser(id); -} - -export function getUserByEmail(email: string): User | undefined { - return getTenantStore().getUserByEmail(email); -} - -export function getUsersByOrg(orgId: string): User[] { - return getTenantStore().getUsersByOrg(orgId); -} - -// API Keys -export function createApiKey( - userId: string, - name: string, - expiresAt?: string -): { apiKey: ApiKey; rawKey: string } { - return getTenantStore().createApiKey(userId, name, expiresAt); -} - -export function getApiKeyByHash(hash: string): ApiKey | undefined { - return getTenantStore().getApiKeyByHash(hash); -} - -export function listApiKeys(userId: string): ApiKey[] { - return getTenantStore().listApiKeys(userId); -} - -export function deleteApiKey(id: string): boolean { - return getTenantStore().deleteApiKey(id); -} - -export function updateApiKeyLastUsed(id: string): void { - return getTenantStore().updateApiKeyLastUsed(id); -} - -// User-scoped sessions -export function createSessionForUser( - userId: string, - url: string, - projectId?: string -): Session { - return getTenantStore().createSessionForUser(userId, url, projectId); -} - -export function listSessionsForUser(userId: string): Session[] { - return getTenantStore().listSessionsForUser(userId); -} - -export function getSessionForUser( - userId: string, - sessionId: string -): Session | undefined { - return getTenantStore().getSessionForUser(userId, sessionId); -} - -export function getSessionWithAnnotationsForUser( - userId: string, - sessionId: string -): SessionWithAnnotations | undefined { - return getTenantStore().getSessionWithAnnotationsForUser(userId, sessionId); -} - -// User-scoped annotations -export function getPendingAnnotationsForUser( - userId: string, - sessionId: string -): Annotation[] { - return getTenantStore().getPendingAnnotationsForUser(userId, sessionId); -} - -export function getAllPendingForUser(userId: string): Annotation[] { - return getTenantStore().getAllPendingForUser(userId); -} diff --git a/mcp/src/types.ts b/mcp/src/types.ts deleted file mode 100644 index 5b00590b..00000000 --- a/mcp/src/types.ts +++ /dev/null @@ -1,193 +0,0 @@ -// ============================================================================= -// Shared Types -// ============================================================================= - -export type Annotation = { - id: string; - x: number; // % of viewport width - y: number; // px from top of document (absolute) OR viewport (if isFixed) - comment: string; - element: string; - elementPath: string; - timestamp: number; - selectedText?: string; - boundingBox?: { x: number; y: number; width: number; height: number }; - nearbyText?: string; - cssClasses?: string; - nearbyElements?: string; - computedStyles?: string; - fullPath?: string; - accessibility?: string; - isMultiSelect?: boolean; // true if created via drag selection - isFixed?: boolean; // true if element has fixed/sticky positioning (marker stays fixed) - reactComponents?: string; // React component hierarchy (e.g. " - {/* MCP Connection section */} + {/* Server Connection section */}
- MCP Connection - + Server Connection + @@ -4181,7 +4181,7 @@ export function PageFeedbackToolbarCSS({ className={`${styles.automationDescription} ${!isDarkMode ? styles.light : ""}`} style={{ paddingBottom: 6 }} > - MCP connection allows agents to receive and act on + Server connection allows agents to receive and act on annotations.{" "} }).env?. + STORYBOOK_AGENTATION_ENDPOINT as string | undefined) || + "http://127.0.0.1:4747"; + const meta: Meta = { title: "PageToolbar", component: PageFeedbackToolbarCSS, args: { + endpoint: storybookEndpoint, componentEditor: "neovim", neovimBridgeUrl: "http://127.0.0.1:8787", }, - parameters: { - localStorage: localStorageForStorybook({ - [STORAGE_KEY]: [], - }), - }, decorators: [ (Story) => (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0cbfe52..591bc248 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,34 +8,6 @@ importers: .: {} - mcp: - dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.26.0(zod@3.25.76) - better-sqlite3: - specifier: ^12.6.2 - version: 12.6.2 - zod: - specifier: ^3.23.0 - version: 3.25.76 - devDependencies: - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 - '@types/node': - specifier: ^20.0.0 - version: 20.19.32 - tsup: - specifier: ^8.0.0 - version: 8.5.1(postcss@8.5.6)(typescript@5.9.3) - typescript: - specifier: ^5.0.0 - version: 5.9.3 - vitest: - specifier: ^2.1.9 - version: 2.1.9(@types/node@20.19.32)(jsdom@25.0.1)(sass-embedded@1.97.3)(sass@1.97.3) - package: devDependencies: '@alexgorbatchev/storybook-addon-localstorage': @@ -291,204 +263,102 @@ packages: resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} engines: {node: '>17.0.0'} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} @@ -501,12 +371,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} @@ -519,12 +383,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} @@ -537,60 +395,30 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4': resolution: {integrity: sha512-6PyZBYKnnVNqOSB0YFly+62R7dmov8segT27A+RVTBVd4iAE6kbW9QBJGlyR2yG4D4ohzhZSTIu7BK1UTtmFFA==} peerDependencies: @@ -616,16 +444,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - '@next/env@14.2.35': resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} @@ -1058,9 +876,6 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1119,26 +934,12 @@ packages: '@vitest/browser': optional: true - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - '@vitest/mocker@4.0.18': resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: @@ -1150,49 +951,30 @@ packages: vite: optional: true - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} - '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -1202,17 +984,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1245,9 +1016,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -1255,20 +1023,6 @@ packages: beautiful-mermaid@0.1.3: resolution: {integrity: sha512-lVEHCnlVLtVRbO03T+D9kY5BZlkpvFU6F18LEu2N2VLB0eo5evG1FJWg3SvREErKY+zZ7j9f+cNsgtiOhYI2Nw==} - better-sqlite3@12.6.2: - resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - brace-expansion@5.0.4: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} @@ -1278,9 +1032,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1295,10 +1046,6 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1307,10 +1054,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001768: resolution: {integrity: sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==} @@ -1330,9 +1073,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -1358,33 +1098,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1416,18 +1132,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -1444,10 +1152,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1470,9 +1174,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} @@ -1480,13 +1181,6 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1516,11 +1210,6 @@ packages: esbuild: '>=0.27.2' sass-embedded: ^1.97.2 - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -1530,9 +1219,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1548,42 +1234,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1593,13 +1247,6 @@ packages: picomatch: optional: true - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -1607,10 +1254,6 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - framer-motion@12.33.0: resolution: {integrity: sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==} peerDependencies: @@ -1625,13 +1268,6 @@ packages: react-dom: optional: true - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1660,9 +1296,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -1690,10 +1323,6 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.11.7: - resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} - engines: {node: '>=16.9.0'} - html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1701,10 +1330,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1717,19 +1342,12 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - immutable@5.1.4: resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} @@ -1737,20 +1355,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} - engines: {node: '>= 12'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -1776,16 +1380,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1798,9 +1396,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1825,12 +1420,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -1889,34 +1478,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1932,9 +1501,6 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -1959,13 +1525,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - next@14.2.35: resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} engines: {node: '>=18.17.0'} @@ -1984,10 +1543,6 @@ packages: sass: optional: true - node-abi@3.87.0: - resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} - engines: {node: '>=10'} - node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -2001,20 +1556,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -2022,14 +1566,6 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2037,12 +1573,6 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2065,10 +1595,6 @@ packages: resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} hasBin: true - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2148,12 +1674,6 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2163,33 +1683,10 @@ packages: peerDependencies: react: '>=16.0.0' - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} - engines: {node: '>=0.6'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -2215,10 +1712,6 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2231,10 +1724,6 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2249,10 +1738,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -2266,9 +1751,6 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2410,49 +1892,8 @@ packages: engines: {node: '>=10'} hasBin: true - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} @@ -2473,10 +1914,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2496,9 +1933,6 @@ packages: string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2511,10 +1945,6 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - styled-jsx@5.1.1: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -2556,13 +1986,6 @@ packages: resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} engines: {node: '>=16.0.0'} - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2587,14 +2010,6 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -2603,10 +2018,6 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} - engines: {node: '>=14.0.0'} - tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -2618,10 +2029,6 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -2671,13 +2078,6 @@ packages: typescript: optional: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2689,10 +2089,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -2714,46 +2110,6 @@ packages: varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2794,31 +2150,6 @@ packages: yaml: optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2877,19 +2208,11 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -2916,14 +2239,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} - peerDependencies: - zod: ^3.25 || ^4 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - snapshots: '@adobe/css-tools@4.4.4': {} @@ -3082,157 +2397,84 @@ snapshots: '@dagrejs/graphlib@2.2.4': {} - '@esbuild/aix-ppc64@0.21.5': - optional: true - '@esbuild/aix-ppc64@0.27.2': optional: true - '@esbuild/android-arm64@0.21.5': - optional: true - '@esbuild/android-arm64@0.27.2': optional: true - '@esbuild/android-arm@0.21.5': - optional: true - '@esbuild/android-arm@0.27.2': optional: true - '@esbuild/android-x64@0.21.5': - optional: true - '@esbuild/android-x64@0.27.2': optional: true - '@esbuild/darwin-arm64@0.21.5': - optional: true - '@esbuild/darwin-arm64@0.27.2': optional: true - '@esbuild/darwin-x64@0.21.5': - optional: true - '@esbuild/darwin-x64@0.27.2': optional: true - '@esbuild/freebsd-arm64@0.21.5': - optional: true - '@esbuild/freebsd-arm64@0.27.2': optional: true - '@esbuild/freebsd-x64@0.21.5': - optional: true - '@esbuild/freebsd-x64@0.27.2': optional: true - '@esbuild/linux-arm64@0.21.5': - optional: true - '@esbuild/linux-arm64@0.27.2': optional: true - '@esbuild/linux-arm@0.21.5': - optional: true - '@esbuild/linux-arm@0.27.2': optional: true - '@esbuild/linux-ia32@0.21.5': - optional: true - '@esbuild/linux-ia32@0.27.2': optional: true - '@esbuild/linux-loong64@0.21.5': - optional: true - '@esbuild/linux-loong64@0.27.2': optional: true - '@esbuild/linux-mips64el@0.21.5': - optional: true - '@esbuild/linux-mips64el@0.27.2': optional: true - '@esbuild/linux-ppc64@0.21.5': - optional: true - '@esbuild/linux-ppc64@0.27.2': optional: true - '@esbuild/linux-riscv64@0.21.5': - optional: true - '@esbuild/linux-riscv64@0.27.2': optional: true - '@esbuild/linux-s390x@0.21.5': - optional: true - '@esbuild/linux-s390x@0.27.2': optional: true - '@esbuild/linux-x64@0.21.5': - optional: true - '@esbuild/linux-x64@0.27.2': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true - '@esbuild/netbsd-x64@0.21.5': - optional: true - '@esbuild/netbsd-x64@0.27.2': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true - '@esbuild/openbsd-x64@0.21.5': - optional: true - '@esbuild/openbsd-x64@0.27.2': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true - '@esbuild/sunos-x64@0.21.5': - optional: true - '@esbuild/sunos-x64@0.27.2': optional: true - '@esbuild/win32-arm64@0.21.5': - optional: true - '@esbuild/win32-arm64@0.27.2': optional: true - '@esbuild/win32-ia32@0.21.5': - optional: true - '@esbuild/win32-ia32@0.27.2': optional: true - '@esbuild/win32-x64@0.21.5': - optional: true - '@esbuild/win32-x64@0.27.2': optional: true - '@hono/node-server@1.19.9(hono@4.11.7)': - dependencies: - hono: 4.11.7 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3))': dependencies: glob: 13.0.6 @@ -3260,28 +2502,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.9(hono@4.11.7) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.11.7 - jose: 6.1.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - supports-color - '@next/env@14.2.35': {} '@next/swc-darwin-arm64@14.2.33': @@ -3607,10 +2827,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 20.19.32 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3699,13 +2915,6 @@ snapshots: optionalDependencies: '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3))(vitest@4.0.18) - '@vitest/expect@2.1.9': - dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - tinyrainbow: 1.2.0 - '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -3723,14 +2932,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3))': dependencies: '@vitest/spy': 4.0.18 @@ -3739,10 +2940,6 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3) - '@vitest/pretty-format@2.1.9': - dependencies: - tinyrainbow: 1.2.0 - '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -3751,44 +2948,23 @@ snapshots: dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@2.1.9': - dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 - '@vitest/runner@4.0.18': dependencies: '@vitest/utils': 4.0.18 pathe: 2.0.3 - '@vitest/snapshot@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - magic-string: 0.30.21 - pathe: 1.1.2 - '@vitest/snapshot@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 - '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 '@vitest/spy@4.0.18': {} - '@vitest/utils@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - loupe: 3.2.1 - tinyrainbow: 1.2.0 - '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -3800,26 +2976,10 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - acorn@8.15.0: {} agent-base@7.1.4: {} - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -3846,43 +3006,12 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.9.19: {} beautiful-mermaid@0.1.3: dependencies: '@dagrejs/dagre': 1.1.8 - better-sqlite3@12.6.2: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.14.1 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -3895,11 +3024,6 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -3913,8 +3037,6 @@ snapshots: dependencies: streamsearch: 1.1.0 - bytes@3.1.2: {} - cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -3922,11 +3044,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001768: {} chai@5.3.3: @@ -3945,8 +3062,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@1.1.4: {} - client-only@0.0.1: {} clsx@2.1.1: {} @@ -3963,27 +3078,8 @@ snapshots: consola@3.4.2: {} - content-disposition@1.0.1: {} - - content-type@1.0.5: {} - convert-source-map@2.0.0: {} - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -4006,14 +3102,8 @@ snapshots: decimal.js@10.6.0: {} - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} - default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -4025,11 +3115,10 @@ snapshots: delayed-stream@1.0.0: {} - depd@2.0.0: {} - dequal@2.0.3: {} - detect-libc@2.1.2: {} + detect-libc@2.1.2: + optional: true doctrine@3.0.0: dependencies: @@ -4045,18 +3134,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - ee-first@1.1.1: {} - electron-to-chromium@1.5.286: {} empathic@2.0.0: {} - encodeurl@2.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - entities@6.0.1: {} es-define-property@1.0.1: {} @@ -4083,32 +3164,6 @@ snapshots: sass: 1.97.3 sass-embedded: 1.97.3 - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -4140,8 +3195,6 @@ snapshots: escalade@3.2.0: {} - escape-html@1.0.3: {} - esprima@4.0.1: {} estree-walker@2.0.2: {} @@ -4152,77 +3205,12 @@ snapshots: esutils@2.0.3: {} - etag@1.8.1: {} - - eventsource-parser@3.0.6: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - - expand-template@2.0.3: {} - expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.0.1 - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.14.1 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-uri@3.1.0: {} - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 - file-uri-to-path@1.0.0: {} - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -4237,8 +3225,6 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - forwarded@0.2.0: {} - framer-motion@12.33.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.33.0 @@ -4248,10 +3234,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - fresh@2.0.0: {} - - fs-constants@1.0.0: {} - fsevents@2.3.2: optional: true @@ -4284,8 +3266,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - github-from-package@0.0.0: {} - glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -4308,22 +3288,12 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.11.7: {} - html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 html-escaper@2.0.2: {} - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -4342,28 +3312,14 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 - ieee754@1.2.1: {} - immutable@5.1.4: {} indent-string@4.0.0: {} - inherits@2.0.4: {} - - ini@1.3.8: {} - - ip-address@10.0.1: {} - - ipaddr.js@1.9.1: {} - is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -4384,14 +3340,10 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-promise@4.0.0: {} - is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4405,8 +3357,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jose@6.1.3: {} - joycon@3.1.1: {} js-tokens@10.0.0: {} @@ -4443,10 +3393,6 @@ snapshots: jsesc@3.1.0: {} - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - json5@2.2.3: {} lilconfig@3.1.3: {} @@ -4491,24 +3437,12 @@ snapshots: math-intrinsics@1.1.0: {} - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: dependencies: mime-db: 1.52.0 - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-response@3.1.0: {} - min-indent@1.0.1: {} minimatch@10.2.4: @@ -4519,8 +3453,6 @@ snapshots: minipass@7.1.3: {} - mkdirp-classic@0.5.3: {} - mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -4546,10 +3478,6 @@ snapshots: nanoid@3.3.11: {} - napi-build-utils@2.0.0: {} - - negotiator@1.0.0: {} - next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3): dependencies: '@next/env': 14.2.35 @@ -4576,10 +3504,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - node-abi@3.87.0: - dependencies: - semver: 7.7.3 - node-addon-api@7.1.1: optional: true @@ -4589,18 +3513,8 @@ snapshots: object-assign@4.1.1: {} - object-inspect@1.13.4: {} - obug@2.1.1: {} - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - open@10.2.0: dependencies: default-browser: 5.5.0 @@ -4612,10 +3526,6 @@ snapshots: dependencies: entities: 6.0.1 - parseurl@1.3.3: {} - - path-key@3.1.1: {} - path-parse@1.0.7: {} path-scurry@2.0.2: @@ -4623,10 +3533,6 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-to-regexp@8.3.0: {} - - pathe@1.1.2: {} - pathe@2.0.3: {} pathval@2.0.1: {} @@ -4641,8 +3547,6 @@ snapshots: dependencies: pngjs: 7.0.0 - pkce-challenge@5.0.1: {} - pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -4717,21 +3621,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.87.0 - pump: 3.0.3 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -4744,38 +3633,8 @@ snapshots: clsx: 2.1.1 react: 18.3.1 - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@2.3.1: {} - qs@6.14.1: - dependencies: - side-channel: 1.1.0 - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4809,12 +3668,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@4.1.2: {} recast@0.23.11: @@ -4830,8 +3683,6 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - require-from-string@2.0.2: {} - resolve-from@5.0.0: {} resolve@1.22.11: @@ -4871,16 +3722,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.3.0 - transitivePeerDependencies: - - supports-color - rrweb-cssom@0.7.1: {} rrweb-cssom@0.8.0: {} @@ -4891,8 +3732,6 @@ snapshots: dependencies: tslib: 2.8.1 - safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} sass-embedded-all-unknown@1.97.3: @@ -5002,77 +3841,8 @@ snapshots: semver@7.7.3: {} - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -5087,8 +3857,6 @@ snapshots: stackback@0.0.2: {} - statuses@2.0.2: {} - std-env@3.10.0: {} storybook@10.2.17(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -5116,10 +3884,6 @@ snapshots: string-hash@1.1.3: {} - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-bom@3.0.0: {} strip-indent@3.0.0: @@ -5128,8 +3892,6 @@ snapshots: strip-indent@4.1.1: {} - strip-json-comments@2.0.1: {} - styled-jsx@5.1.1(react@18.3.1): dependencies: client-only: 0.0.1 @@ -5163,21 +3925,6 @@ snapshots: sync-message-port@1.2.0: {} - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.3 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -5199,16 +3946,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.1.1: {} - - tinyrainbow@1.2.0: {} - tinyrainbow@2.0.0: {} tinyrainbow@3.0.3: {} - tinyspy@3.0.2: {} - tinyspy@4.0.4: {} tldts-core@6.1.86: {} @@ -5217,8 +3958,6 @@ snapshots: dependencies: tldts-core: 6.1.86 - toidentifier@1.0.1: {} - totalist@3.0.1: {} tough-cookie@5.1.2: @@ -5271,24 +4010,12 @@ snapshots: - tsx - yaml - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - typescript@5.9.3: {} ufo@1.6.3: {} undici-types@6.21.0: {} - unpipe@1.0.0: {} - unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -5310,37 +4037,6 @@ snapshots: varint@6.0.0: {} - vary@1.1.2: {} - - vite-node@2.1.9(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.57.1 - optionalDependencies: - '@types/node': 20.19.32 - fsevents: 2.3.3 - sass: 1.97.3 - sass-embedded: 1.97.3 - vite@7.3.1(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3): dependencies: esbuild: 0.27.2 @@ -5355,42 +4051,6 @@ snapshots: sass: 1.97.3 sass-embedded: 1.97.3 - vitest@2.1.9(@types/node@20.19.32)(jsdom@25.0.1)(sass-embedded@1.97.3)(sass@1.97.3): - dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3) - vite-node: 2.1.9(@types/node@20.19.32)(sass-embedded@1.97.3)(sass@1.97.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 20.19.32 - jsdom: 25.0.1 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - vitest@4.0.18(@types/node@20.19.32)(@vitest/browser-playwright@4.0.18)(jsdom@25.0.1)(sass-embedded@1.97.3)(sass@1.97.3): dependencies: '@vitest/expect': 4.0.18 @@ -5449,17 +4109,11 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 - which@2.0.2: - dependencies: - isexe: 2.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - wrappy@1.0.2: {} - ws@8.19.0: {} wsl-utils@0.1.0: @@ -5471,9 +4125,3 @@ snapshots: xmlchars@2.2.0: {} yallist@3.1.1: {} - - zod-to-json-schema@3.25.1(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 645858de..b957d2de 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,3 @@ packages: - 'package' - 'package/example' - - 'mcp' diff --git a/router/README.md b/router/README.md deleted file mode 100644 index 79ab1660..00000000 --- a/router/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Agentation Router - -Local routing daemon for Neovim bridge sessions. - -## What it does - -- accepts frontend `open`/`ping` requests at a single endpoint -- tracks active Neovim sessions per project -- routes requests to the best matching Neovim session - -## Binary - -The binary name is: - -```bash -agentation-router -``` - -## Build - -From repo root: - -```bash -just build-router -``` - -Or directly: - -```bash -cd router -go build -o ../bin/agentation-router ./cmd/agentation-router -``` - -## Test - -From repo root: - -```bash -just test-router -``` - -Or directly: - -```bash -cd router -go test ./... -``` - -## Run - -Foreground server: - -```bash -./bin/agentation-router serve -``` - -Managed process commands: - -```bash -./bin/agentation-router start # background (default) -./bin/agentation-router start --foreground -./bin/agentation-router status -./bin/agentation-router stop -``` - -Defaults: - -- address: `127.0.0.1:8787` -- session stale timeout: `20s` -- pid file: `${TMPDIR:-/tmp}/agentation-router.pid` -- log file: `${TMPDIR:-/tmp}/agentation-router.log` - -Optional overrides: - -- `AGENTATION_ROUTER_PID_FILE` -- `AGENTATION_ROUTER_LOG_FILE` - -## API - -- `GET /health` -- `GET /sessions` -- `POST /register` -- `POST /unregister` -- `GET|POST /ping` -- `GET|POST /open` - -### Register payload - -```json -{ - "sessionId": "nvim-abc", - "projectId": "project-123", - "repoId": "repo-123", - "root": "/Users/alex/dev/project", - "displayName": "project", - "endpoint": "http://127.0.0.1:9011" -} -``` - -### Open query parameters - -- `projectId` (optional) -- `path` (required) -- `line` (optional, default `1`) -- `column` (optional, default `1`) -- `origin` (optional) - -## Service snippets (optional) - -### launchd (macOS) - -Create `~/Library/LaunchAgents/dev.agentation.router.plist` and point `ProgramArguments` to the built binary: - -```xml -ProgramArguments - - /absolute/path/to/bin/agentation-router - -``` - -Then load: - -```bash -launchctl load ~/Library/LaunchAgents/dev.agentation.router.plist -``` - -### systemd (Linux user service) - -```ini -[Unit] -Description=Agentation Router - -[Service] -ExecStart=/absolute/path/to/bin/agentation-router -Restart=on-failure - -[Install] -WantedBy=default.target -``` - -## Project identity edge cases - -- If no git repository exists, the Neovim bridge falls back to hashing the configured project root path. -- Git worktrees naturally produce distinct project IDs because each worktree has its own real path. -- Symlinked project paths are resolved via realpath before hashing to keep identity stable. diff --git a/router/cmd/agentation-router/main.go b/router/cmd/agentation-router/main.go deleted file mode 100644 index 4df6cd56..00000000 --- a/router/cmd/agentation-router/main.go +++ /dev/null @@ -1,372 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "log/slog" - "net/http" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" - - "github.com/benjitaylor/agentation/router/internal/config" - httpserver "github.com/benjitaylor/agentation/router/internal/http" - routerpkg "github.com/benjitaylor/agentation/router/internal/router" - "github.com/benjitaylor/agentation/router/internal/store" -) - -const shutdownTimeout = 5 * time.Second - -func main() { - arguments := os.Args[1:] - if len(arguments) == 0 { - printUsage() - os.Exit(0) - } - - subcommand := arguments[0] - subcommandArgs := arguments[1:] - - switch subcommand { - case "serve": - os.Exit(runServe(subcommandArgs)) - case "start": - os.Exit(runStart(subcommandArgs)) - case "stop": - os.Exit(runStop()) - case "status": - os.Exit(runStatus()) - case "help", "--help", "-h": - printUsage() - os.Exit(0) - default: - os.Exit(runServe(arguments)) - } -} - -func runServe(args []string) int { - cfg := config.Load(args) - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) - - registry := store.NewRegistry(cfg.SessionStaleAfter) - forwarder := routerpkg.NewForwarder(cfg.ForwardTimeout) - server := httpserver.NewServer(cfg, logger, registry, forwarder) - - go func() { - logger.Info("agentation router listening", "address", cfg.Address) - error := server.ListenAndServe() - if error != nil && !errors.Is(error, http.ErrServerClosed) { - logger.Error("router server failed", "error", error) - os.Exit(1) - } - }() - - signalContext, stopSignal := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stopSignal() - - <-signalContext.Done() - shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancelShutdown() - - if error := server.Shutdown(shutdownContext); error != nil { - logger.Error("router shutdown failed", "error", error) - return 1 - } - - logger.Info("agentation router stopped") - return 0 -} - -func runStart(args []string) int { - foreground, serveArgs := parseStartArgs(args) - - if pid, ok := loadRunningPID(); ok { - fmt.Printf("agentation-router already running (pid %d)\n", pid) - return 0 - } - - if foreground { - fmt.Println("starting agentation-router in foreground") - return runServe(serveArgs) - } - - executablePath, error := os.Executable() - if error != nil { - fmt.Fprintf(os.Stderr, "failed to resolve executable path: %v\n", error) - return 1 - } - - logPath := logFilePath() - if error := os.MkdirAll(filepath.Dir(logPath), 0o755); error != nil { - fmt.Fprintf(os.Stderr, "failed to create log directory: %v\n", error) - return 1 - } - logFile, error := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if error != nil { - fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", error) - return 1 - } - defer logFile.Close() - - commandArgs := append([]string{"serve"}, serveArgs...) - command := exec.Command(executablePath, commandArgs...) - command.Stdout = logFile - command.Stderr = logFile - - if error := command.Start(); error != nil { - fmt.Fprintf(os.Stderr, "failed to start agentation-router: %v\n", error) - return 1 - } - - pid := command.Process.Pid - if error := writePID(pid); error != nil { - fmt.Fprintf(os.Stderr, "failed to write pid file: %v\n", error) - _ = command.Process.Kill() - return 1 - } - - time.Sleep(250 * time.Millisecond) - if !isProcessRunning(pid) { - _ = removePIDFile() - fmt.Fprintln(os.Stderr, "agentation-router failed to stay running") - return 1 - } - - fmt.Printf("agentation-router started in background (pid %d)\n", pid) - fmt.Printf("log: %s\n", logPath) - return 0 -} - -func runStop() int { - pid, error := readPID() - if error != nil || !isProcessRunning(pid) { - fallbackPID, ok := findRunningRouterPIDByScan() - if !ok { - _ = removePIDFile() - fmt.Println("agentation-router is not running") - return 0 - } - pid = fallbackPID - } - - process, error := os.FindProcess(pid) - if error != nil { - fmt.Fprintf(os.Stderr, "failed to find agentation-router process: %v\n", error) - return 1 - } - - if error := process.Signal(os.Interrupt); error != nil { - if killError := process.Kill(); killError != nil { - fmt.Fprintf(os.Stderr, "failed to stop agentation-router: %v\n", killError) - return 1 - } - } - - for attempt := 0; attempt < 30; attempt++ { - if !isProcessRunning(pid) { - _ = removePIDFile() - fmt.Printf("agentation-router stopped (pid %d)\n", pid) - return 0 - } - time.Sleep(100 * time.Millisecond) - } - - if error := process.Kill(); error != nil { - fmt.Fprintf(os.Stderr, "failed to kill agentation-router: %v\n", error) - return 1 - } - - _ = removePIDFile() - fmt.Printf("agentation-router stopped (pid %d)\n", pid) - return 0 -} - -func runStatus() int { - pid, error := readPID() - if error != nil || !isProcessRunning(pid) { - fallbackPID, ok := findRunningRouterPIDByScan() - if !ok { - _ = removePIDFile() - fmt.Println("agentation-router not running") - return 1 - } - pid = fallbackPID - _ = writePID(pid) - } - - fmt.Printf("agentation-router running (pid %d)\n", pid) - return 0 -} - -func parseStartArgs(args []string) (bool, []string) { - foreground := false - serveArgs := make([]string, 0, len(args)) - for _, arg := range args { - switch arg { - case "--foreground", "foreground": - foreground = true - case "--background", "background": - foreground = false - default: - serveArgs = append(serveArgs, arg) - } - } - return foreground, serveArgs -} - -func pidFilePath() string { - path := strings.TrimSpace(os.Getenv("AGENTATION_ROUTER_PID_FILE")) - if path != "" { - return path - } - return filepath.Join(os.TempDir(), "agentation-router.pid") -} - -func logFilePath() string { - path := strings.TrimSpace(os.Getenv("AGENTATION_ROUTER_LOG_FILE")) - if path != "" { - return path - } - return filepath.Join(os.TempDir(), "agentation-router.log") -} - -func writePID(pid int) error { - path := pidFilePath() - if error := os.MkdirAll(filepath.Dir(path), 0o755); error != nil { - return error - } - return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o644) -} - -func readPID() (int, error) { - contents, error := os.ReadFile(pidFilePath()) - if error != nil { - return 0, error - } - - raw := strings.TrimSpace(string(contents)) - if raw == "" { - return 0, fmt.Errorf("pid file is empty") - } - - pid, error := strconv.Atoi(raw) - if error != nil { - return 0, error - } - if pid <= 0 { - return 0, fmt.Errorf("pid is invalid") - } - - return pid, nil -} - -func removePIDFile() error { - error := os.Remove(pidFilePath()) - if errors.Is(error, os.ErrNotExist) { - return nil - } - return error -} - -func isProcessRunning(pid int) bool { - if pid <= 0 { - return false - } - - process, error := os.FindProcess(pid) - if error != nil { - return false - } - - error = process.Signal(syscall.Signal(0)) - if error == nil { - return true - } - - message := strings.ToLower(error.Error()) - if strings.Contains(message, "process already finished") || strings.Contains(message, "no such process") { - return false - } - - return true -} - -func loadRunningPID() (int, bool) { - pid, error := readPID() - if error == nil && isProcessRunning(pid) { - return pid, true - } - - fallbackPID, ok := findRunningRouterPIDByScan() - if !ok { - _ = removePIDFile() - return 0, false - } - - _ = writePID(fallbackPID) - return fallbackPID, true -} - -func findRunningRouterPIDByScan() (int, bool) { - output, error := exec.Command("pgrep", "-f", "agentation-router").Output() - if error != nil { - return 0, false - } - - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - for _, line := range lines { - value := strings.TrimSpace(line) - if value == "" { - continue - } - - pid, parseError := strconv.Atoi(value) - if parseError != nil { - continue - } - if pid <= 0 || pid == os.Getpid() { - continue - } - if !isProcessRunning(pid) { - continue - } - - commandOutput, commandError := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() - if commandError != nil { - continue - } - - commandLine := strings.TrimSpace(string(commandOutput)) - if commandLine == "" { - continue - } - - if strings.Contains(commandLine, "agentation-router start") || - strings.Contains(commandLine, "agentation-router stop") || - strings.Contains(commandLine, "agentation-router status") { - continue - } - - if strings.Contains(commandLine, "agentation-router serve") || - strings.HasSuffix(commandLine, "agentation-router") || - strings.HasSuffix(commandLine, "bin/agentation-router") { - return pid, true - } - } - - return 0, false -} - -func printUsage() { - fmt.Println("agentation-router commands:") - fmt.Println(" start [--foreground|--background] [serve flags]") - fmt.Println(" stop") - fmt.Println(" status") - fmt.Println(" serve [flags] (run foreground server)") -} diff --git a/router/go.mod b/router/go.mod deleted file mode 100644 index 44211179..00000000 --- a/router/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/benjitaylor/agentation/router - -go 1.24.0 diff --git a/skills/agentation-fix-loop/SKILL.md b/skills/agentation-fix-loop/SKILL.md new file mode 100644 index 00000000..ff31c0f0 --- /dev/null +++ b/skills/agentation-fix-loop/SKILL.md @@ -0,0 +1,146 @@ +--- +name: agentation-fix-loop +description: >- + Watch for Agentation annotations and fix each one using the Agentation CLI. + Runs `agentation watch` in a loop — acknowledges each annotation, makes the + code fix, then resolves it. Use when the user says "watch annotations", + "fix annotations", "annotation loop", "agentation fix loop", or wants + autonomous processing of design feedback from the Agentation toolbar. +targets: + - '*' +--- + +# Agentation Fix Loop (CLI) + +Watch for annotations from the Agentation toolbar and fix each one in the codebase using the `agentation` CLI. + +## Preflight (required) + +1. Ensure CLI exists: + +```bash +command -v agentation >/dev/null || { echo "agentation CLI not found"; exit 1; } +``` + +2. Ensure server is reachable (default `http://127.0.0.1:4747`): + +```bash +agentation pending --json >/dev/null +``` + +If unreachable, start Agentation first: + +```bash +agentation start --background +# or foreground during debugging +agentation start --foreground +``` + +If you only want the HTTP API without router for this run: + +```bash +AGENTATION_ROUTER_ADDR=0 agentation start --background +``` + +3. Always fetch pending work **before waiting**: + +```bash +agentation pending --json +``` + +Process that batch first, then enter watch mode. + +## Behavior + +1. Call: + +```bash +agentation watch --timeout 300 --batch-window 10 --json +``` + +2. For each annotation in the returned batch: + + a. **Acknowledge** + + ```bash + agentation ack + ``` + + b. **Understand** + - Read annotation text (`comment`) + - Read target context (`element`, `elementPath`, `url`, `nearbyText`, `reactComponents`) + - Map to likely source files before editing + + c. **Fix** + - Make the code change requested by the annotation + - Keep changes minimal and aligned with project conventions + + d. **Resolve** + + ```bash + agentation resolve --summary "" + ``` + +3. After processing the batch, loop back to step 1. + +4. Stop when: + - user says stop, or + - watch times out repeatedly with no new work. + +## Rules + +- Always acknowledge before starting work. +- Keep resolve summaries concise (1–2 sentences, mention file(s) + result). +- If unclear, ask via thread reply instead of guessing: + +```bash +agentation reply --message "I need clarification on ..." +``` + +- If not actionable, dismiss with reason: + +```bash +agentation dismiss --reason "Not actionable because ..." +``` + +- Process annotations in received order. +- Only resolve once the requested change is implemented. + +## Optional session-scoped loop + +If user wants only one page/session, scope commands with `--session`: + +```bash +agentation pending --session --json +agentation watch --session --timeout 300 --batch-window 10 --json +``` + +## Loop template + +```text +Round 1: + agentation pending --json + -> process all returned annotations + +Round 2: + agentation watch --timeout 300 --batch-window 10 --json + -> got 2 annotations + -> ack #1, fix, resolve #1 + -> ack #2, reply (needs clarification) + +Round 3: + agentation watch --timeout 300 --batch-window 10 --json + -> got 1 annotation (clarification follow-up) + -> ack, fix, resolve + +Round 4: + agentation watch --timeout 300 --batch-window 10 --json + -> timeout true, no annotations + -> exit (or continue if user requested persistent watch mode) +``` + +## Troubleshooting + +- `agentation pending` fails: Agentation is not running or base URL is wrong (`agentation start --background`). +- If using non-default server URL, pass `--base-url` or set `AGENTATION_BASE_URL`. +- If frontend keeps creating new sessions unexpectedly, verify localStorage/session behavior in the host app or Storybook setup. diff --git a/skills/agentation/SKILL.md b/skills/agentation/SKILL.md index 9eb489f7..5e53ced5 100644 --- a/skills/agentation/SKILL.md +++ b/skills/agentation/SKILL.md @@ -42,20 +42,18 @@ Set up the Agentation annotation toolbar in this project. 5. **Confirm component setup** - Tell the user the Agentation toolbar component is configured -6. **Recommend MCP server setup** - - Explain that for real-time annotation syncing with AI agents, they should also set up the MCP server - - Recommend one of the following approaches: - - **Universal (supports 9+ agents including Claude Code, Cursor, Codex, Windsurf, etc.):** - See [add-mcp](https://github.com/neondatabase/add-mcp) — run `npx add-mcp` and follow the prompts to add `agentation-mcp` as an MCP server - - **Claude Code only (interactive wizard):** - Run `agentation-mcp init` after installing the package - - Tell user to restart their coding agent after MCP setup to load the server - - Explain that once configured, annotations will sync to the agent automatically +6. **Recommend local CLI/server setup** + - Explain that for real-time annotation syncing with AI agents, they should run the local Agentation server stack + - Recommend: + - Start both server + router: `agentation start` + - Optional router-only mode: `AGENTATION_SERVER_ADDR=0 agentation start` + - Tell the user they can point the toolbar endpoint at `http://127.0.0.1:4747` + - Explain that once running, annotations sync in real time and can be consumed via CLI commands (`pending`, `watch`, `ack`, `resolve`, `reply`) ## Notes - The `NODE_ENV` check ensures Agentation only loads in development - Agentation requires React 18 -- The MCP server runs on port 4747 by default for the HTTP server -- MCP server exposes tools like `agentation_get_all_pending`, `agentation_resolve`, and `agentation_watch_annotations` -- Run `agentation-mcp doctor` to verify setup after installing +- The Agentation HTTP server runs on port 4747 by default +- Use `agentation pending`, `agentation watch`, `agentation ack`, and `agentation resolve` for loop workflows +- Use `agentation status` to verify the local stack is running