Files
Devour/cmd/serve.go
2026-02-24 12:10:13 +01:00

237 lines
7.1 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/yourorg/devour/internal/projectstate"
"github.com/yourorg/devour/internal/scraper"
"github.com/yourorg/devour/internal/search"
"github.com/yourorg/devour/internal/server"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the local Devour RPC server",
Long: `Start the Devour RPC server.
Local mode (default): JSON-RPC over stdin/stdout for agent/skill integration.
Remote mode (--remote): experimental HTTP RPC endpoint at /rpc.
Examples:
devour serve
devour serve --remote
devour serve --remote --port 3000`,
RunE: runServe,
}
var (
serveRemote bool
servePort int
serveHost string
)
func init() {
serveCmd.Flags().BoolVar(&serveRemote, "remote", false, "run as remote HTTP server (experimental)")
serveCmd.Flags().IntVarP(&servePort, "port", "p", 8080, "HTTP port (remote mode only)")
serveCmd.Flags().StringVar(&serveHost, "host", "localhost", "HTTP host (remote mode only)")
}
func runServe(cmd *cobra.Command, args []string) error {
if _, err := loadAppConfig(); err != nil {
return fmt.Errorf("load app config for server startup: %w", err)
}
srvCfg := &server.Config{
Mode: "local",
Transport: "stdio",
Host: serveHost,
Port: servePort,
Handler: func(ctx context.Context, method string, params json.RawMessage) (any, error) {
return handleServeMethod(ctx, method, params)
},
}
if serveRemote {
srvCfg.Mode = "remote"
fmt.Printf("🚀 Starting Devour RPC server in remote experimental mode\n")
fmt.Printf(" URL: http://%s:%d/rpc\n", serveHost, servePort)
} else {
fmt.Println("🚀 Starting Devour RPC server in local mode (stdio)")
fmt.Println(" Protocol: JSON-RPC 2.0 over stdin/stdout")
}
srv := server.NewServer(srvCfg)
if err := srv.Start(context.Background()); err != nil {
return fmt.Errorf("start rpc server: %w", err)
}
return nil
}
func handleServeMethod(ctx context.Context, method string, params json.RawMessage) (any, error) {
// The method implementation needs full typed config. Load per-call to avoid stale state.
loadedCfg, err := loadAppConfig()
if err != nil {
return nil, fmt.Errorf("load app config for rpc method %q: %w", method, err)
}
switch strings.TrimSpace(method) {
case "devour_query":
var req struct {
Query string `json:"query"`
Limit int `json:"limit"`
Threshold float64 `json:"threshold"`
}
if len(params) > 0 {
if err := json.Unmarshal(params, &req); err != nil {
return nil, fmt.Errorf("decode devour_query params: %w", err)
}
}
engine := search.NewEngine(loadedCfg)
results, stats, err := engine.Search(ctx, req.Query, search.SearchOptions{Limit: req.Limit, Threshold: req.Threshold})
if err != nil {
return nil, fmt.Errorf("run devour_query search: %w", err)
}
return map[string]any{"query": req.Query, "count": len(results), "results": results, "indexed": stats.Documents}, nil
case "devour_status":
docsStats, err := projectstate.CollectDocsStats(loadedCfg.Storage.DocsDir)
if err != nil {
return nil, fmt.Errorf("collect docs stats: %w", err)
}
state, err := projectstate.LoadSourceState(loadedCfg.Storage.MetadataDir)
if err != nil {
return nil, fmt.Errorf("load source state: %w", err)
}
engine := search.NewEngine(loadedCfg)
idxStats, err := engine.EnsureIndexed(ctx)
if err != nil {
return nil, fmt.Errorf("ensure search index: %w", err)
}
return map[string]any{
"documents": docsStats.DocumentCount,
"storage_bytes": docsStats.StorageBytes,
"last_updated": docsStats.LastUpdated,
"sources": state.Sources,
"indexed_docs": idxStats.Documents,
"index_timestamp": idxStats.LastIndexedAt,
}, nil
case "devour_scrape":
var req struct {
Source string `json:"source"`
Type string `json:"type"`
Format string `json:"format"`
Output string `json:"output"`
Query string `json:"query"`
ResultLimit int `json:"result_limit"`
Domains []string `json:"domains"`
Include []string `json:"include"`
Exclude []string `json:"exclude"`
}
if err := json.Unmarshal(params, &req); err != nil {
return nil, fmt.Errorf("decode devour_scrape params: %w", err)
}
if strings.TrimSpace(req.Source) == "" {
return nil, fmt.Errorf("source is required")
}
st := scraper.SourceType(req.Type)
if st == "" {
st = detectSourceType(req.Source)
}
source := &scraper.Source{
Name: extractName(req.Source),
Type: st,
URL: req.Source,
Query: strings.TrimSpace(req.Query),
ResultLimit: req.ResultLimit,
Domains: append([]string(nil), req.Domains...),
Include: append([]string(nil), req.Include...),
Exclude: append([]string(nil), req.Exclude...),
}
if st == scraper.SourceTypeLocal {
source.Path = req.Source
}
applySourceProfile(source)
prevFormat := scrapeFormat
prevOutput := scrapeOutput
prevAllowEmpty := scrapeAllowEmpty
defer func() {
scrapeFormat = prevFormat
scrapeOutput = prevOutput
scrapeAllowEmpty = prevAllowEmpty
}()
scrapeFormat = coalesceString(req.Format, "json")
scrapeOutput = req.Output
scrapeAllowEmpty = false
count, err := scrapeOne(nil, loadedCfg, source, resolveOutputDir(loadedCfg, req.Output))
if err != nil {
return nil, fmt.Errorf("run scrape for %q: %w", req.Source, err)
}
return map[string]any{"source": req.Source, "type": st, "documents": count}, nil
case "devour_ask":
var req struct {
Question string `json:"question"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(params, &req); err != nil {
return nil, fmt.Errorf("decode devour_ask params: %w", err)
}
if strings.TrimSpace(req.Question) == "" {
return nil, fmt.Errorf("question is required")
}
limit := req.Limit
if limit <= 0 {
limit = 5
}
engine := search.NewEngine(loadedCfg)
results, _, err := engine.Search(ctx, req.Question, search.SearchOptions{Limit: limit})
if err != nil {
return nil, fmt.Errorf("run devour_ask search: %w", err)
}
summary := "No relevant docs found."
if len(results) > 0 {
summary = results[0].Snippet
}
return map[string]any{"question": req.Question, "summary": summary, "sources": results}, nil
case "devour_sync":
prevForce, prevRebuild, prevSource := syncForce, syncRebuild, syncSource
var req struct {
Source string `json:"source"`
Force bool `json:"force"`
Rebuild bool `json:"rebuild"`
}
if len(params) > 0 {
if err := json.Unmarshal(params, &req); err != nil {
return nil, fmt.Errorf("decode devour_sync params: %w", err)
}
}
syncForce = req.Force
syncRebuild = req.Rebuild
syncSource = req.Source
defer func() {
syncForce, syncRebuild, syncSource = prevForce, prevRebuild, prevSource
}()
err := runSync(nil, nil)
if err != nil {
return nil, fmt.Errorf("run devour_sync: %w", err)
}
return map[string]any{"ok": true}, nil
default:
return nil, fmt.Errorf("unknown method: %s", method)
}
}
func coalesceString(primary, fallback string) string {
if strings.TrimSpace(primary) != "" {
return primary
}
return fallback
}