From 303b65165c2e12a2d6f1b59da7225cd04011e9f6 Mon Sep 17 00:00:00 2001 From: howznguyen Date: Thu, 30 Apr 2026 20:02:18 +0700 Subject: [PATCH 1/2] feat(cli,search,ui): optimize code ingest performance and expand MCP platform support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add batch embedding with length-sorted content for 5x ingest speedup (1m27s → 17s) - Implement parser instance pooling to reduce tree-sitter allocation overhead - Compact embedding content to signature + edge summary instead of full source code - Expand MCP setup to support 10 platforms with unified registry pattern - Add Java, Rust, C# language support to AST indexer via CGO path - Update server port from 4455 to 6420 in knowns config - Fix Memory page silent failure by handling missing `/api/working-memories` route - Eliminate duplicate "Semantic search ready" message in sync output - Document code ingest performance optimization patterns and decisions - Document MCP multi-platform registry setup learnings and tradeoffs - Update Go dependencies to support new language parsers --- .knowns/config.json | 2 +- ...ng-code-ingest-performance-optimization.md | 66 +++ ...rning-mcp-setup-multi-platform-registry.md | 53 +++ go.mod | 5 +- go.sum | 4 + internal/cli/download_setup.go | 3 +- internal/cli/ingest.go | 76 ++- internal/cli/mcp.go | 433 ++++++++++++++++-- internal/cli/update.go | 20 +- internal/cli/watch.go | 2 +- internal/search/ast_indexer.go | 39 +- internal/search/ast_indexer_candidates.go | 11 +- internal/search/ast_indexer_edges.go | 36 ++ internal/search/ast_indexer_parse.go | 91 +++- internal/search/ast_indexer_symbols.go | 75 ++- ui/src/pages/AuditPage.tsx | 119 +++-- ui/src/pages/CodeGraphPage.tsx | 55 +-- ui/src/pages/MemoryPage.tsx | 62 +-- 18 files changed, 985 insertions(+), 167 deletions(-) create mode 100644 .knowns/docs/learnings/learning-code-ingest-performance-optimization.md create mode 100644 .knowns/docs/learnings/learning-mcp-setup-multi-platform-registry.md diff --git a/.knowns/config.json b/.knowns/config.json index 35aa030..4984349 100644 --- a/.knowns/config.json +++ b/.knowns/config.json @@ -39,7 +39,7 @@ "dimensions": 384, "maxTokens": 512 }, - "serverPort": 4455, + "serverPort": 6420, "platforms": [ "claude-code", "opencode", diff --git a/.knowns/docs/learnings/learning-code-ingest-performance-optimization.md b/.knowns/docs/learnings/learning-code-ingest-performance-optimization.md new file mode 100644 index 0000000..8824ecd --- /dev/null +++ b/.knowns/docs/learnings/learning-code-ingest-performance-optimization.md @@ -0,0 +1,66 @@ +--- +title: 'Learning: Code Ingest Performance Optimization' +description: Learnings from optimizing code ingest pipeline — 5x speedup via batch embedding, content compaction, and parser pooling +createdAt: '2026-04-30T13:00:36.691Z' +updatedAt: '2026-04-30T13:00:36.691Z' +tags: + - learning + - performance + - embedding + - ast + - code-intelligence +--- + +# Learning: Code Ingest Performance Optimization + +## Patterns + +### Batch Embedding with Length-Sorted Content +- **What:** Sort code symbols by embedding content length before batching ORT inference. Short symbols batch together (minimal padding), long symbols batch separately. +- **When to use:** Any ORT/ONNX batch inference where inputs vary widely in length. +- **Impact:** 5x wall-clock speedup (1m27s → 17s on 3700+ symbols). +- **Why it works:** ORT pads all texts in a batch to the longest one. Without sorting, one long function in a batch of 64 forces all 63 short symbols to be padded to max length → wasted compute. + +### Compact Embedding Content for Code Symbols +- **What:** Use signature + edge summary instead of full source code for embedding content. Functions/methods get signature only. Classes get member signature list. +- **When to use:** Code symbol embedding where the model has limited token budget (512 tokens). +- **Why:** Full source code gets truncated by the model anyway. Signature + relationships (calls, imports, instantiates) capture the semantic meaning better for search. + +### Parser Instance Pooling +- **What:** Pool tree-sitter parser instances per language extension using a bounded stack. Avoids alloc/dealloc per file. +- **When to use:** Sequential file parsing with tree-sitter CGO bindings. +- **Impact:** Minor but measurable — eliminates per-file parser creation overhead. + +## Decisions + +### Sort + Adaptive Batch Size +- **Chose:** Sort by content length + adaptive batch size (64 for short, 32 for long) +- **Over:** Fixed batch size, parallel embedding goroutines +- **Tag:** GOOD_CALL +- **Outcome:** Simple change, massive impact. No concurrency complexity. +- **Recommendation:** Always sort by input length before batching any padded inference. + +### Add Java/Rust/C# on CGO Path Now +- **Chose:** Add languages to current Go CGO tree-sitter path +- **Over:** Wait for sidecar migration to complete +- **Tag:** TRADEOFF +- **Outcome:** Users get new language support immediately. Will need to port to sidecar later but the AST node type mapping is reusable. + +### Truncate Content at 2000 Chars +- **Chose:** Hard truncate embedding content at 2000 chars before tokenization +- **Over:** Let tokenizer handle full text then truncate at token level +- **Tag:** GOOD_CALL +- **Outcome:** Reduces tokenizer CPU overhead significantly. Model uses max 512 tokens ≈ 2000 chars for code. + +## Failures + +### WebUI Memory Page Silent Failure +- **What went wrong:** Memory page showed "0 entries" despite API returning 77 entries. +- **Root cause:** Frontend `Promise.all([memoryApi.list(), workingMemoryApi.list()])` — the `/api/working-memories` route was removed from backend but frontend still called it → 404 → `Promise.all` rejects → catch block runs → both persistent and working entries stay as empty arrays. +- **Time lost:** ~20 minutes debugging. +- **Prevention:** When removing a backend route, always grep frontend for the route path. Or use `Promise.allSettled` instead of `Promise.all` for independent fetches. + +### Duplicate "Semantic search ready" Message +- **What went wrong:** `knowns sync` printed "✓ Semantic search ready (model: ...)" twice. +- **Root cause:** `ensureProjectAndGlobalSemanticReady` calls `ensureSemanticStoreReady` twice (project + global), each calling `runSemanticSetup` which printed the message independently. +- **Prevention:** Print status messages at the orchestration level, not inside reusable helper functions. diff --git a/.knowns/docs/learnings/learning-mcp-setup-multi-platform-registry.md b/.knowns/docs/learnings/learning-mcp-setup-multi-platform-registry.md new file mode 100644 index 0000000..7c95fe2 --- /dev/null +++ b/.knowns/docs/learnings/learning-mcp-setup-multi-platform-registry.md @@ -0,0 +1,53 @@ +--- +title: 'Learning: MCP Setup Multi-Platform Registry' +description: Learnings from rewriting knowns mcp setup to support per-platform selection and 10 platforms +createdAt: '2026-04-30T12:58:14.440Z' +updatedAt: '2026-04-30T12:58:14.440Z' +tags: + - learning + - cli + - mcp + - platforms +--- + +# Learning: MCP Setup Multi-Platform Registry + +Source: `internal/cli/mcp.go` rewrite (April 2026) + +## Patterns + +### Platform Registry Pattern +- **What:** Data-driven registry of platform configs using a slice of structs with `id`, `label`, `scope`, and `setup func`. CLI args map to registry entries, replacing hardcoded if/else branches. +- **When to use:** Any CLI command that needs to operate across multiple targets (platforms, runtimes, environments). Especially useful when the list of targets grows over time. +- **Example:** `mcpPlatforms` slice in `internal/cli/mcp.go` — each entry is self-contained with its own setup function, making it trivial to add new platforms. + +### Unified Setup Function Signature +- **What:** All platform setup functions normalized to `func(projectRoot string) (path string, err error)`. Previous code had inconsistent signatures. +- **When to use:** When building a registry of operations that need uniform dispatch. + +## Decisions + +### Reuse init.go config functions +- **Chose:** Delegate to existing `create*Quiet` functions from `init.go` +- **Over:** Duplicating config creation logic in `mcp.go` +- **Tag:** GOOD_CALL +- **Outcome:** Zero duplication, behavior stays consistent between `knowns init` and `knowns mcp setup` +- **Recommendation:** Always check `init.go` and `sync.go` before writing new platform config logic — the function likely already exists. + +### Positional args over per-platform flags +- **Chose:** `knowns mcp setup claude kiro antigravity` (positional args) +- **Over:** `knowns mcp setup --claude --kiro --antigravity` (boolean flags) +- **Tag:** GOOD_CALL +- **Outcome:** Cleaner UX, shell completion works naturally, scales to any number of platforms without flag explosion. +- **Recommendation:** Use positional args for target selection when the set is open-ended. Use flags for behavioral modifiers (`--project`, `--global`). + +### Claude Desktop path fix (macOS) +- **Chose:** `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Over:** `~/.claude/claude_desktop_config.json` (old, incorrect path) +- **Tag:** TRADEOFF +- **Outcome:** Correct for Claude Desktop app. The old path was the Claude Code CLI config, not Claude Desktop. +- **Recommendation:** Always verify platform config paths against official docs before hardcoding. + +## Failures + +No significant failures. The implementation was straightforward because most platform config functions already existed in `init.go`. The main gap was that the original `mcp setup` was a minimal stub that only wrote one global config." diff --git a/go.mod b/go.mod index b3791cf..ab4a253 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,12 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/rs/cors v1.11.1 github.com/spf13/cobra v1.10.2 - github.com/yalue/onnxruntime_go v1.27.0 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-go v0.25.0 github.com/tree-sitter/tree-sitter-javascript v0.25.0 github.com/tree-sitter/tree-sitter-python v0.25.0 github.com/tree-sitter/tree-sitter-typescript v0.23.2 + github.com/yalue/onnxruntime_go v1.27.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.46.1 ) @@ -28,6 +28,9 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/tree-sitter/tree-sitter-c-sharp v0.23.5 // indirect + github.com/tree-sitter/tree-sitter-java v0.23.5 // indirect + github.com/tree-sitter/tree-sitter-rust v0.24.2 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index f0bf081..90e7f17 100644 --- a/go.sum +++ b/go.sum @@ -141,6 +141,8 @@ github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12kr github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU= github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c= github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw= +github.com/tree-sitter/tree-sitter-c-sharp v0.23.5 h1:EeUc2WJE5G1pD6YEqera2yVHYeroeR+/MakKX2a+0xQ= +github.com/tree-sitter/tree-sitter-c-sharp v0.23.5/go.mod h1:H7/aFm5vR1A8Yn5VIOfLWPdlKuJsMgZ5eDmaJdv8bY0= github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w= github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8= github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg= @@ -163,6 +165,8 @@ github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA= github.com/tree-sitter/tree-sitter-rust v0.23.2 h1:6AtoooCW5GqNrRpfnvl0iUhxTAZEovEmLKDbyHlfw90= github.com/tree-sitter/tree-sitter-rust v0.23.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI= +github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0= +github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI= github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU= github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= diff --git a/internal/cli/download_setup.go b/internal/cli/download_setup.go index 5d32a0d..c824ea9 100644 --- a/internal/cli/download_setup.go +++ b/internal/cli/download_setup.go @@ -337,7 +337,6 @@ func runSemanticSetup(modelID string, force ...bool) error { } if !forceDownload && isModelInstalled(selected) { - fmt.Println(StyleSuccess.Render(fmt.Sprintf("✓ Semantic search ready (model: %s)", modelID))) return nil } @@ -500,5 +499,7 @@ func ensureProjectAndGlobalSemanticReady(projectStore *storage.Store, defaultMod if err != nil { return projectChanged, false, err } + // Print ready message once after both stores are set up. + fmt.Println(StyleSuccess.Render(fmt.Sprintf("✓ Semantic search ready (model: %s)", defaultModelID))) return projectChanged, globalChanged, nil } diff --git a/internal/cli/ingest.go b/internal/cli/ingest.go index bf63463..62277f6 100644 --- a/internal/cli/ingest.go +++ b/internal/cli/ingest.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -22,7 +23,8 @@ var ingestCmd = &cobra.Command{ Long: `Index all code files in the project using tree-sitter AST parsing. This command walks the project directory, parses AST for Go, TypeScript, -JavaScript, and Python files, and stores code symbols as indexed code data. +JavaScript, Python, Java, Rust, and C# files, and stores code symbols as +indexed code data. Files matching .gitignore and test files (*_test.go, *.spec.ts, etc.) are skipped by default. Use --include-tests to include them. @@ -240,16 +242,76 @@ func runIngestWithProgress(projectRoot string, includeTests bool) (symCount, fil vecStore.RemoveByPrefix("code::") - var chunks []search.Chunk + // Pre-compute chunks and sort by content length to minimize padding + // waste in ORT batch inference (all texts in a batch are padded to + // the longest one). + type indexedChunk struct { + idx int + chunk search.Chunk + } + preChunks := make([]indexedChunk, 0, len(syms)) for i, sym := range syms { - chunk := sym.ToChunk() - vec, err := embedder.EmbedDocument(chunk.Content) + preChunks = append(preChunks, indexedChunk{idx: i, chunk: sym.ToChunk()}) + } + + // Truncate embedding content to ~2000 chars. The model uses max 512 + // tokens (~2000 chars for code). Sending longer text only wastes + // tokenizer CPU without improving the embedding vector. + const maxEmbedChars = 2000 + for i := range preChunks { + if len(preChunks[i].chunk.Content) > maxEmbedChars { + preChunks[i].chunk.Content = preChunks[i].chunk.Content[:maxEmbedChars] + } + } + + sort.Slice(preChunks, func(a, b int) bool { + return len(preChunks[a].chunk.Content) < len(preChunks[b].chunk.Content) + }) + + // Batch embed for much better throughput. + // Use adaptive batch size: larger batches for short content, + // smaller for long content to manage memory. + var chunks []search.Chunk + i := 0 + for i < len(preChunks) { + // Adaptive batch size based on content length of first item. + batchSize := 64 + if len(preChunks[i].chunk.Content) > 1000 { + batchSize = 32 + } + end := i + batchSize + if end > len(preChunks) { + end = len(preChunks) + } + batch := preChunks[i:end] + + texts := make([]string, len(batch)) + for j, ic := range batch { + texts[j] = ic.chunk.Content + } + + vecs, err := embedder.EmbedDocumentBatch(texts) if err != nil { + // Fallback: embed one-by-one on batch failure + for j, ic := range batch { + vec, err := embedder.EmbedDocument(ic.chunk.Content) + if err != nil { + continue + } + ic.chunk.Embedding = vec + chunks = append(chunks, ic.chunk) + state.processed = i + j + 1 + } + i = end continue } - chunk.Embedding = vec - chunks = append(chunks, chunk) - state.processed = i + 1 + + for j := range batch { + batch[j].chunk.Embedding = vecs[j] + chunks = append(chunks, batch[j].chunk) + } + state.processed = end + i = end } vecStore.AddChunks(chunks) diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3e6e893..07c2969 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/spf13/cobra" @@ -36,76 +37,444 @@ func runMCP(cmd *cobra.Command, args []string) error { // --- mcp setup --- +// mcpPlatform describes a supported MCP platform and how to configure it. +type mcpPlatform struct { + id string // canonical lowercase name used as CLI argument + label string // display name + scope string // "project" or "global" + // setup returns the config file path and any error. + // projectRoot is the current working directory (needed for project-level and some global configs). + setup func(projectRoot string) (path string, err error) +} + +// mcpPlatforms is the registry of all supported platforms. +// Order determines display order. +var mcpPlatforms = []mcpPlatform{ + // Project-level + {id: "claude", label: "Claude Code", scope: "project", setup: func(root string) (string, error) { + return filepath.Join(root, ".mcp.json"), createMCPJsonFileQuiet(root, true) + }}, + {id: "kiro", label: "Kiro", scope: "project", setup: func(root string) (string, error) { + return filepath.Join(root, ".kiro", "settings", "mcp.json"), createKiroMCPConfigQuiet(root) + }}, + {id: "opencode", label: "OpenCode", scope: "project", setup: func(root string) (string, error) { + return filepath.Join(root, "opencode.json"), createOpenCodeConfigQuiet(root) + }}, + {id: "cursor", label: "Cursor", scope: "project", setup: func(root string) (string, error) { + return filepath.Join(root, ".cursor", "mcp.json"), createCursorMCPConfigQuiet(root) + }}, + {id: "codex", label: "Codex", scope: "project", setup: func(root string) (string, error) { + return filepath.Join(root, ".codex", "config.toml"), createCodexMCPConfigQuiet(root) + }}, + {id: "cline", label: "Cline", scope: "project", setup: setupClineMCP}, + {id: "continue", label: "Continue", scope: "project", setup: setupContinueMCP}, + // Global + {id: "claude-desktop", label: "Claude Desktop", scope: "global", setup: func(_ string) (string, error) { + return setupClaudeDesktopMCP() + }}, + {id: "antigravity", label: "Antigravity", scope: "global", setup: func(root string) (string, error) { + return setupAntigravityMCP(root) + }}, + {id: "gemini", label: "Gemini CLI", scope: "global", setup: func(_ string) (string, error) { + return setupGeminiCLIMCP() + }}, +} + +// mcpPlatformIDs returns sorted list of all platform IDs. +func mcpPlatformIDs() []string { + ids := make([]string, len(mcpPlatforms)) + for i, p := range mcpPlatforms { + ids[i] = p.id + } + return ids +} + +// mcpPlatformByID looks up a platform by its canonical ID. +func mcpPlatformByID(id string) *mcpPlatform { + id = strings.ToLower(id) + for i := range mcpPlatforms { + if mcpPlatforms[i].id == id { + return &mcpPlatforms[i] + } + } + return nil +} + var mcpSetupCmd = &cobra.Command{ - Use: "setup", - Short: "Set up MCP in Claude Code config", - Long: "Writes or updates the Claude Code MCP settings file to include the Knowns MCP server.", - RunE: runMCPSetup, + Use: "setup [platforms...]", + Short: "Set up MCP configuration for AI assistants", + Long: `Configure the Knowns MCP server for AI assistants. + +Usage: + knowns mcp setup Set up all platforms + knowns mcp setup claude kiro Set up specific platforms only + knowns mcp setup --project Set up all project-level platforms + knowns mcp setup --global Set up all global platforms + +Available platforms: + Project-level: + claude Claude Code (.mcp.json) + kiro Kiro (.kiro/settings/mcp.json) + opencode OpenCode (opencode.json) + cursor Cursor (.cursor/mcp.json) + codex Codex (.codex/config.toml) + cline Cline (.cline/mcp.json) + continue Continue (.continue/config.json) + + Global: + claude-desktop Claude Desktop (~/Library/Application Support/Claude/...) + antigravity Antigravity (~/.gemini/antigravity/mcp_config.json) + gemini Gemini CLI (~/.gemini/settings.json)`, + ValidArgsFunction: mcpSetupValidArgs, + RunE: runMCPSetup, +} + +// mcpSetupValidArgs provides shell completion for platform names. +func mcpSetupValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Exclude already-specified platforms + used := make(map[string]bool, len(args)) + for _, a := range args { + used[strings.ToLower(a)] = true + } + + var completions []string + for _, p := range mcpPlatforms { + if !used[p.id] { + completions = append(completions, fmt.Sprintf("%s\t%s (%s)", p.id, p.label, p.scope)) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// mcpSetupResult tracks what was created/updated during setup. +type mcpSetupResult struct { + platform string + path string + err error } func runMCPSetup(cmd *cobra.Command, args []string) error { - // Determine the Claude Code settings path - settingsPath := getMCPSettingsPath() + projectOnly, _ := cmd.Flags().GetBool("project") + globalOnly, _ := cmd.Flags().GetBool("global") + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + // Determine which platforms to set up + var targets []mcpPlatform + + if len(args) > 0 { + // Explicit platform selection: knowns mcp setup claude kiro antigravity + for _, arg := range args { + p := mcpPlatformByID(arg) + if p == nil { + return fmt.Errorf("unknown platform %q\n\nAvailable platforms: %s", + arg, strings.Join(mcpPlatformIDs(), ", ")) + } + targets = append(targets, *p) + } + } else { + // No args: use --project / --global flags, or all + for _, p := range mcpPlatforms { + switch { + case projectOnly && p.scope == "project": + targets = append(targets, p) + case globalOnly && p.scope == "global": + targets = append(targets, p) + case !projectOnly && !globalOnly: + targets = append(targets, p) + } + } + } + + if len(targets) == 0 { + fmt.Println(RenderHint("No platforms matched. Use 'knowns mcp setup --help' to see available platforms.")) + return nil + } + + // Run setup for each target + var results []mcpSetupResult + for _, t := range targets { + path, setupErr := t.setup(cwd) + results = append(results, mcpSetupResult{ + platform: t.label, + path: path, + err: setupErr, + }) + } + + // Print results + var successCount, failCount int + for _, r := range results { + if r.err != nil { + failCount++ + fmt.Println(RenderError(fmt.Sprintf("%s: %v", r.platform, r.err))) + } else { + successCount++ + fmt.Println(RenderSuccess(fmt.Sprintf("%s → %s", r.platform, r.path))) + } + } + + fmt.Println() + if failCount > 0 { + fmt.Println(RenderField("Result", fmt.Sprintf("%d configured, %d failed", successCount, failCount))) + } else { + fmt.Println(RenderField("Result", fmt.Sprintf("%d platform(s) configured", successCount))) + } + fmt.Println(RenderHint("Restart your AI assistant to load the new MCP server.")) + + return nil +} + +// --- Platform setup helpers --- + +// setupClineMCP creates/updates .cline/mcp.json. +func setupClineMCP(projectRoot string) (string, error) { + settingsDir := filepath.Join(projectRoot, ".cline") + if err := os.MkdirAll(settingsDir, 0755); err != nil { + return "", fmt.Errorf("create .cline: %w", err) + } + + configPath := filepath.Join(settingsDir, "mcp.json") + config := map[string]any{} + + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return configPath, fmt.Errorf("parse .cline/mcp.json: %w", err) + } + } else if !os.IsNotExist(err) { + return configPath, err + } + + servers, ok := config["mcpServers"].(map[string]any) + if !ok || servers == nil { + servers = make(map[string]any) + } + + cmd, args := mcpCommand() + servers["knowns"] = map[string]any{ + "command": cmd, + "args": args, + } + + config["mcpServers"] = servers + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return configPath, err + } + + return configPath, os.WriteFile(configPath, append(data, '\n'), 0644) +} + +// setupContinueMCP creates/updates .continue/config.json. +func setupContinueMCP(projectRoot string) (string, error) { + settingsDir := filepath.Join(projectRoot, ".continue") + if err := os.MkdirAll(settingsDir, 0755); err != nil { + return "", fmt.Errorf("create .continue: %w", err) + } + + configPath := filepath.Join(settingsDir, "config.json") + config := map[string]any{} + + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return configPath, fmt.Errorf("parse .continue/config.json: %w", err) + } + } else if !os.IsNotExist(err) { + return configPath, err + } + + // Continue uses: experimental.modelContextProtocolServers + experimental, ok := config["experimental"].(map[string]any) + if !ok || experimental == nil { + experimental = make(map[string]any) + } + + cmd, args := mcpCommand() + knownsServer := map[string]any{ + "name": "knowns", + "transport": map[string]any{ + "type": "stdio", + "command": cmd, + "args": args, + }, + } + + // Replace existing knowns entry or append + serverList, _ := experimental["modelContextProtocolServers"].([]any) + found := false + for i, s := range serverList { + if srv, ok := s.(map[string]any); ok { + if name, _ := srv["name"].(string); name == "knowns" { + serverList[i] = knownsServer + found = true + break + } + } + } + if !found { + serverList = append(serverList, knownsServer) + } + + experimental["modelContextProtocolServers"] = serverList + config["experimental"] = experimental + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return configPath, err + } + + return configPath, os.WriteFile(configPath, append(data, '\n'), 0644) +} + +// setupClaudeDesktopMCP creates/updates the Claude Desktop global config. +func setupClaudeDesktopMCP() (string, error) { + settingsPath := getClaudeDesktopConfigPath() - // Read existing settings or create new var settings map[string]any data, err := os.ReadFile(settingsPath) if err == nil { if jsonErr := json.Unmarshal(data, &settings); jsonErr != nil { - return fmt.Errorf("parse existing MCP settings: %w", jsonErr) + return settingsPath, fmt.Errorf("parse existing config: %w", jsonErr) } } else { settings = make(map[string]any) } - // Ensure mcpServers map exists mcpServers, ok := settings["mcpServers"].(map[string]any) if !ok { mcpServers = make(map[string]any) } - // Find the knowns binary path - execPath, err := os.Executable() - if err != nil { - execPath = "knowns" - } - - // Add/update the knowns server entry + cmd, args := mcpCommand() mcpServers["knowns"] = map[string]any{ - "command": execPath, - "args": []string{"mcp", "--stdio"}, + "command": cmd, + "args": args, } settings["mcpServers"] = mcpServers - // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil { - return fmt.Errorf("create settings directory: %w", err) + return settingsPath, fmt.Errorf("create settings directory: %w", err) } - // Write updated settings out, err := json.MarshalIndent(settings, "", " ") if err != nil { - return fmt.Errorf("marshal settings: %w", err) + return settingsPath, fmt.Errorf("marshal settings: %w", err) } if err := os.WriteFile(settingsPath, append(out, '\n'), 0644); err != nil { - return fmt.Errorf("write MCP settings: %w", err) + return settingsPath, fmt.Errorf("write config: %w", err) } - fmt.Println(RenderSuccess("MCP setup complete.")) - fmt.Println(RenderField("Settings file", settingsPath)) - fmt.Println(RenderHint("Knowns MCP server has been configured for Claude Code.")) - return nil + return settingsPath, nil +} + +// setupAntigravityMCP creates/updates ~/.gemini/antigravity/mcp_config.json. +func setupAntigravityMCP(projectRoot string) (string, error) { + home, err := osUserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve user home: %w", err) + } + + settingsDir := filepath.Join(home, ".gemini", "antigravity") + if err := os.MkdirAll(settingsDir, 0755); err != nil { + return "", fmt.Errorf("create antigravity config dir: %w", err) + } + + configPath := filepath.Join(settingsDir, "mcp_config.json") + config := map[string]any{} + + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return configPath, fmt.Errorf("parse antigravity config: %w", err) + } + } else if !os.IsNotExist(err) { + return configPath, err + } + + servers, ok := config["mcpServers"].(map[string]any) + if !ok || servers == nil { + servers = make(map[string]any) + } + + cmd, args := mcpCommand() + // Antigravity is global — include --project so the server knows which project to use + absRoot, absErr := filepath.Abs(projectRoot) + if absErr == nil { + args = append(args, "--project", absRoot) + } + servers["knowns"] = map[string]any{ + "command": cmd, + "args": args, + } + + config["mcpServers"] = servers + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return configPath, err + } + + return configPath, os.WriteFile(configPath, append(data, '\n'), 0644) +} + +// setupGeminiCLIMCP creates/updates ~/.gemini/settings.json. +func setupGeminiCLIMCP() (string, error) { + home, err := osUserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve user home: %w", err) + } + + settingsDir := filepath.Join(home, ".gemini") + if err := os.MkdirAll(settingsDir, 0755); err != nil { + return "", fmt.Errorf("create .gemini dir: %w", err) + } + + configPath := filepath.Join(settingsDir, "settings.json") + config := map[string]any{} + + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return configPath, fmt.Errorf("parse gemini settings: %w", err) + } + } else if !os.IsNotExist(err) { + return configPath, err + } + + servers, ok := config["mcpServers"].(map[string]any) + if !ok || servers == nil { + servers = make(map[string]any) + } + + cmd, args := mcpCommand() + servers["knowns"] = map[string]any{ + "command": cmd, + "args": args, + } + + config["mcpServers"] = servers + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return configPath, err + } + + return configPath, os.WriteFile(configPath, append(data, '\n'), 0644) } -// getMCPSettingsPath returns the default Claude Code MCP settings file path. -func getMCPSettingsPath() string { +// getClaudeDesktopConfigPath returns the Claude Desktop config file path. +func getClaudeDesktopConfigPath() string { home, _ := os.UserHomeDir() switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") case "windows": return filepath.Join(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") default: - return filepath.Join(home, ".claude", "claude_desktop_config.json") + return filepath.Join(home, ".config", "claude", "claude_desktop_config.json") } } @@ -113,7 +482,11 @@ func init() { mcpCmd.Flags().Bool("stdio", false, "Use stdio transport (for MCP clients)") mcpCmd.Flags().String("project", "", "Project root directory (auto-detected from cwd if not set)") + mcpSetupCmd.Flags().Bool("project", false, "Only set up project-level platforms") + mcpSetupCmd.Flags().Bool("global", false, "Only set up global/user-level platforms") + mcpCmd.AddCommand(mcpSetupCmd) rootCmd.AddCommand(mcpCmd) } + diff --git a/internal/cli/update.go b/internal/cli/update.go index a47b506..3bf130e 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -53,10 +53,6 @@ func runUpdate(cmd *cobra.Command, args []string) error { if cmp <= 0 { fmt.Printf(" %s Already on the latest version %s\n", RenderSuccess(""), StyleBold.Render("v"+current)) - // Still sync configs even if up to date - if !checkOnly { - return runSync(syncCmd, nil) - } return nil } @@ -77,9 +73,21 @@ func runUpdate(cmd *cobra.Command, args []string) error { return err } - // 3. Full sync (skills, instructions, model, search index, MCP configs) + // 3. Sync MCP configs only (lightweight — skip full sync) fmt.Println() - return runSync(syncCmd, nil) + if err := syncMCPConfigs(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: MCP config sync failed: %v\n", err) + } + + // 4. Restart runtime if needed + if err := restartRuntimeIfNeeded(latest); err != nil { + fmt.Fprintf(os.Stderr, "Warning: runtime restart failed: %v\n", err) + } + + fmt.Printf("\n %s Run %s to sync skills and rebuild the search index.\n", + StyleInfo.Render("ℹ"), + StyleBold.Render("knowns sync")) + return nil } // runUpgrade detects the install method and runs the appropriate upgrade command. diff --git a/internal/cli/watch.go b/internal/cli/watch.go index 0ba6386..59adb3e 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -59,7 +59,7 @@ func runWatch(cmd *cobra.Command, args []string) error { return fmt.Errorf("check semantic search: %w", err) } if !semanticEnabled { - fmt.Printf("Warning: semantic search not enabled. Run 'knowns ingest' first to enable code indexing.\n") + fmt.Printf("Warning: semantic search not enabled. Run 'knowns code ingest' first to enable code indexing.\n") } // projectRoot is the parent of knDir diff --git a/internal/search/ast_indexer.go b/internal/search/ast_indexer.go index a27615f..1d927d3 100644 --- a/internal/search/ast_indexer.go +++ b/internal/search/ast_indexer.go @@ -209,10 +209,16 @@ func enrichCodeSymbolContent(symbols []CodeSymbol, edges []CodeEdge) []CodeSymbo } edgeSummaryByID := make(map[string]*edgeLists, len(symbols)) + // Also collect method signatures per class/interface for compact content. + methodSigsByOwner := make(map[string][]string) for _, edge := range edges { if !strings.HasPrefix(edge.From, "code::") { continue } + if edge.Type == "has_method" { + // Collect method signatures for the owning class. + methodSigsByOwner[edge.From] = append(methodSigsByOwner[edge.From], edge.TargetName) + } lists := edgeSummaryByID[edge.From] if lists == nil { lists = &edgeLists{} @@ -234,6 +240,19 @@ func enrichCodeSymbolContent(symbols []CodeSymbol, edges []CodeEdge) []CodeSymbo } } + // Build a signature lookup for method symbols. + sigByName := make(map[string]string, len(symbols)) + for _, sym := range symbols { + if sym.Kind == "method" || sym.Kind == "function" { + key := CodeChunkID(sym.DocPath, sym.Name) + if strings.TrimSpace(sym.Signature) != "" { + sigByName[key] = sym.Signature + } else { + sigByName[key] = sym.Name + } + } + } + enriched := make([]CodeSymbol, 0, len(symbols)) for _, symbol := range symbols { updated := symbol @@ -267,8 +286,26 @@ func enrichCodeSymbolContent(symbols []CodeSymbol, edges []CodeEdge) []CodeSymbo summary := strings.Join(parts, " - ") if symbol.Kind == "file" || strings.TrimSpace(symbol.Source) == "" { updated.Content = summary + } else if symbol.Kind == "function" || symbol.Kind == "method" { + // Functions/methods: signature + edges is enough for search. + updated.Content = summary } else { - updated.Content = summary + "\n\n" + strings.TrimSpace(symbol.Source) + // Classes/interfaces: list member signatures instead of full source. + id := CodeChunkID(symbol.DocPath, symbol.Name) + if methods, ok := methodSigsByOwner[id]; ok && len(methods) > 0 { + var memberLines []string + for _, methodName := range methods { + methodID := CodeChunkID(symbol.DocPath, methodName) + if sig, ok := sigByName[methodID]; ok { + memberLines = append(memberLines, "+ "+sig) + } else { + memberLines = append(memberLines, "+ "+methodName) + } + } + updated.Content = summary + "\n" + strings.Join(memberLines, "\n") + } else { + updated.Content = summary + } } enriched = append(enriched, updated) } diff --git a/internal/search/ast_indexer_candidates.go b/internal/search/ast_indexer_candidates.go index 606b049..1b52b50 100644 --- a/internal/search/ast_indexer_candidates.go +++ b/internal/search/ast_indexer_candidates.go @@ -9,7 +9,7 @@ import ( func isCodeFile(path string) bool { ext := strings.ToLower(filepath.Ext(path)) switch ext { - case ".go", ".ts", ".tsx", ".js", ".jsx", ".py": + case ".go", ".ts", ".tsx", ".js", ".jsx", ".py", ".java", ".rs", ".cs": return true } return false @@ -26,7 +26,14 @@ func isTestFile(path string) bool { strings.HasSuffix(base, ".spec.ts") || strings.HasSuffix(base, ".test.ts") || strings.HasSuffix(base, ".spec.js") || - strings.HasSuffix(base, ".test.js") + strings.HasSuffix(base, ".test.js") || + strings.HasSuffix(base, "Test.java") || + strings.HasSuffix(base, "Tests.java") || + strings.HasSuffix(base, "_test.rs") || + strings.HasSuffix(base, "Test.cs") || + strings.HasSuffix(base, "Tests.cs") || + strings.Contains(path, "/test/") || + strings.Contains(path, "/tests/") } func listCodeCandidateFiles(projectRoot string, includeTests bool) ([]string, error) { diff --git a/internal/search/ast_indexer_edges.go b/internal/search/ast_indexer_edges.go index 644dacf..2ef1df3 100644 --- a/internal/search/ast_indexer_edges.go +++ b/internal/search/ast_indexer_edges.go @@ -135,6 +135,42 @@ func extractFileImports(docPath string, data []byte) fileImportIndex { } idx.Namespace[alias] = modulePath } + case ".java": + // Java: import com.example.MyClass; or import static com.example.MyClass.method; + javaImportRE := regexp.MustCompile(`(?m)^\s*import\s+(?:static\s+)?([\w.]+)\s*;`) + for _, m := range javaImportRE.FindAllStringSubmatch(text, -1) { + modulePath := strings.TrimSpace(m[1]) + if modulePath == "" { + continue + } + alias := pathBase(strings.ReplaceAll(modulePath, ".", "/")) + idx.Namespace[alias] = modulePath + } + case ".cs": + // C#: using System.Collections.Generic; or using Alias = Namespace; + csUsingRE := regexp.MustCompile(`(?m)^\s*using\s+(?:static\s+)?(?:(\w+)\s*=\s*)?([\w.]+)\s*;`) + for _, m := range csUsingRE.FindAllStringSubmatch(text, -1) { + alias := strings.TrimSpace(m[1]) + modulePath := strings.TrimSpace(m[2]) + if modulePath == "" { + continue + } + if alias == "" { + alias = pathBase(strings.ReplaceAll(modulePath, ".", "/")) + } + idx.Namespace[alias] = modulePath + } + case ".rs": + // Rust: use std::collections::HashMap; or use crate::module::Type; + rsUseRE := regexp.MustCompile(`(?m)^\s*use\s+([\w:]+(?:::\w+)*)\s*;`) + for _, m := range rsUseRE.FindAllStringSubmatch(text, -1) { + modulePath := strings.TrimSpace(m[1]) + if modulePath == "" { + continue + } + alias := pathBase(strings.ReplaceAll(modulePath, "::", "/")) + idx.Namespace[alias] = modulePath + } } return idx diff --git a/internal/search/ast_indexer_parse.go b/internal/search/ast_indexer_parse.go index 9c3e329..a0c7647 100644 --- a/internal/search/ast_indexer_parse.go +++ b/internal/search/ast_indexer_parse.go @@ -5,11 +5,15 @@ import ( "path/filepath" "regexp" "strings" + "sync" sitter "github.com/tree-sitter/go-tree-sitter" + tree_sitter_c_sharp "github.com/tree-sitter/tree-sitter-c-sharp/bindings/go" tree_sitter_go "github.com/tree-sitter/tree-sitter-go/bindings/go" + tree_sitter_java "github.com/tree-sitter/tree-sitter-java/bindings/go" tree_sitter_javascript "github.com/tree-sitter/tree-sitter-javascript/bindings/go" tree_sitter_python "github.com/tree-sitter/tree-sitter-python/bindings/go" + tree_sitter_rust "github.com/tree-sitter/tree-sitter-rust/bindings/go" tree_sitter_typescript "github.com/tree-sitter/tree-sitter-typescript/bindings/go" ) @@ -86,16 +90,12 @@ func parseRawFile(docPath, absPath string) ([]CodeSymbol, []CodeEdge, error) { if err != nil { return nil, nil, err } - lang := detectLang(docPath) - if lang == nil { + ext := strings.ToLower(filepath.Ext(docPath)) + parser := acquireParserForExt(ext) + if parser == nil { return nil, nil, nil } - - parser := sitter.NewParser() - defer parser.Close() - if err := parser.SetLanguage(lang); err != nil { - return nil, nil, err - } + defer releaseParser(ext, parser) tree := parser.Parse(data, nil) if tree == nil { @@ -112,6 +112,75 @@ func parseRawFile(docPath, absPath string) ([]CodeSymbol, []CodeEdge, error) { return syms, eds, nil } +// ─── parser pool ───────────────────────────────────────────────────── +// Reuse parser instances to avoid alloc/dealloc overhead per file. +// Keyed by file extension so each language gets its own pool. + +var parserPool sync.Map // map[string]*parserStack + +func acquireParser(lang *sitter.Language) *sitter.Parser { + // We don't have a direct language key, so callers should use acquireParserForExt. + p := sitter.NewParser() + _ = p.SetLanguage(lang) + return p +} + +func acquireParserForExt(ext string) *sitter.Parser { + if pool, ok := parserPool.Load(ext); ok { + if p := pool.(*parserStack).pop(); p != nil { + return p + } + } + lang := detectLang("file" + ext) + if lang == nil { + return nil + } + p := sitter.NewParser() + _ = p.SetLanguage(lang) + return p +} + +func releaseParser(ext string, p *sitter.Parser) { + if p == nil { + return + } + pool, _ := parserPool.LoadOrStore(ext, &parserStack{}) + stack := pool.(*parserStack) + if !stack.push(p) { + p.Close() + } +} + +// parserStack is a simple bounded stack for parser reuse. +type parserStack struct { + mu sync.Mutex + parsers []*sitter.Parser +} + +const maxPooledParsers = 4 + +func (s *parserStack) pop() *sitter.Parser { + s.mu.Lock() + defer s.mu.Unlock() + n := len(s.parsers) + if n == 0 { + return nil + } + p := s.parsers[n-1] + s.parsers = s.parsers[:n-1] + return p +} + +func (s *parserStack) push(p *sitter.Parser) bool { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.parsers) >= maxPooledParsers { + return false + } + s.parsers = append(s.parsers, p) + return true +} + func finalizeCodeParse(symbols []CodeSymbol, edges []CodeEdge) ([]CodeSymbol, []CodeEdge, error) { symbols = filterSupportedCodeSymbols(symbols) edges = append(edges, buildImplementsEdges(symbols)...) @@ -138,6 +207,12 @@ func detectLang(path string) *sitter.Language { return sitter.NewLanguage(tree_sitter_javascript.Language()) case ".py": return sitter.NewLanguage(tree_sitter_python.Language()) + case ".java": + return sitter.NewLanguage(tree_sitter_java.Language()) + case ".rs": + return sitter.NewLanguage(tree_sitter_rust.Language()) + case ".cs": + return sitter.NewLanguage(tree_sitter_c_sharp.Language()) default: return nil } diff --git a/internal/search/ast_indexer_symbols.go b/internal/search/ast_indexer_symbols.go index 704bad6..e86737d 100644 --- a/internal/search/ast_indexer_symbols.go +++ b/internal/search/ast_indexer_symbols.go @@ -164,11 +164,13 @@ func walkTree(node *sitter.Node, v *symbolVisitor) { } childKind := child.Kind() switch childKind { - case "function_declaration", "function_definition": + case "function_declaration", "function_definition", + "function_item", "local_function_statement": if name := v.functionName(*child); name != "" { v.funcStack = append(v.funcStack, name) } - case "method_declaration", "method_definition": + case "method_declaration", "method_definition", + "constructor_declaration": if name := v.methodName(*child); name != "" { v.funcStack = append(v.funcStack, name) } @@ -176,22 +178,33 @@ func walkTree(node *sitter.Node, v *symbolVisitor) { if name := v.functionVariableName(*child); name != "" { v.funcStack = append(v.funcStack, name) } - case "class_declaration", "class_definition": + case "class_declaration", "class_definition", + "struct_declaration", "struct_item", + "record_declaration", "enum_declaration", + "enum_item": if name := v.className(*child); name != "" { v.classStack = append(v.classStack, name) } - case "interface_declaration", "interface_specifier": + case "interface_declaration", "interface_specifier", + "trait_item": if name := v.interfaceName(*child); name != "" { v.classStack = append(v.classStack, name) } + case "impl_item": + if name := v.implItemTypeName(*child); name != "" { + v.classStack = append(v.classStack, name) + } } walkTree(child, v) switch childKind { - case "function_declaration", "function_definition", "method_declaration", "method_definition", "variable_declarator": + case "function_declaration", "function_definition", "method_declaration", "method_definition", + "variable_declarator", "function_item", "constructor_declaration", "local_function_statement": if len(v.funcStack) > 0 { v.funcStack = v.funcStack[:len(v.funcStack)-1] } - case "class_declaration", "class_definition", "interface_declaration", "interface_specifier": + case "class_declaration", "class_definition", "interface_declaration", "interface_specifier", + "struct_declaration", "struct_item", "record_declaration", "enum_declaration", + "enum_item", "trait_item", "impl_item": if len(v.classStack) > 0 { v.classStack = v.classStack[:len(v.classStack)-1] } @@ -201,7 +214,8 @@ func walkTree(node *sitter.Node, v *symbolVisitor) { func (v *symbolVisitor) visit(node sitter.Node) { switch node.Kind() { - case "function_declaration", "function_definition": + case "function_declaration", "function_definition", + "function_item", "local_function_statement": if name := v.functionName(node); name != "" { v.addSymbol(name, "function", node) } @@ -209,14 +223,37 @@ func (v *symbolVisitor) visit(node sitter.Node) { if name := v.methodName(node); name != "" { v.addSymbol(name, "method", node) } + case "constructor_declaration": + if name := v.methodName(node); name != "" { + v.addSymbol(name, "method", node) + } case "class_declaration", "class_definition": if name := v.className(node); name != "" { v.addSymbol(name, "class", node) } + case "struct_declaration", "struct_item": + if name := v.className(node); name != "" { + v.addSymbol(name, "class", node) + } + case "record_declaration": + if name := v.className(node); name != "" { + v.addSymbol(name, "class", node) + } + case "enum_declaration", "enum_item": + if name := v.className(node); name != "" { + v.addSymbol(name, "class", node) + } case "interface_declaration", "interface_specifier": if name := v.interfaceName(node); name != "" { v.addSymbol(name, "interface", node) } + case "trait_item": + if name := v.interfaceName(node); name != "" { + v.addSymbol(name, "interface", node) + } + case "impl_item": + // Rust impl blocks: extract methods inside, track as class context + // The impl block itself is not a symbol, but its methods are. case "type_declaration", "type_spec": name, symbolKind := v.typeDeclarationSymbol(node) if name != "" && symbolKind != "" { @@ -230,21 +267,21 @@ func (v *symbolVisitor) visit(node sitter.Node) { if name := v.functionVariableName(node); name != "" { v.addSymbol(name, "function", node) } - case "class_heritage", "extends_clause": + case "class_heritage", "extends_clause", "base_list", "superclass": if len(v.classStack) > 0 { if target := v.extendsTarget(node); target.TargetName != "" { target.ReceiverTypeHint = v.currentReceiverType() v.addEdge(v.currentReceiverType(), target, "extends") } } - case "call_expression": + case "call_expression", "invocation_expression": if len(v.funcStack) > 0 { if target := v.callExpressionTarget(node); target.TargetName != "" { target.ReceiverTypeHint = v.currentReceiverType() v.addEdge(v.funcStack[len(v.funcStack)-1], target, "calls") } } - case "new_expression", "composite_literal": + case "new_expression", "composite_literal", "object_creation_expression": if len(v.funcStack) > 0 { if target := v.instantiatedTarget(node); target.TargetName != "" { target.ReceiverTypeHint = v.currentReceiverType() @@ -298,6 +335,24 @@ func (v *symbolVisitor) interfaceName(node sitter.Node) string { return "" } +// implItemTypeName extracts the type name from a Rust impl block. +// e.g. `impl MyStruct { ... }` → "MyStruct" +func (v *symbolVisitor) implItemTypeName(node sitter.Node) string { + // In Rust tree-sitter, impl_item has a "type" field + typeNode := node.ChildByFieldName("type") + if typeNode != nil { + return v.extractNodeText(typeNode) + } + // Fallback: look for type_identifier child + for i := 0; i < int(node.ChildCount()); i++ { + child := node.Child(uint(i)) + if child != nil && child.Kind() == "type_identifier" { + return v.extractNodeText(child) + } + } + return "" +} + func (v *symbolVisitor) typeName(node sitter.Node) string { for i := 0; i < int(node.ChildCount()); i++ { child := node.Child(uint(i)) diff --git a/ui/src/pages/AuditPage.tsx b/ui/src/pages/AuditPage.tsx index 7b5ecd8..2494a61 100644 --- a/ui/src/pages/AuditPage.tsx +++ b/ui/src/pages/AuditPage.tsx @@ -4,8 +4,11 @@ import { Activity, AlertCircle, CheckCircle2, + ChevronDown, + ChevronRight, Clock, Filter, + FolderOpen, Loader2, RefreshCw, ShieldAlert, @@ -219,6 +222,7 @@ function RecentTab({ } function EventRow({ event }: { event: AuditEvent }) { + const [expanded, setExpanded] = useState(false); const rc = resultColors[event.result] ?? { bg: "bg-gray-500/10", text: "text-gray-600 dark:text-gray-400", @@ -231,39 +235,98 @@ function EventRow({ event }: { event: AuditEvent }) { const toolDisplay = event.action ? `${event.toolName}.${event.action}` : event.toolName; + const hasDetails = + (event.argumentSummary && Object.keys(event.argumentSummary).length > 0) || + event.projectRoot; + return ( -
-
- -
-
-
- {toolDisplay} - - {event.actionClass} - - {event.dryRun && ( - - dry-run +
+
hasDetails && setExpanded(!expanded)} + > + {/* Expand indicator */} +
+ {hasDetails ? ( + expanded ? ( + + ) : ( + + ) + ) : null} +
+ +
+ +
+
+
+ {toolDisplay} + + {event.actionClass} + + {event.dryRun && ( + + dry-run + + )} + + {event.durationMs}ms +
+ {event.errorMessage && ( +

{event.errorMessage}

+ )} + {event.entityRefs && event.entityRefs.length > 0 && ( +

+ {event.entityRefs.join(", ")} +

)} - - {event.durationMs}ms -
- {event.errorMessage && ( -

{event.errorMessage}

- )} - {event.entityRefs && event.entityRefs.length > 0 && ( -

- {event.entityRefs.join(", ")} -

- )} -
-
-
{timeStr}
-
{dateStr}
+
+
{timeStr}
+
{dateStr}
+
+ + {/* Expanded details */} + {expanded && hasDetails && ( +
+ {event.projectRoot && ( +
+ + Project: + {event.projectRoot} +
+ )} + {event.argumentSummary && Object.keys(event.argumentSummary).length > 0 && ( +
+

Arguments

+
+ + + {Object.entries(event.argumentSummary).map(([key, value]) => ( + + + + + ))} + +
+ {key} + + {value} +
+
+
+ )} +
+ )}
); } diff --git a/ui/src/pages/CodeGraphPage.tsx b/ui/src/pages/CodeGraphPage.tsx index e13e67e..438667e 100644 --- a/ui/src/pages/CodeGraphPage.tsx +++ b/ui/src/pages/CodeGraphPage.tsx @@ -8,6 +8,7 @@ import { useTheme } from "@/ui/App"; import { GraphDetailPanel } from "./GraphDetailPanel"; import { CodeGraphLegend } from "./graph/GraphLegend"; import { useContainerSize } from "./graph/useContainerSize"; +import { cn } from "../lib/utils"; import { buildSelectedNodeReferences, CODE_GRAPH_FILTERS, @@ -297,72 +298,72 @@ export default function CodeGraphPage() { if (error) { return ( -
+
-
{error}
-

Run knowns ingest to index code files first.

+
{error}
+

Run knowns code ingest to index code files first.

); } return ( -
-
+
+
- + setSearchQuery(e.target.value)} placeholder="Search symbols..." - className="h-7 w-40 rounded-md border bg-background pl-7 pr-7 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + className={cn('h-7', 'w-40', 'rounded-md', 'border', 'bg-background', 'pl-7', 'pr-7', 'text-xs', 'placeholder:text-muted-foreground', 'focus:outline-none', 'focus:ring-1', 'focus:ring-ring')} /> {searchQuery && ( )}
- {debouncedSearchQuery && {forceData.matches} matches} + {debouncedSearchQuery && {forceData.matches} matches}
- + {visibleKindCount} {data && visibleKindCount !== data.nodes.length ? ` / ${data.nodes.length}` : ""} symbols, {edgeCount} {data && edgeCount !== data.edges.length ? ` / ${data.edges.length}` : ""} edges - {engineRunning && Layouting...} + {engineRunning && Layouting...} -
+
-
-
+
+
{filteredData && ( -
- +
+
+ Loading code graph...
)} {!loading && data?.nodes.length === 0 && ( -
+
-

No code indexed yet.

-

- Run knowns ingest to index code files. +

No code indexed yet.

+

+ Run knowns code ingest to index code files.

@@ -480,7 +481,7 @@ export default function CodeGraphPage() { {selectedNode && ( -
+
{ try { const layer = activeLayer === "all" ? undefined : activeLayer; - const [persistent, working] = await Promise.all([ - memoryApi.list(layer), - workingMemoryApi.list(), - ]); + const persistent = await memoryApi.list(layer); setPersistentEntries(persistent); - setWorkingEntries(working); } catch (err) { console.error("Failed to load memory:", err); } finally { @@ -130,9 +126,8 @@ export default function MemoryPage() { void fetchEntries(); }, [fetchEntries]); - const visibleEntries = scope === "persistent" ? persistentEntries : workingEntries; + const visibleEntries = persistentEntries; const persistentCount = persistentEntries.length; - const workingCount = workingEntries.length; const meta = scopeMeta[scope]; const dialogOpen = modalMode !== null; const selectedPersistentEntry = @@ -274,7 +269,7 @@ export default function MemoryPage() {

Memories

- {persistentCount + workingCount} total entries + {persistentCount} total entries

@@ -299,41 +294,29 @@ export default function MemoryPage() {

-
+
{ - setScope("persistent"); - closeModal(); - }} - /> - { - setScope("working"); closeModal(); }} />
- {scope === "persistent" && ( -
- {["all", ...persistentLayerOrder].map((layer) => ( - setActiveLayer(layer as "all" | PersistentMemoryLayer)} - > - {layer.charAt(0).toUpperCase() + layer.slice(1)} - - ))} -
- )} +
+ {["all", ...persistentLayerOrder].map((layer) => ( + setActiveLayer(layer as "all" | PersistentMemoryLayer)} + > + {layer.charAt(0).toUpperCase() + layer.slice(1)} + + ))} +
- {scope === "working" && ( - - )}
- {meta.label} - {meta.description} + Memories + Project and global knowledge that persists across sessions.
From a431e9f604e90ff094ae09a2a8cdb87d6466caa3 Mon Sep 17 00:00:00 2001 From: howznguyen Date: Thu, 30 Apr 2026 20:26:30 +0700 Subject: [PATCH 2/2] test(e2e): improve server port detection and error diagnostics - Replace random port selection with OS-assigned port via socket binding to guarantee availability - Change findPort() to async function that properly binds to port 0 and reads assigned port - Increase server readiness timeout from 15s to 30s for slower environments - Add stderr collection during server startup for better error diagnostics - Enhance error messages to include server stderr output when startup fails - Import createServer from node:net for reliable port detection --- ui/e2e/helpers.ts | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/ui/e2e/helpers.ts b/ui/e2e/helpers.ts index 38bbe0a..d6273a6 100644 --- a/ui/e2e/helpers.ts +++ b/ui/e2e/helpers.ts @@ -4,6 +4,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process"; import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -19,16 +20,27 @@ const BINARY = process.env.TEST_BINARY ? resolve(process.cwd(), process.env.TEST_BINARY) : defaultBinary; -/** Find an available port */ -function findPort(): number { - // Use a random port in the high range - return 10000 + Math.floor(Math.random() * 50000); +/** Find a genuinely available port by binding to port 0 and reading the OS-assigned port */ +function findPort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (addr && typeof addr === "object") { + const port = addr.port; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error("Failed to get port from server address"))); + } + }); + srv.on("error", reject); + }); } /** Wait for HTTP server to be ready */ async function waitForServer( url: string, - timeoutMs = 15000, + timeoutMs = 30000, ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -87,7 +99,7 @@ export async function startServer(): Promise { }); // Find available port and start server - const port = findPort(); + const port = await findPort(); const serverProcess: ChildProcess = spawn( BINARY, ["browser", "--port", String(port), "--no-open"], @@ -98,6 +110,12 @@ export async function startServer(): Promise { }, ); + // Collect stderr for diagnostics if startup fails + let serverStderr = ""; + serverProcess.stderr?.on("data", (chunk: Buffer) => { + serverStderr += chunk.toString(); + }); + const baseURL = `http://localhost:${port}`; // Wait for server to be ready @@ -106,7 +124,10 @@ export async function startServer(): Promise { } catch (err) { serverProcess.kill("SIGTERM"); rmSync(projectDir, { recursive: true, force: true }); - throw err; + const detail = serverStderr ? `\nServer stderr:\n${serverStderr}` : ""; + throw new Error( + `Server not ready at ${baseURL} after timeout.${detail}`, + ); } const cli = (args: string): string => {