package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/sgx-labs/statelessagent/internal/cli"
"github.com/sgx-labs/statelessagent/internal/config"
"github.com/sgx-labs/statelessagent/internal/llm"
"github.com/sgx-labs/statelessagent/internal/setup"
"github.com/sgx-labs/statelessagent/internal/store"
)
func doctorCmd() *cobra.Command {
var jsonOut bool
cmd := &cobra.Command{
Use: "doctor",
Short: "Check system health and diagnose issues",
Long: "Runs health checks on your SAME setup: verifies providers are configured, your notes are indexed, and search is working.",
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctor(jsonOut)
},
}
cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
return cmd
}
// DoctorResult represents a single health check result
type DoctorResult struct {
Name string `json:"name"`
Status string `json:"status"` // "pass", "skip", "fail"
Message string `json:"message,omitempty"`
Hint string `json:"hint,omitempty"`
}
// DoctorReport represents the complete health check report
type DoctorReport struct {
Checks []DoctorResult `json:"checks"`
Summary struct {
Total int `json:"total"`
Passed int `json:"passed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
} `json:"summary"`
}
// sanitizeErrorForJSON removes potentially sensitive information from error messages
// SECURITY: Prevents leaking absolute file paths, hostnames, or other PII in JSON output
func sanitizeErrorForJSON(err error) string {
msg := err.Error()
// Remove absolute paths by stripping anything that looks like a filesystem path
// This is a simple heuristic: if the error contains a '/', replace with generic message
if strings.Contains(msg, "/") || strings.Contains(msg, "\\") {
// Try to extract just the error type without the path
if idx := strings.LastIndex(msg, ":"); idx != -1 {
return strings.TrimSpace(msg[idx+1:])
}
return "operation failed"
}
return msg
}
func runDoctor(jsonOut bool) error {
passed := 0
failed := 0
skipped := 0
var results []DoctorResult
// Probe embedding provider once up front so semantic checks can skip gracefully.
embedAvailable := false
embedSkipReason := "not configured"
if embedClient, err := newEmbedProvider(); err != nil {
embedSkipReason = fmt.Sprintf("provider: %v", err)
} else if _, err := embedClient.GetQueryEmbedding("test"); err != nil {
// Classify the connection error for better diagnostics
errMsg := err.Error()
switch {
case strings.Contains(errMsg, "connection_refused"):
embedSkipReason = "connection refused — local embedding endpoint not running"
case strings.Contains(errMsg, "permission_denied"):
embedSkipReason = "permission denied — localhost may be blocked by sandbox/runtime policy"
case strings.Contains(errMsg, "timeout"):
embedSkipReason = "timeout — embedding endpoint slow to respond"
case strings.Contains(errMsg, "dns_failure"):
embedSkipReason = "DNS failure — cannot resolve hostname"
default:
embedSkipReason = fmt.Sprintf("connection failed: %v", err)
}
} else {
embedAvailable = true
}
// Track vault availability so DB-dependent checks can skip gracefully
// instead of cascading into confusing "permission denied" errors.
vaultOK := false
check := func(name string, hint string, fn func() (string, error)) {
detail, err := fn()
if err != nil {
if jsonOut {
results = append(results, DoctorResult{
Name: name,
Status: "fail",
Message: sanitizeErrorForJSON(err),
Hint: hint,
})
} else {
fmt.Printf(" %s✗%s %s: %s\n",
cli.Red, cli.Reset, name, err)
if hint != "" {
fmt.Printf(" → %s\n", hint)
}
}
failed++
} else {
if jsonOut {
results = append(results, DoctorResult{
Name: name,
Status: "pass",
Message: detail,
})
} else {
if detail != "" {
fmt.Printf(" %s✓%s %s (%s)\n",
cli.Green, cli.Reset, name, detail)
} else {
fmt.Printf(" %s✓%s %s\n",
cli.Green, cli.Reset, name)
}
}
passed++
}
}
// skip marks a check as skipped (keyword-only mode) instead of failed.
skip := func(name string, reason string) {
if jsonOut {
results = append(results, DoctorResult{
Name: name,
Status: "skip",
Message: reason,
})
} else {
fmt.Printf(" %s-%s %s: %s\n",
cli.Dim, cli.Reset, name, reason)
}
skipped++
}
if !jsonOut {
cli.Header("SAME Health Check")
fmt.Println()
}
// 1. Vault path
check("Vault path", "run 'same init' in your project, or set VAULT_PATH=<path> to point at your vault", func() (string, error) {
vp := config.VaultPath()
if vp == "" {
return "", fmt.Errorf("no vault found — run 'same init' or set VAULT_PATH env var")
}
info, err := os.Stat(vp)
if err != nil {
return "", fmt.Errorf("vault path not accessible (moved or deleted?)")
}
if !info.IsDir() {
return "", fmt.Errorf("vault path is not a directory")
}
vaultOK = true
return "", nil
})
// 2. Database — skip if vault path is broken
if !vaultOK {
skip("Database", "skipped (vault path not found)")
skip("Index mode", "skipped (vault path not found)")
} else {
check("Database", "run 'same init' or 'same reindex'", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", fmt.Errorf("cannot open")
}
defer db.Close()
noteCount, err := db.NoteCount()
if err != nil {
return "", fmt.Errorf("cannot query")
}
chunkCount, err := db.ChunkCount()
if err != nil {
return "", fmt.Errorf("cannot query")
}
if noteCount == 0 {
return "", fmt.Errorf("empty")
}
return fmt.Sprintf("%s notes, %s chunks",
cli.FormatNumber(noteCount),
cli.FormatNumber(chunkCount)), nil
})
// 2b. Index mode
check("Index mode", "run 'same reindex' with an embedding provider for semantic search", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", fmt.Errorf("cannot open database")
}
defer db.Close()
graphNodes := -1
graphEdges := -1
if err := db.Conn().QueryRow("SELECT COUNT(*) FROM graph_nodes").Scan(&graphNodes); err == nil {
_ = db.Conn().QueryRow("SELECT COUNT(*) FROM graph_edges").Scan(&graphEdges)
}
graphDetail := ""
if graphNodes >= 0 && graphEdges >= 0 {
graphDetail = fmt.Sprintf(", graph %d nodes/%d edges", graphNodes, graphEdges)
}
if db.HasVectors() {
ec := config.EmbeddingProviderConfig()
provider := ec.Provider
if provider == "" {
provider = "ollama"
}
return fmt.Sprintf("semantic (%s embeddings%s)", provider, graphDetail), nil
}
noteCount, _ := db.NoteCount()
if noteCount > 0 {
msg := "keyword-only (configure SAME_EMBED_PROVIDER + run 'same reindex' to upgrade"
if graphNodes == 0 {
msg += "; graph empty — run 'same graph rebuild' after reindex"
}
msg += ")"
if graphDetail != "" {
msg += graphDetail
}
return msg, nil
}
return "empty", nil
})
} // end vaultOK guard for Database + Index mode
// 2c. Upgrade prompt: embedding provider reachable but index is keyword-only
if vaultOK && embedAvailable {
db, err := store.Open()
if err == nil {
defer db.Close()
if !db.HasVectors() {
noteCount, _ := db.NoteCount()
if noteCount > 0 {
if !jsonOut {
ec := config.EmbeddingProviderConfig()
provider := ec.Provider
if provider == "" {
provider = "ollama"
}
fmt.Printf("\n %s⚡ %s provider is reachable but your index is keyword-only.%s\n", cli.Bold, provider, cli.Reset)
fmt.Printf(" %s Run 'same reindex' to enable semantic search.%s\n\n", cli.Dim, cli.Reset)
}
}
}
}
}
// 2d. Graph LLM extraction policy
check("Graph LLM policy", "set SAME_GRAPH_LLM=off|local-only|on (regex-only fallback is always available)", func() (string, error) {
mode := config.GraphLLMMode()
switch mode {
case "off":
return "off (regex-only graph extraction)", nil
case "local-only":
client, err := llm.NewClientWithOptions(llm.Options{LocalOnly: true})
if err != nil {
return fmt.Sprintf("local-only (fallback regex-only: %s)", sanitizeRuntimeError(err)), nil
}
model, modelErr := client.PickBestModel()
if modelErr != nil || strings.TrimSpace(model) == "" {
return "local-only (fallback regex-only: no local chat model detected)", nil
}
return fmt.Sprintf("local-only (enabled via %s/%s)", client.Provider(), model), nil
case "on":
client, err := llm.NewClient()
if err != nil {
return fmt.Sprintf("on (fallback regex-only: %s)", sanitizeRuntimeError(err)), nil
}
model, modelErr := client.PickBestModel()
if modelErr != nil || strings.TrimSpace(model) == "" {
return "on (fallback regex-only: no chat model detected)", nil
}
return fmt.Sprintf("on (enabled via %s/%s)", client.Provider(), model), nil
default:
return "", fmt.Errorf("invalid graph llm policy")
}
})
// 3. Embedding provider — skip gracefully in keyword-only mode
if embedAvailable {
check("Embedding connection", "check SAME_EMBED_PROVIDER and endpoint settings", func() (string, error) {
embedClient, err := newEmbedProvider()
if err != nil {
return "", fmt.Errorf("not connected (keyword search still works)")
}
return fmt.Sprintf("connected via %s", embedClient.Name()), nil
})
} else {
skip("Embedding connection", fmt.Sprintf("skipped (%s)", embedSkipReason))
}
// 4-6: Search and security checks — skip if vault path is broken
if !vaultOK {
skip("Search working", "skipped (vault path not found)")
skip("Finding relevant notes", "skipped (vault path not found)")
skip("Private folders hidden", "skipped (vault path not found)")
} else {
// 4. Vector search — skip gracefully in keyword-only mode
if embedAvailable {
check("Search working", "run 'same reindex' to rebuild", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", err
}
defer db.Close()
embedClient, err := newEmbedProvider()
if err != nil {
return "", fmt.Errorf("provider error")
}
vec, err := embedClient.GetQueryEmbedding("test query")
if err != nil {
return "", fmt.Errorf("embedding failed")
}
results, err := db.VectorSearch(vec, store.SearchOptions{TopK: 1})
if err != nil {
return "", fmt.Errorf("search failed")
}
if len(results) == 0 {
return "", fmt.Errorf("no results")
}
return "", nil
})
} else {
skip("Search working", "skipped (keyword-only mode — needs embeddings for vector search)")
}
// 5. Context surfacing — fall back to keyword test in keyword-only mode
if embedAvailable {
check("Finding relevant notes", "try 'same search <query>' to test", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", err
}
defer db.Close()
embedClient, err := newEmbedProvider()
if err != nil {
return "", fmt.Errorf("provider error")
}
vec, err := embedClient.GetQueryEmbedding("what notes are in this vault")
if err != nil {
return "", fmt.Errorf("embedding failed")
}
raw, err := db.VectorSearchRaw(vec, 3)
if err != nil {
return "", fmt.Errorf("raw search failed")
}
if len(raw) == 0 {
return "", fmt.Errorf("no results")
}
noteCount, _ := db.NoteCount()
mode := "semantic (hybrid)"
return fmt.Sprintf("%s (%s notes)", mode, cli.FormatNumber(noteCount)), nil
})
} else {
check("Finding relevant notes", "try 'same search <query>' to test", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", err
}
defer db.Close()
noteCount, _ := db.NoteCount()
if noteCount == 0 {
return "", fmt.Errorf("no notes indexed")
}
// Check for semantic search capability
if db.HasVectors() {
embedClient, embedErr := newEmbedProvider()
if embedErr == nil {
if _, probeErr := embedClient.GetQueryEmbedding("test"); probeErr == nil {
mode := "semantic (hybrid)"
return fmt.Sprintf("%s (%s notes)", mode, cli.FormatNumber(noteCount)), nil
}
}
// Embedding provider down — fall through to keyword check
}
// Actually test keyword search works (FTS5 or LIKE-based)
var mode string
if db.FTSAvailable() {
results, ftsErr := db.FTS5Search("test", store.SearchOptions{TopK: 1})
if ftsErr != nil || results == nil {
mode = "keyword (LIKE)"
} else {
mode = "keyword (FTS5)"
}
} else {
terms := store.ExtractSearchTerms("test")
_, kwErr := db.KeywordSearch(terms, 1)
if kwErr != nil {
return "", fmt.Errorf("keyword search failed: %w", kwErr)
}
mode = "keyword (LIKE)"
}
return fmt.Sprintf("%s (%s notes)", mode, cli.FormatNumber(noteCount)), nil
})
}
// 6. Private content excluded
check("Private folders hidden", "'same reindex --force' to refresh", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", err
}
defer db.Close()
var count int
err = db.Conn().QueryRow("SELECT COUNT(*) FROM vault_notes WHERE path LIKE '_PRIVATE/%'").Scan(&count)
if err != nil {
return "", nil
}
if count > 0 {
return "", fmt.Errorf("%d _PRIVATE/ entries in index", count)
}
return "", nil
})
} // end vaultOK guard for search/security checks
// 7. Embedding endpoint policy
check("Embedding endpoint policy", "use localhost endpoints for fully local processing", func() (string, error) {
ec := config.EmbeddingProviderConfig()
provider := strings.TrimSpace(ec.Provider)
if provider == "" {
provider = "ollama"
}
switch provider {
case "none":
return "keyword-only (no embedding endpoint)", nil
case "ollama":
ollamaURL, err := config.OllamaURL()
if err != nil {
return "", err
}
u, err := url.Parse(ollamaURL)
if err != nil {
return "", fmt.Errorf("invalid embedding endpoint URL")
}
return fmt.Sprintf("local (%s)", u.Host), nil
case "openai":
return "remote (api.openai.com)", nil
case "openai-compatible":
baseURL := strings.TrimSpace(ec.BaseURL)
if baseURL == "" {
return "", fmt.Errorf("openai-compatible requires base_url")
}
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid embedding endpoint URL")
}
host := strings.TrimSpace(u.Hostname())
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return fmt.Sprintf("local (%s)", u.Host), nil
}
return fmt.Sprintf("remote (%s)", u.Host), nil
default:
return "", fmt.Errorf("unknown embedding provider")
}
})
// 8. Config file validity
check("Config file", "check .same/config.toml for syntax errors", func() (string, error) {
_, err := config.LoadConfig()
if err != nil {
return "", err
}
return "", nil
})
// 9. Hook installation
if !vaultOK {
skip("Hooks installed", "skipped (vault path not found)")
} else {
check("Hooks installed", "run 'same setup hooks'", func() (string, error) {
vp := config.VaultPath()
if vp == "" {
return "", fmt.Errorf("no vault path — run 'same init' or set VAULT_PATH")
}
settingsPath := filepath.Join(vp, ".claude", "settings.json")
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
return "", fmt.Errorf("no .claude/settings.json found")
}
hookStatus := setup.HooksInstalled(vp)
activeCount := 0
for _, v := range hookStatus {
if v {
activeCount++
}
}
if activeCount == 0 {
return "", fmt.Errorf("no SAME hooks found in settings")
}
// Check portability
portable, exists := setup.HooksUsePortablePath(vp)
if exists && !portable {
return fmt.Sprintf("%d hooks active (non-portable paths — run 'same setup hooks' to fix)", activeCount), nil
}
return fmt.Sprintf("%d hooks active", activeCount), nil
})
}
// 9b. MCP config portability
if !vaultOK {
skip("MCP config", "skipped (vault path not found)")
} else {
check("MCP config", "run 'same setup mcp' to update", func() (string, error) {
vp := config.VaultPath()
portable, exists := setup.MCPUsesPortablePath(vp)
if !exists {
return "not installed (optional)", nil
}
if !portable {
return "", fmt.Errorf("uses absolute binary path — run 'same setup mcp' to fix for portability")
}
return "portable", nil
})
}
// 10. Vault registry
check("Vault registry", "register vaults with 'same vault add <name> <path>'", func() (string, error) {
reg := config.LoadRegistry()
if len(reg.Vaults) == 0 {
return "no vaults registered (optional)", nil
}
var missingNames []string
for name, path := range reg.Vaults {
if _, err := os.Stat(path); err != nil {
missingNames = append(missingNames, name)
}
}
if len(missingNames) > 0 {
return "", fmt.Errorf("%d of %d vault path(s) missing: %s",
len(missingNames), len(reg.Vaults), strings.Join(missingNames, ", "))
}
if reg.Default != "" {
if _, ok := reg.Vaults[reg.Default]; !ok {
return "", fmt.Errorf("default vault %q not in registry", reg.Default)
}
}
return fmt.Sprintf("%d vault(s) registered", len(reg.Vaults)), nil
})
// 11-12: Database integrity and freshness — skip if vault path is broken
if !vaultOK {
skip("Database integrity", "skipped (vault path not found)")
skip("Index freshness", "skipped (vault path not found)")
} else {
check("Database integrity", "run 'same reindex' to rebuild", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", fmt.Errorf("cannot open")
}
defer db.Close()
var orphaned int
err = db.Conn().QueryRow(`
SELECT COUNT(*) FROM vault_notes_vec v
LEFT JOIN vault_notes n ON v.note_id = n.id
WHERE n.id IS NULL
`).Scan(&orphaned)
if err != nil {
return "", nil // table may not exist yet, not an error
}
if orphaned > 0 {
return "", fmt.Errorf("%d orphaned vectors", orphaned)
}
return "", nil
})
check("Index freshness", "run 'same reindex' to update", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", fmt.Errorf("cannot open")
}
defer db.Close()
age, err := db.IndexAge()
if err != nil {
return "", nil // no index yet
}
if age > 7*24*time.Hour {
return "", fmt.Errorf("last indexed %s ago", formatDuration(age))
}
return fmt.Sprintf("last indexed %s ago", formatDuration(age)), nil
})
} // end vaultOK guard for integrity checks
// 12. Log file size
check("Log file size", "rotation keeps logs under 5MB automatically", func() (string, error) {
logPath := filepath.Join(config.DataDir(), "verbose.log")
info, err := os.Stat(logPath)
if os.IsNotExist(err) {
return "no log file", nil
}
if err != nil {
return "", nil
}
sizeMB := float64(info.Size()) / (1024 * 1024)
if sizeMB > 10 {
return "", fmt.Errorf("verbose.log is %.1f MB", sizeMB)
}
return fmt.Sprintf("%.1f MB", sizeMB), nil
})
// 13-15: Embedding config, SQLite integrity, utilization — skip if vault path broken
if !vaultOK {
skip("Embedding config", "skipped (vault path not found)")
skip("SQLite integrity", "skipped (vault path not found)")
skip("Retrieval utilization", "skipped (vault path not found)")
} else {
check("Embedding config", "run 'same reindex --force' if model changed", func() (string, error) {
ec := config.EmbeddingProviderConfig()
provider := strings.TrimSpace(ec.Provider)
if provider == "" {
provider = "ollama"
}
if provider == "none" {
return "keyword-only mode (metadata check skipped)", nil
}
db, err := store.Open()
if err != nil {
return "", fmt.Errorf("cannot open")
}
defer db.Close()
embedClient, err := newEmbedProvider()
if err != nil {
return "", fmt.Errorf("cannot create provider: %v", err)
}
if mismatchErr := db.CheckEmbeddingMeta(embedClient.Name(), embedClient.Model(), embedClient.Dimensions()); mismatchErr != nil {
return "", mismatchErr
}
storedProvider, _ := db.GetMeta("embed_provider")
dims, _ := db.GetMeta("embed_dims")
if storedProvider == "" {
return "no metadata stored yet", nil
}
return fmt.Sprintf("%s, %s dims", storedProvider, dims), nil
})
check("SQLite integrity", "run 'same repair' to rebuild", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", fmt.Errorf("cannot open")
}
defer db.Close()
return "", db.IntegrityCheck()
})
check("Retrieval utilization", "try different queries or adjust your profile", func() (string, error) {
db, err := store.Open()
if err != nil {
return "", fmt.Errorf("cannot open")
}
defer db.Close()
usage, err := db.GetRecentUsage(5)
if err != nil || len(usage) == 0 {
return "no usage data yet", nil
}
total := 0
referenced := 0
for _, u := range usage {
total++
if u.WasReferenced {
referenced++
}
}
rate := float64(referenced) / float64(total)
detail := fmt.Sprintf("%.0f%% of injected context was used", rate*100)
if rate < 0.20 {
return fmt.Sprintf("%.0f%% — this improves as your AI references more notes", rate*100), nil
}
return detail, nil
})
} // end vaultOK guard for DB checks
if jsonOut {
report := DoctorReport{
Checks: results,
}
report.Summary.Total = len(results)
report.Summary.Passed = passed
report.Summary.Skipped = skipped
report.Summary.Failed = failed
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return fmt.Errorf("marshal JSON: %w", err)
}
fmt.Println(string(data))
if failed > 0 {
return fmt.Errorf("%d check(s) failed", failed)
}
return nil
}
summary := fmt.Sprintf("%d passed, %d failed", passed, failed)
if skipped > 0 {
summary += fmt.Sprintf(", %d skipped", skipped)
}
lines := []string{summary}
if !vaultOK {
lines = append(lines, "Vault not found. Run 'same init' or set VAULT_PATH=<path> to point at your vault.")
} else if !embedAvailable {
lines = append(lines, "SAME is running in keyword-only mode. Configure SAME_EMBED_PROVIDER and run 'same reindex' for semantic search.")
}
if failed > 0 {
lines = append(lines, "Still stuck? Report a bug: https://github.com/sgx-labs/statelessagent/issues")
}
cli.Box(lines)
cli.Footer()
if failed > 0 {
return fmt.Errorf("%d check(s) failed", failed)
}
return nil
}