diff --git a/cmd/synapses/main.go b/cmd/synapses/main.go index 6639a86..2ae3e27 100644 --- a/cmd/synapses/main.go +++ b/cmd/synapses/main.go @@ -33,6 +33,7 @@ import ( "github.com/SynapsesOS/synapses/internal/parser" "github.com/SynapsesOS/synapses/internal/peer" "github.com/SynapsesOS/synapses/internal/resolver" + "github.com/SynapsesOS/synapses/internal/scout" "github.com/SynapsesOS/synapses/internal/store" "github.com/SynapsesOS/synapses/internal/watcher" ) @@ -196,6 +197,35 @@ func cmdStart(args []string) error { } } + // Optional: connect to synapses-scout web-search service. + var scoutCli *scout.Client + if cfg.Scout.URL != "" { + scoutCli = scout.NewClient(cfg.Scout.URL, cfg.Scout.TimeoutSec) + if scoutCli.Health(context.Background()) { + fmt.Fprintf(os.Stderr, "synapses: scout connected at %s\n", cfg.Scout.URL) + srv.SetScoutClient(scoutCli) + } else { + fmt.Fprintf(os.Stderr, "synapses: scout unreachable at %s (continuing without)\n", cfg.Scout.URL) + scoutCli = nil + } + } + + // Autosubscribe: detect tech stack from manifest files and optionally enrich + // with official doc URLs via scout. Runs in the background — does not block startup. + go func() { + entries := scout.DetectTechStack(absPath) + if len(entries) == 0 { + return + } + if scoutCli != nil { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + entries = scout.EnrichWithDocs(ctx, scoutCli, entries) + cancel() + } + srv.SetTechStack(entries) + fmt.Fprintf(os.Stderr, "synapses: tech stack detected (%d deps)\n", len(entries)) + }() + // Start the file watcher so the graph stays current as files change. if !*noWatch { w := parser.NewWalker() diff --git a/internal/config/config.go b/internal/config/config.go index e3b75b7..b55f723 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -116,6 +116,11 @@ type Config struct { // When set, get_context returns LLM-enriched Context Packets, violations // include plain-English explanations, and file changes are auto-ingested. Brain BrainConfig `json:"brain,omitempty"` + + // Scout configures the optional synapses-scout integration. + // When set, web_search, web_fetch, and web_deep_search MCP tools become + // available, giving AI agents real-time web data through the scout sidecar. + Scout ScoutConfig `json:"scout,omitempty"` } // BrainConfig describes the connection to a synapses-intelligence sidecar. @@ -130,6 +135,16 @@ type BrainConfig struct { EnableLLM bool `json:"enable_llm,omitempty"` } +// ScoutConfig describes the connection to a synapses-scout sidecar. +type ScoutConfig struct { + // URL is the base URL of the scout service, e.g. "http://localhost:11436". + // Leave empty to disable scout integration. + URL string `json:"url,omitempty"` + // TimeoutSec is the per-request HTTP timeout. Defaults to 30 if URL is set. + // Scout fetches real web pages so a generous timeout is required. + TimeoutSec int `json:"timeout_sec,omitempty"` +} + // PeerConfig describes a remote synapses peer instance to connect to. type PeerConfig struct { // Name is a short, unique identifier used in MCP tool params and env var @@ -476,6 +491,12 @@ func (c *Config) applyDefaults() { c.Brain.EnableLLM = true // default true when URL is provided } } + // Scout defaults: if URL is set but timeout is zero, apply sensible default. + if c.Scout.URL != "" { + if c.Scout.TimeoutSec <= 0 { + c.Scout.TimeoutSec = 30 + } + } } func defaultConfig() *Config { diff --git a/internal/mcp/scout_tools.go b/internal/mcp/scout_tools.go new file mode 100644 index 0000000..12167ec --- /dev/null +++ b/internal/mcp/scout_tools.go @@ -0,0 +1,144 @@ +package mcp + +import ( + "context" + "fmt" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/SynapsesOS/synapses/internal/scout" +) + +// getScoutClient type-asserts the stored scoutClient to *scout.Client. +// Returns nil if no scout client is configured. +func (s *Server) getScoutClient() *scout.Client { + if s.scoutClient == nil { + return nil + } + sc, _ := s.scoutClient.(*scout.Client) + return sc +} + +// handleWebSearch calls scout POST /v1/search and returns structured results. +func (s *Server) handleWebSearch( + ctx context.Context, + req mcpgo.CallToolRequest, +) (*mcpgo.CallToolResult, error) { + sc := s.getScoutClient() + if sc == nil { + return mcpgo.NewToolResultError("scout unavailable: configure scout.url in synapses.json"), nil + } + + query, _ := req.Params.Arguments["query"].(string) + if query == "" { + return mcpgo.NewToolResultError("query is required"), nil + } + + maxResults := 5 + if v, ok := req.Params.Arguments["max_results"].(float64); ok && v > 0 { + maxResults = int(v) + } + region, _ := req.Params.Arguments["region"].(string) + timelimit, _ := req.Params.Arguments["timelimit"].(string) + + resp := sc.Search(ctx, scout.SearchRequest{ + Query: query, + MaxResults: maxResults, + Region: region, + Timelimit: timelimit, + }) + if resp == nil { + return mcpgo.NewToolResultError("scout search failed: service unreachable or timed out"), nil + } + + return jsonResult(map[string]interface{}{ + "query": resp.Query, + "hits": resp.Hits, + "count": resp.Count, + }) +} + +// handleWebFetch calls scout POST /v1/fetch and returns extracted Markdown content. +// Input may be a URL or a plain-text query — scout auto-routes. +func (s *Server) handleWebFetch( + ctx context.Context, + req mcpgo.CallToolRequest, +) (*mcpgo.CallToolResult, error) { + sc := s.getScoutClient() + if sc == nil { + return mcpgo.NewToolResultError("scout unavailable: configure scout.url in synapses.json"), nil + } + + input, _ := req.Params.Arguments["input"].(string) + if input == "" { + return mcpgo.NewToolResultError("input is required (URL or search query)"), nil + } + + forceRefresh, _ := req.Params.Arguments["force_refresh"].(bool) + + resp := sc.Fetch(ctx, scout.FetchRequest{ + Input: input, + ForceRefresh: forceRefresh, + }) + if resp == nil { + return mcpgo.NewToolResultError(fmt.Sprintf("scout fetch failed for input=%q: service unreachable or timed out", input)), nil + } + + result := map[string]interface{}{ + "url": resp.URL, + "title": resp.Title, + "content_type": resp.ContentType, + "content_md": resp.ContentMD, + "word_count": resp.WordCount, + "cached": resp.Cached, + } + if resp.Fragment != nil { + result["summary"] = resp.Fragment.Summary + result["tags"] = resp.Fragment.Tags + } + + return jsonResult(result) +} + +// handleWebDeepSearch calls scout POST /v1/deep-search for multi-query +// orchestrated search with fan-out and deduplication. +func (s *Server) handleWebDeepSearch( + ctx context.Context, + req mcpgo.CallToolRequest, +) (*mcpgo.CallToolResult, error) { + sc := s.getScoutClient() + if sc == nil { + return mcpgo.NewToolResultError("scout unavailable: configure scout.url in synapses.json"), nil + } + + query, _ := req.Params.Arguments["query"].(string) + if query == "" { + return mcpgo.NewToolResultError("query is required"), nil + } + + maxResults := 10 + if v, ok := req.Params.Arguments["max_results"].(float64); ok && v > 0 { + maxResults = int(v) + } + region, _ := req.Params.Arguments["region"].(string) + timelimit, _ := req.Params.Arguments["timelimit"].(string) + + resp := sc.DeepSearch(ctx, scout.DeepSearchRequest{ + Query: query, + MaxResults: maxResults, + Region: region, + Timelimit: timelimit, + }) + if resp == nil { + return mcpgo.NewToolResultError("scout deep-search failed: service unreachable or timed out"), nil + } + + return jsonResult(map[string]interface{}{ + "query": resp.Query, + "expanded_queries": resp.ExpandedQueries, + "hits": resp.Hits, + "count": resp.Count, + "total_raw_hits": resp.TotalRawHits, + "deduplicated": resp.DeduplicatedCount, + }) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 816e42c..9aacb60 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -25,7 +25,7 @@ type ChangeSource interface { const ( serverName = "synapses" - serverVersion = "0.3.1" + serverVersion = "0.5.0" ) // packetCacheEntry holds a cached context packet with an expiry time. @@ -43,6 +43,8 @@ type Server struct { changeSource ChangeSource // nil if started without a file watcher peerManager interface{} // *peer.PeerManager — set via SetPeerManager; nil if no peers configured brainClient interface{} // *brain.Client — set via SetBrainClient; nil if brain not configured + scoutClient interface{} // *scout.Client — set via SetScoutClient; nil if scout not configured + techStack interface{} // []scout.TechStackEntry — set via SetTechStack after autosubscribe rulesMu sync.RWMutex // protects s.config.Rules for concurrent dynamic upserts // Context-packet cache: 20 slots max, 30s TTL. Keyed by "entityName:depth". @@ -182,6 +184,20 @@ func (s *Server) SetBrainClient(bc interface{}) { s.brainClient = bc } +// SetScoutClient wires a *scout.Client into the server so that web_search, +// web_fetch, and web_deep_search tools are functional. +// Using interface{} avoids an import cycle (scout imports only stdlib). +func (s *Server) SetScoutClient(sc interface{}) { + s.scoutClient = sc +} + +// SetTechStack stores the detected tech stack entries ([]scout.TechStackEntry) +// so that get_project_identity can surface them as tech_stack. +// Called from cmdStart after autosubscribe detection completes. +func (s *Server) SetTechStack(ts interface{}) { + s.techStack = ts +} + // ServeStdio starts the MCP server on stdin/stdout. This call blocks until // the client disconnects or the process receives a signal. func (s *Server) ServeStdio() error { @@ -626,4 +642,83 @@ func (s *Server) registerTools() { s.handleGetWorkingState, ) + // ── Web Tools (requires synapses-scout sidecar) ────────────────────────── + + // web_search + s.mcp.AddTool( + mcp.NewTool( + "web_search", + mcp.WithDescription( + "Searches the web via the synapses-scout sidecar and returns structured results. "+ + "Use this to find framework docs, API references, error solutions, or any "+ + "information that requires real-time data. "+ + "Requires scout.url in synapses.json (default: http://localhost:11436).", + ), + mcp.WithString("query", + mcp.Required(), + mcp.Description("The search query."), + ), + mcp.WithNumber("max_results", + mcp.Description("Maximum results to return. Default 5."), + ), + mcp.WithString("region", + mcp.Description("Optional region code for localised results, e.g. 'us-en'."), + ), + mcp.WithString("timelimit", + mcp.Description("Optional time limit, e.g. 'd' (day), 'w' (week), 'm' (month)."), + ), + ), + s.handleWebSearch, + ) + + // web_fetch + s.mcp.AddTool( + mcp.NewTool( + "web_fetch", + mcp.WithDescription( + "Fetches and extracts a URL (or performs a search if given a query) via "+ + "synapses-scout, returning the content as Markdown. "+ + "Use this to read documentation pages, GitHub READMEs, or any web content "+ + "that an agent needs to reason about. "+ + "Requires scout.url in synapses.json (default: http://localhost:11436).", + ), + mcp.WithString("input", + mcp.Required(), + mcp.Description("A URL to fetch or a plain-text search query (scout auto-routes)."), + ), + mcp.WithBoolean("force_refresh", + mcp.Description("When true, bypasses the scout cache and re-fetches. Default false."), + ), + ), + s.handleWebFetch, + ) + + // web_deep_search + s.mcp.AddTool( + mcp.NewTool( + "web_deep_search", + mcp.WithDescription( + "Performs an orchestrated multi-query search via synapses-scout: expands the "+ + "original query into sub-queries, fans out searches, deduplicates, and returns "+ + "a richer result set than web_search. Use this for research tasks where "+ + "a single query is unlikely to capture all relevant results. "+ + "Requires scout.url in synapses.json (default: http://localhost:11436).", + ), + mcp.WithString("query", + mcp.Required(), + mcp.Description("The research query to expand and search."), + ), + mcp.WithNumber("max_results", + mcp.Description("Maximum deduplicated results to return. Default 10."), + ), + mcp.WithString("region", + mcp.Description("Optional region code for localised results, e.g. 'us-en'."), + ), + mcp.WithString("timelimit", + mcp.Description("Optional time limit, e.g. 'd' (day), 'w' (week), 'm' (month)."), + ), + ), + s.handleWebDeepSearch, + ) + } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e0cd533..21903a0 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -80,6 +80,10 @@ func (s *Server) handleGetProjectIdentity( "7. release_claims → free scope when done", }, } + // Autosubscribe: surface detected tech stack (populated by cmdStart after indexing). + if s.techStack != nil { + out["tech_stack"] = s.techStack + } return jsonResult(out) } diff --git a/internal/scout/client.go b/internal/scout/client.go new file mode 100644 index 0000000..e1d5c85 --- /dev/null +++ b/internal/scout/client.go @@ -0,0 +1,102 @@ +package scout + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client is a fail-silent HTTP client for the synapses-scout sidecar. +// Every method returns zero/nil values on failure — callers never need to +// handle scout errors, and the graph-only path always degrades gracefully. +type Client struct { + baseURL string + cli *http.Client +} + +// NewClient creates a Client targeting the given base URL. timeoutSec is the +// per-request HTTP timeout; pass 0 to use the default of 30 seconds. +// Scout fetches real pages, so the timeout must be generous. +func NewClient(baseURL string, timeoutSec int) *Client { + if timeoutSec <= 0 { + timeoutSec = 30 + } + return &Client{ + baseURL: baseURL, + cli: &http.Client{ + Timeout: time.Duration(timeoutSec) * time.Second, + }, + } +} + +// Health calls GET /v1/health and returns true if the service is reachable. +func (c *Client) Health(ctx context.Context) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/health", nil) + if err != nil { + return false + } + resp, err := c.cli.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// Search calls POST /v1/search and returns the results. Returns nil on failure. +func (c *Client) Search(ctx context.Context, req SearchRequest) *SearchResponse { + var out SearchResponse + if err := c.post(ctx, "/v1/search", req, &out); err != nil { + return nil + } + return &out +} + +// Fetch calls POST /v1/fetch and returns the extracted content. Returns nil on failure. +func (c *Client) Fetch(ctx context.Context, req FetchRequest) *FetchResponse { + var out FetchResponse + if err := c.post(ctx, "/v1/fetch", req, &out); err != nil { + return nil + } + return &out +} + +// DeepSearch calls POST /v1/deep-search and returns the orchestrated results. Returns nil on failure. +func (c *Client) DeepSearch(ctx context.Context, req DeepSearchRequest) *DeepSearchResponse { + var out DeepSearchResponse + if err := c.post(ctx, "/v1/deep-search", req, &out); err != nil { + return nil + } + return &out +} + +// post marshals body as JSON, POSTs to the endpoint, and decodes the response +// into out (if out is non-nil). Returns an error on any failure. +func (c *Client) post(ctx context.Context, path string, body, out interface{}) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.cli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} diff --git a/internal/scout/techstack.go b/internal/scout/techstack.go new file mode 100644 index 0000000..8993445 --- /dev/null +++ b/internal/scout/techstack.go @@ -0,0 +1,280 @@ +package scout + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// TechStackEntry represents one detected dependency with optional doc enrichment. +type TechStackEntry struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Ecosystem string `json:"ecosystem"` // "go", "node", "python", "rust", "java", "unknown" + DocURL string `json:"doc_url,omitempty"` + Snippet string `json:"snippet,omitempty"` +} + +// maxDeps is the maximum number of dependencies surfaced per project. +const maxDeps = 10 + +// DetectTechStack reads well-known manifest files in projectRoot and returns +// up to maxDeps dependency entries. It is fast (pure file I/O, no network) +// and always runs regardless of whether scout is configured. +func DetectTechStack(projectRoot string) []TechStackEntry { + var entries []TechStackEntry + + // Try each manifest parser in priority order. + // Each appends to entries until maxDeps is reached. + parsers := []func(string) []TechStackEntry{ + parseGoMod, + parsePackageJSON, + parseRequirementsTxt, + parseCargoToml, + parsePyprojectToml, + } + for _, p := range parsers { + if len(entries) >= maxDeps { + break + } + got := p(projectRoot) + for _, e := range got { + if len(entries) >= maxDeps { + break + } + entries = append(entries, e) + } + } + return entries +} + +// EnrichWithDocs searches for official documentation for each entry via scout. +// Enrichment is best-effort: any failed entries are returned unchanged. +// A per-entry timeout of 5 seconds is enforced so startup stays fast. +func EnrichWithDocs(ctx context.Context, sc *Client, entries []TechStackEntry) []TechStackEntry { + enriched := make([]TechStackEntry, len(entries)) + copy(enriched, entries) + + for i, e := range enriched { + if e.DocURL != "" { + continue // already has a URL + } + entryCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + query := e.Name + " official documentation" + resp := sc.Search(entryCtx, SearchRequest{Query: query, MaxResults: 1}) + cancel() + if resp != nil && len(resp.Hits) > 0 { + enriched[i].DocURL = resp.Hits[0].URL + enriched[i].Snippet = resp.Hits[0].Snippet + } + } + return enriched +} + +// ── manifest parsers ────────────────────────────────────────────────────────── + +func parseGoMod(root string) []TechStackEntry { + data, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + return nil + } + + var entries []TechStackEntry + inRequire := false + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "require (" { + inRequire = true + continue + } + if inRequire && line == ")" { + inRequire = false + continue + } + // Single-line require: require github.com/foo/bar v1.2.3 + if strings.HasPrefix(line, "require ") { + line = strings.TrimPrefix(line, "require ") + inRequire = false + } + if inRequire || strings.HasPrefix(line, "github.com/") || strings.HasPrefix(line, "golang.org/") { + parts := strings.Fields(line) + if len(parts) >= 2 && !strings.HasSuffix(parts[1], "// indirect") { + name := moduleShortName(parts[0]) + version := parts[1] + if len(parts) >= 3 && parts[2] == "//" { + continue // skip indirect deps + } + entries = append(entries, TechStackEntry{ + Name: name, + Version: version, + Ecosystem: "go", + }) + } + } + } + // Sort by name for stability, cap at maxDeps. + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + if len(entries) > maxDeps { + entries = entries[:maxDeps] + } + return entries +} + +// moduleShortName extracts a readable short name from a Go module path. +// github.com/mark3labs/mcp-go → mcp-go +func moduleShortName(modPath string) string { + parts := strings.Split(modPath, "/") + if len(parts) == 0 { + return modPath + } + return parts[len(parts)-1] +} + +func parsePackageJSON(root string) []TechStackEntry { + data, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + return nil + } + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + var entries []TechStackEntry + // Production deps first, then dev deps. + for name, ver := range pkg.Dependencies { + entries = append(entries, TechStackEntry{ + Name: name, + Version: strings.TrimLeft(ver, "^~"), + Ecosystem: "node", + }) + } + for name, ver := range pkg.DevDependencies { + entries = append(entries, TechStackEntry{ + Name: name, + Version: strings.TrimLeft(ver, "^~"), + Ecosystem: "node", + }) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + if len(entries) > maxDeps { + entries = entries[:maxDeps] + } + return entries +} + +func parseRequirementsTxt(root string) []TechStackEntry { + data, err := os.ReadFile(filepath.Join(root, "requirements.txt")) + if err != nil { + return nil + } + var entries []TechStackEntry + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // name==version or name>=version or just name + name := line + version := "" + for _, sep := range []string{"==", ">=", "<=", "~=", "!="} { + if idx := strings.Index(line, sep); idx >= 0 { + name = line[:idx] + version = line[idx+len(sep):] + break + } + } + entries = append(entries, TechStackEntry{ + Name: strings.TrimSpace(name), + Version: strings.TrimSpace(version), + Ecosystem: "python", + }) + } + if len(entries) > maxDeps { + entries = entries[:maxDeps] + } + return entries +} + +func parseCargoToml(root string) []TechStackEntry { + data, err := os.ReadFile(filepath.Join(root, "Cargo.toml")) + if err != nil { + return nil + } + var entries []TechStackEntry + inDeps := false + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "[dependencies]" || line == "[dev-dependencies]" { + inDeps = true + continue + } + if strings.HasPrefix(line, "[") { + inDeps = false + continue + } + if inDeps && strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + name := strings.TrimSpace(parts[0]) + version := strings.Trim(strings.TrimSpace(parts[1]), `"{}`) + entries = append(entries, TechStackEntry{ + Name: name, + Version: version, + Ecosystem: "rust", + }) + } + } + if len(entries) > maxDeps { + entries = entries[:maxDeps] + } + return entries +} + +func parsePyprojectToml(root string) []TechStackEntry { + data, err := os.ReadFile(filepath.Join(root, "pyproject.toml")) + if err != nil { + return nil + } + var entries []TechStackEntry + inDeps := false + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Match [tool.poetry.dependencies] or [project] with dependencies list. + if line == "[tool.poetry.dependencies]" || line == "[project]" { + inDeps = true + continue + } + if strings.HasPrefix(line, "[") { + inDeps = false + continue + } + if inDeps && strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + name := strings.TrimSpace(parts[0]) + if name == "python" || name == "name" || name == "version" || name == "description" { + continue + } + version := strings.Trim(strings.TrimSpace(parts[1]), `"^~`) + entries = append(entries, TechStackEntry{ + Name: name, + Version: version, + Ecosystem: "python", + }) + } + } + if len(entries) > maxDeps { + entries = entries[:maxDeps] + } + return entries +} diff --git a/internal/scout/types.go b/internal/scout/types.go new file mode 100644 index 0000000..aee2386 --- /dev/null +++ b/internal/scout/types.go @@ -0,0 +1,84 @@ +// Package scout is a fail-silent HTTP client for the synapses-scout sidecar. +// Every method returns zero values on failure — callers never need to handle +// scout errors, and the graph-only path always degrades gracefully. +package scout + +// SearchRequest is the body sent to POST /v1/search. +type SearchRequest struct { + Query string `json:"query"` + MaxResults int `json:"max_results,omitempty"` + Region string `json:"region,omitempty"` + Timelimit string `json:"timelimit,omitempty"` +} + +// SearchHit is one search result entry. +type SearchHit struct { + Title string `json:"title"` + URL string `json:"url"` + Snippet string `json:"snippet"` +} + +// SearchResponse is returned by POST /v1/search. +type SearchResponse struct { + Query string `json:"query"` + Hits []SearchHit `json:"hits"` + Count int `json:"count"` +} + +// FetchRequest is the body sent to POST /v1/fetch. +// Input may be a URL or a plain-text query (scout auto-routes). +type FetchRequest struct { + Input string `json:"input"` + ForceRefresh bool `json:"force_refresh,omitempty"` + Distill *bool `json:"distill,omitempty"` + Region string `json:"region,omitempty"` + Timelimit string `json:"timelimit,omitempty"` + MaxResults int `json:"max_results,omitempty"` +} + +// FetchResponse is returned by POST /v1/fetch (mirrors ScoutResult). +type FetchResponse struct { + URL string `json:"url"` + ContentType string `json:"content_type"` + Title string `json:"title"` + ContentMD string `json:"content_md"` + WordCount int `json:"word_count"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Fragment *ScoutFragment `json:"fragment,omitempty"` + Cached bool `json:"cached"` + FetchedAt string `json:"fetched_at"` +} + +// ScoutFragment is the distilled LLM summary attached to a FetchResponse. +type ScoutFragment struct { + Summary string `json:"summary"` + Tags []string `json:"tags"` + DistilledBy string `json:"distilled_by"` +} + +// DeepSearchRequest is the body sent to POST /v1/deep-search. +type DeepSearchRequest struct { + Query string `json:"query"` + MaxResults int `json:"max_results,omitempty"` + Region string `json:"region,omitempty"` + Timelimit string `json:"timelimit,omitempty"` + Expand *bool `json:"expand,omitempty"` +} + +// DeepSearchResponse is returned by POST /v1/deep-search. +type DeepSearchResponse struct { + Query string `json:"query"` + ExpandedQueries []string `json:"expanded_queries"` + Hits []SearchHit `json:"hits"` + Count int `json:"count"` + TotalRawHits int `json:"total_raw_hits"` + DeduplicatedCount int `json:"deduplicated"` +} + +// HealthResponse is returned by GET /v1/health. +type HealthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + IntelligenceAvailable bool `json:"intelligence_available"` + Cache interface{} `json:"cache,omitempty"` +}