diff --git a/src/agent_sdk.zig b/src/agent_sdk.zig index f3a3fb1..d3bf218 100644 --- a/src/agent_sdk.zig +++ b/src/agent_sdk.zig @@ -13,9 +13,7 @@ // {"type":"result","subtype":"success","result":"",...} const std = @import("std"); -const mj = @import("mcp").json; -/// Options for a Claude agent run via `claude -p`. /// Options for a Claude agent run via `claude -p`. pub const AgentOptions = struct { /// Comma-separated tool allowlist, e.g. "Bash,Read,Edit". @@ -56,12 +54,6 @@ pub fn runAgent( // ───────────────────────────────────────────────────────────────────────────── -/// Attempts to run the turn via `claude -p`. Returns false when claude is -/// unavailable so the caller can fall back to codex_appserver. -/// Attempts to run the turn via `claude -p`. Returns false when claude is -/// unavailable so the caller can fall back to codex_appserver. -/// Attempts to run the turn via `claude -p`. Returns false when claude is -/// unavailable so the caller can fall back to codex_appserver. /// Attempts to run the turn via `claude -p`. Returns false when claude is /// unavailable so the caller can fall back to codex_appserver. pub fn tryClaudeAgent( @@ -83,25 +75,39 @@ pub fn tryClaudeAgent( // Build argv in a fixed-size stack buffer (22 slots is sufficient). var argv_buf: [22][]const u8 = undefined; var argc: usize = 0; - argv_buf[argc] = "claude"; argc += 1; - argv_buf[argc] = "-p"; argc += 1; - argv_buf[argc] = prompt; argc += 1; - argv_buf[argc] = "--output-format"; argc += 1; - argv_buf[argc] = "stream-json"; argc += 1; - argv_buf[argc] = "--verbose"; argc += 1; - argv_buf[argc] = "--permission-mode"; argc += 1; - argv_buf[argc] = perm_mode; argc += 1; - argv_buf[argc] = "--model"; argc += 1; - argv_buf[argc] = model; argc += 1; + argv_buf[argc] = "claude"; + argc += 1; + argv_buf[argc] = "-p"; + argc += 1; + argv_buf[argc] = prompt; + argc += 1; + argv_buf[argc] = "--output-format"; + argc += 1; + argv_buf[argc] = "stream-json"; + argc += 1; + argv_buf[argc] = "--verbose"; + argc += 1; + argv_buf[argc] = "--permission-mode"; + argc += 1; + argv_buf[argc] = perm_mode; + argc += 1; + argv_buf[argc] = "--model"; + argc += 1; + argv_buf[argc] = model; + argc += 1; if (opts.reasoning_effort) |effort| { - argv_buf[argc] = "--reasoning-effort"; argc += 1; - argv_buf[argc] = effort; argc += 1; + argv_buf[argc] = "--reasoning-effort"; + argc += 1; + argv_buf[argc] = effort; + argc += 1; } if (opts.allowed_tools) |at| { - argv_buf[argc] = "--allowedTools"; argc += 1; - argv_buf[argc] = at; argc += 1; + argv_buf[argc] = "--allowedTools"; + argc += 1; + argv_buf[argc] = at; + argc += 1; } // Inherit environment but strip CLAUDECODE (nested-session guard) and @@ -113,10 +119,10 @@ pub fn tryClaudeAgent( // ── Option 1: direct spawn ──────────────────────────────────────────────── var child = std.process.Child.init(argv_buf[0..argc], alloc); - child.stdin_behavior = .Close; + child.stdin_behavior = .Close; child.stdout_behavior = .Pipe; child.stderr_behavior = .Close; - child.env_map = &env_map; + child.env_map = &env_map; if (opts.cwd) |cwd| child.cwd = cwd; if (child.spawn()) |_| { @@ -125,7 +131,9 @@ pub fn tryClaudeAgent( const proc_out = child.stdout orelse return false; streamClaudeOutput(alloc, proc_out, out); return true; - } else |_| {} + } else |err| { + std.log.warn("agent_sdk: spawn failed: {s}", .{@errorName(err)}); + } // ── Option 2: login shell fallback ──────────────────────────────────────── // Direct spawn failed — claude not on the trimmed PATH seen by this process. @@ -153,10 +161,10 @@ pub fn tryClaudeAgent( const argv2 = [_][]const u8{ shell, "-lc", shell_cmd.items }; var child2 = std.process.Child.init(&argv2, alloc); - child2.stdin_behavior = .Close; + child2.stdin_behavior = .Close; child2.stdout_behavior = .Pipe; child2.stderr_behavior = .Close; - child2.env_map = &env_map; + child2.env_map = &env_map; if (opts.cwd) |cwd| child2.cwd = cwd; child2.spawn() catch return false; @@ -168,9 +176,6 @@ pub fn tryClaudeAgent( return true; } -/// Reads NDJSON from `claude -p --output-format stream-json`. -/// Extracts the agent's final text from the `{"type":"result"}` event. -/// Falls back to accumulated assistant-message text if no result event arrives. /// Reads NDJSON from `claude -p --output-format stream-json`. /// Extracts the agent's final text from the `{"type":"result"}` event. /// Falls back to accumulated assistant-message text if no result event arrives. @@ -197,7 +202,8 @@ fn streamClaudeOutput( // Append cumulative usage marker for telemetry extraction if (total_in > 0 or total_out > 0) { var buf: [128]u8 = undefined; - const marker = std.fmt.bufPrint(&buf, + const marker = std.fmt.bufPrint( + &buf, "\n__USAGE__:tokens_in={d},tokens_out={d}", .{ total_in, total_out }, ) catch ""; @@ -211,27 +217,48 @@ fn readLine(alloc: std.mem.Allocator, file: std.fs.File) ?[]u8 { var buf: [4096]u8 = undefined; var line: std.ArrayList(u8) = .empty; while (true) { - const n = file.read(&buf) catch { line.deinit(alloc); return null; }; + const n = file.read(&buf) catch { + line.deinit(alloc); + return null; + }; if (n == 0) { - if (line.items.len == 0) { line.deinit(alloc); return null; } - return line.toOwnedSlice(alloc) catch { line.deinit(alloc); return null; }; + if (line.items.len == 0) { + line.deinit(alloc); + return null; + } + return line.toOwnedSlice(alloc) catch { + line.deinit(alloc); + return null; + }; } // Scan the chunk for newline for (buf[0..n], 0..) |byte, i| { if (byte == '\n') { // Append everything before the newline - line.appendSlice(alloc, buf[0..i]) catch { line.deinit(alloc); return null; }; + line.appendSlice(alloc, buf[0..i]) catch { + line.deinit(alloc); + return null; + }; // Seek back to just after the newline so next read picks up there const leftover = n - i - 1; if (leftover > 0) { file.seekBy(-@as(i64, @intCast(leftover))) catch {}; } - return line.toOwnedSlice(alloc) catch { line.deinit(alloc); return null; }; + return line.toOwnedSlice(alloc) catch { + line.deinit(alloc); + return null; + }; } } // No newline found — append entire chunk and keep reading - line.appendSlice(alloc, buf[0..n]) catch { line.deinit(alloc); return null; }; - if (line.items.len > 8 * 1024 * 1024) { line.deinit(alloc); return null; } + line.appendSlice(alloc, buf[0..n]) catch { + line.deinit(alloc); + return null; + }; + if (line.items.len > 8 * 1024 * 1024) { + line.deinit(alloc); + return null; + } } } @@ -354,7 +381,6 @@ fn stripCodexEnv(alloc: std.mem.Allocator, env: *std.process.EnvMap) void { // Tests // ───────────────────────────────────────────────────────────────────────────── - test "agent_sdk: parseClaudeLine extracts result text" { const alloc = std.testing.allocator; @@ -523,7 +549,7 @@ test "agent_sdk: streamClaudeOutput extracts result via pipe" { const alloc = std.testing.allocator; const pipe = try std.posix.pipe(); - const read_fd = std.fs.File{ .handle = pipe[0] }; + const read_fd = std.fs.File{ .handle = pipe[0] }; const write_fd = std.fs.File{ .handle = pipe[1] }; const ndjson = @@ -547,12 +573,10 @@ test "agent_sdk: streamClaudeOutput falls back to accumulated when no result eve const alloc = std.testing.allocator; const pipe = try std.posix.pipe(); - const read_fd = std.fs.File{ .handle = pipe[0] }; + const read_fd = std.fs.File{ .handle = pipe[0] }; const write_fd = std.fs.File{ .handle = pipe[1] }; - _ = try write_fd.write( - "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"fallback text\"}]},\"session_id\":\"s1\"}\n" - ); + _ = try write_fd.write("{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"fallback text\"}]},\"session_id\":\"s1\"}\n"); write_fd.close(); var out: std.ArrayList(u8) = .empty; @@ -568,7 +592,7 @@ test "agent_sdk: readLine reads lines delimited by newline" { const alloc = std.testing.allocator; const pipe = try std.posix.pipe(); - const read_fd = std.fs.File{ .handle = pipe[0] }; + const read_fd = std.fs.File{ .handle = pipe[0] }; const write_fd = std.fs.File{ .handle = pipe[1] }; _ = try write_fd.write("hello\nworld\n"); @@ -589,7 +613,7 @@ test "agent_sdk: readLine returns partial content on EOF without trailing newlin const alloc = std.testing.allocator; const pipe = try std.posix.pipe(); - const read_fd = std.fs.File{ .handle = pipe[0] }; + const read_fd = std.fs.File{ .handle = pipe[0] }; const write_fd = std.fs.File{ .handle = pipe[1] }; _ = try write_fd.write("no newline at end"); @@ -605,7 +629,7 @@ test "agent_sdk: readLine returns null on immediate EOF" { const alloc = std.testing.allocator; const pipe = try std.posix.pipe(); - const read_fd = std.fs.File{ .handle = pipe[0] }; + const read_fd = std.fs.File{ .handle = pipe[0] }; const write_fd = std.fs.File{ .handle = pipe[1] }; write_fd.close(); @@ -617,7 +641,7 @@ test "agent_sdk: readLine handles lines spanning read boundary" { const alloc = std.testing.allocator; const pipe = try std.posix.pipe(); - const read_fd = std.fs.File{ .handle = pipe[0] }; + const read_fd = std.fs.File{ .handle = pipe[0] }; const write_fd = std.fs.File{ .handle = pipe[1] }; // Two lines: first is 5000 bytes (exceeds 4096 internal buffer), second is short @@ -648,7 +672,8 @@ test "integration: agent_sdk round-trip — haiku replies to a simple prompt" { var out: std.ArrayList(u8) = .empty; defer out.deinit(alloc); - runAgent(alloc, + runAgent( + alloc, "Reply with exactly the text TRANSPORT_OK and nothing else.", .{ .model = "haiku" }, &out, @@ -671,7 +696,7 @@ test "integration: agent_sdk model param is forwarded" { var out2: std.ArrayList(u8) = .empty; defer out2.deinit(alloc); - runAgent(alloc, "Reply with exactly: HAIKU_RESPONSE", .{ .model = "haiku" }, &out1); + runAgent(alloc, "Reply with exactly: HAIKU_RESPONSE", .{ .model = "haiku" }, &out1); runAgent(alloc, "Reply with exactly: SONNET_RESPONSE", .{ .model = "sonnet" }, &out2); if (out1.items.len == 0 and out2.items.len == 0) return; // soft skip diff --git a/src/config.zig b/src/config.zig index 067a6be..daa279e 100644 --- a/src/config.zig +++ b/src/config.zig @@ -12,32 +12,32 @@ const std = @import("std"); /// Per-role overrides from [agents.] sections. pub const RoleConfig = struct { - model: ?[]const u8 = null, // model override (haiku | sonnet | opus | full ID) - backend: ?[]const u8 = null, // backend override (claude | codex | amp) - sandbox: ?[]const u8 = null, // sandbox hint (read-only | write) - max_turns: ?u32 = null, + model: ?[]const u8 = null, // model override (haiku | sonnet | opus | full ID) + backend: ?[]const u8 = null, // backend override (claude | codex | amp) + sandbox: ?[]const u8 = null, // sandbox hint (read-only | write) + max_turns: ?u32 = null, }; /// Parsed representation of .devswarm/config.toml. pub const Config = struct { /// [provider] primary = "claude" | "codex" | "amp" | "auto" - primary: ?[]const u8 = null, + primary: ?[]const u8 = null, /// [provider] claude_default = "sonnet" | "opus" | full model ID claude_default: ?[]const u8 = null, /// [provider] codex_default = "codex-mini-latest" | ... - codex_default: ?[]const u8 = null, + codex_default: ?[]const u8 = null, /// [agents.] sections, keyed by role name (owned strings) roles: std.StringHashMap(RoleConfig), pub fn deinit(self: *Config, alloc: std.mem.Allocator) void { - if (self.primary) |p| alloc.free(p); + if (self.primary) |p| alloc.free(p); if (self.claude_default) |d| alloc.free(d); - if (self.codex_default) |d| alloc.free(d); + if (self.codex_default) |d| alloc.free(d); var it = self.roles.iterator(); while (it.next()) |e| { alloc.free(e.key_ptr.*); const rc = e.value_ptr; - if (rc.model) |v| alloc.free(v); + if (rc.model) |v| alloc.free(v); if (rc.backend) |v| alloc.free(v); if (rc.sandbox) |v| alloc.free(v); } @@ -91,9 +91,9 @@ fn parse(alloc: std.mem.Allocator, text: []const u8) !Config { // Key = value const eq = std.mem.indexOfScalar(u8, line, '=') orelse continue; - const key = std.mem.trim(u8, line[0..eq], " \t"); - const raw_v = std.mem.trim(u8, line[eq + 1..], " \t"); - const val = stripQuotes(raw_v); + const key = std.mem.trim(u8, line[0..eq], " \t"); + const raw_v = std.mem.trim(u8, line[eq + 1 ..], " \t"); + const val = stripQuotes(stripInlineComment(raw_v)); try applyKV(alloc, &cfg, section, key, val); } @@ -102,11 +102,11 @@ fn parse(alloc: std.mem.Allocator, text: []const u8) !Config { } fn applyKV( - alloc: std.mem.Allocator, - cfg: *Config, + alloc: std.mem.Allocator, + cfg: *Config, section: []const u8, - key: []const u8, - val: []const u8, + key: []const u8, + val: []const u8, ) !void { if (std.mem.eql(u8, section, "provider")) { if (std.mem.eql(u8, key, "primary")) { @@ -140,8 +140,8 @@ fn applyKV( const rc = gop.value_ptr; if (std.mem.eql(u8, key, "model")) { - if (rc.model) |old| alloc.free(old); - rc.model = try alloc.dupe(u8, val); + if (rc.model) |old| alloc.free(old); + rc.model = try alloc.dupe(u8, val); } else if (std.mem.eql(u8, key, "backend")) { if (rc.backend) |old| alloc.free(old); rc.backend = try alloc.dupe(u8, val); @@ -155,9 +155,22 @@ fn applyKV( } } +/// Strip inline `# comment` suffix from a TOML value, respecting double-quoted strings. +fn stripInlineComment(s: []const u8) []const u8 { + var in_quotes = false; + for (s, 0..) |ch, i| { + if (ch == '"') { + in_quotes = !in_quotes; + } else if (ch == '#' and !in_quotes) { + return std.mem.trimRight(u8, s[0..i], " \t"); + } + } + return s; +} + /// Strip surrounding double or single quotes from a TOML string value. fn stripQuotes(s: []const u8) []const u8 { - if (s.len >= 2 and s[0] == '"' and s[s.len - 1] == '"') return s[1 .. s.len - 1]; + if (s.len >= 2 and s[0] == '"' and s[s.len - 1] == '"') return s[1 .. s.len - 1]; if (s.len >= 2 and s[0] == '\'' and s[s.len - 1] == '\'') return s[1 .. s.len - 1]; return s; } @@ -225,3 +238,27 @@ test "config: loadDefault handles presence or absence of config file" { c.deinit(alloc); } } + +test "config: inline comments are stripped from values" { + const alloc = std.testing.allocator; + const toml = + \\[provider] + \\primary = claude # my note + ; + var cfg = try parse(alloc, toml); + defer cfg.deinit(alloc); + + try std.testing.expectEqualStrings("claude", cfg.primary.?); +} + +test "config: quoted values preserve # inside quotes" { + const alloc = std.testing.allocator; + const toml = + \\[provider] + \\primary = "claude # not a comment" + ; + var cfg = try parse(alloc, toml); + defer cfg.deinit(alloc); + + try std.testing.expectEqualStrings("claude # not a comment", cfg.primary.?); +} diff --git a/src/runtime/dispatch.zig b/src/runtime/dispatch.zig index 84dbe3d..3fb2881 100644 --- a/src/runtime/dispatch.zig +++ b/src/runtime/dispatch.zig @@ -23,7 +23,7 @@ pub fn dispatch( ) void { switch (resolved.backend) { .claude => spawnClaude(alloc, resolved, prompt, out), - .codex => spawnCodex(alloc, resolved, prompt, out), + .codex => spawnCodex(alloc, resolved, prompt, out), } } @@ -42,18 +42,21 @@ fn spawnClaude( (if (resolved.writable) "bypassPermissions" else "default"); const opts: sdk.AgentOptions = .{ - .model = resolved.model, - .writable = resolved.writable, - .allowed_tools = resolved.allowed_tools, - .permission_mode = perm_mode, + .model = resolved.model, + .writable = resolved.writable, + .allowed_tools = resolved.allowed_tools, + .permission_mode = perm_mode, .reasoning_effort = resolved.reasoning_effort, - .cwd = resolved.cwd, + .cwd = resolved.cwd, }; - const full_prompt = if (resolved.system_prompt.len > 0) - std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch prompt - else - prompt; + const full_prompt = full_prompt: { + if (resolved.system_prompt.len == 0) break :full_prompt prompt; + break :full_prompt std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch { + std.log.warn("dispatch: OOM concatenating system prompt — running with user prompt only", .{}); + break :full_prompt prompt; + }; + }; defer if (full_prompt.ptr != prompt.ptr) alloc.free(full_prompt); if (sdk.tryClaudeAgent(alloc, full_prompt, opts, out)) return; @@ -75,10 +78,13 @@ fn spawnCodex( const policy: cas.SandboxPolicy = if (resolved.writable) .writable else .read_only; // Prepend system prompt to the user's prompt - const full_prompt = if (resolved.system_prompt.len > 0) - std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch prompt - else - prompt; + const full_prompt = full_prompt: { + if (resolved.system_prompt.len == 0) break :full_prompt prompt; + break :full_prompt std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch { + std.log.warn("dispatch: OOM concatenating system prompt — running with user prompt only", .{}); + break :full_prompt prompt; + }; + }; defer if (full_prompt.ptr != prompt.ptr) alloc.free(full_prompt); cas.runTurnPolicy(alloc, full_prompt, out, policy); diff --git a/src/search.zig b/src/search.zig index 32f444c..ccc5090 100644 --- a/src/search.zig +++ b/src/search.zig @@ -5,15 +5,15 @@ // and uses it to find symbol references across the codebase. const std = @import("std"); -const gh = @import("gh.zig"); +const gh = @import("gh.zig"); // ── Search tool detection ───────────────────────────────────────────────────── pub const SearchTool = enum { zigrep, rg, grep, none }; -var g_mu: std.Thread.Mutex = .{}; -var g_probed: bool = false; -var g_tool: SearchTool = .none; +var g_mu: std.Thread.Mutex = .{}; +var g_probed: bool = false; +var g_tool: SearchTool = .none; /// Detect which search tool is available. Cached after first call. pub fn probe(alloc: std.mem.Allocator) SearchTool { @@ -23,8 +23,8 @@ pub fn probe(alloc: std.mem.Allocator) SearchTool { const candidates = [_][]const []const u8{ &.{ "zigrep", "--version" }, - &.{ "rg", "--version" }, - &.{ "grep", "--version" }, + &.{ "rg", "--version" }, + &.{ "grep", "--version" }, }; const tools = [_]SearchTool{ .zigrep, .rg, .grep }; @@ -43,9 +43,9 @@ pub fn probe(alloc: std.mem.Allocator) SearchTool { pub fn toolName(tool: SearchTool) []const u8 { return switch (tool) { .zigrep => "zigrep", - .rg => "rg", - .grep => "grep", - .none => "none", + .rg => "rg", + .grep => "grep", + .none => "none", }; } @@ -63,9 +63,9 @@ pub fn searchRefs( const argv: []const []const u8 = switch (tool) { .zigrep => &.{ "zigrep", "-l", "-w", symbol, "." }, - .rg => &.{ "rg", "-l", "-F", "-w", symbol, "." }, - .grep => &.{ "grep", "-rlFw", symbol, "." }, - .none => return results, + .rg => &.{ "rg", "-l", "-F", "-w", symbol, "." }, + .grep => &.{ "grep", "-rlFw", symbol, "." }, + .none => return results, }; const r = gh.runWithOutput(alloc, argv) catch |err| { @@ -207,9 +207,9 @@ pub fn extractSymbolsFromContent( test "toolName returns correct strings" { try std.testing.expectEqualStrings("zigrep", toolName(.zigrep)); - try std.testing.expectEqualStrings("rg", toolName(.rg)); - try std.testing.expectEqualStrings("grep", toolName(.grep)); - try std.testing.expectEqualStrings("none", toolName(.none)); + try std.testing.expectEqualStrings("rg", toolName(.rg)); + try std.testing.expectEqualStrings("grep", toolName(.grep)); + try std.testing.expectEqualStrings("none", toolName(.none)); } test "extractFilePath: standard diff header" { @@ -312,14 +312,14 @@ test "extractIdentifierFromContext: leading whitespace" { test "searchRefs: empty symbol returns empty" { const alloc = std.testing.allocator; - const refs = try searchRefs(alloc, .rg, "", null); + var refs = try searchRefs(alloc, .rg, "", null); defer refs.deinit(alloc); try std.testing.expectEqual(@as(usize, 0), refs.items.len); } test "searchRefs: none tool returns empty" { const alloc = std.testing.allocator; - const refs = try searchRefs(alloc, .none, "handleFoo", null); + var refs = try searchRefs(alloc, .none, "handleFoo", null); defer refs.deinit(alloc); try std.testing.expectEqual(@as(usize, 0), refs.items.len); } diff --git a/src/tools.zig b/src/tools.zig index b5673e3..818a005 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -206,7 +206,7 @@ pub const tools_list = \\{"name":"merge_pr","description":"Merge a pull request. Defaults to current branch's PR if pr_number is omitted. Supports squash, merge, and rebase strategies.","inputSchema":{"type":"object","properties":{"pr_number":{"type":"integer","description":"PR number (defaults to current branch's PR)"},"strategy":{"type":"string","enum":["squash","merge","rebase"],"description":"Merge strategy (default: squash)"},"delete_branch":{"type":"boolean","description":"Delete the branch after merging (default: false)"}},"required":[]}}, \\{"name":"get_pr_diff","description":"Get the diff for a pull request. Defaults to current branch's PR if pr_number is omitted. Use this to review what a PR changes before merging.","inputSchema":{"type":"object","properties":{"pr_number":{"type":"integer","description":"PR number (defaults to current branch's PR)"}},"required":[]}}, \\{"name":"review_pr_impact","description":"Analyze a PR's blast radius: extracts changed files and function symbols from the diff, then searches the codebase for all references to those symbols. Call this before approving or merging a PR to understand what other code might be affected by the changes. Also useful after creating a PR to self-review impact. Returns files changed, symbols modified, and which files reference each symbol.","inputSchema":{"type":"object","properties":{"pr_number":{"type":"integer","description":"PR number (defaults to current branch's PR)"}},"required":[]}}, - \\{"name":"blast_radius","description":"Find all files that reference symbols defined in a file or a specific symbol. Use this to understand the impact of changing a file or function before editing. Works offline with grep-based search.","inputSchema":{"type":"object","properties":{"file":{"type":"string","description":"Path to file to analyze (extracts symbols automatically)"},"symbol":{"type":"string","description":"Specific symbol name to search for"}},"required":[]}}, + \\{"name":"blast_radius","description":"Find all files that reference symbols defined in a file or a specific symbol. Use this to understand the impact of changing a file or function before editing. Works offline with grep-based search. Provide at least one of: file or symbol.","inputSchema":{"type":"object","properties":{"file":{"type":"string","description":"Path to file to analyze (extracts symbols automatically)"},"symbol":{"type":"string","description":"Specific symbol name to search for"}},"required":[]}}, \\{"name":"relevant_context","description":"Find files most related to a given file by analyzing symbol cross-references and imports. Use this to understand what other files you should read before modifying a file. Works offline with grep-based search.","inputSchema":{"type":"object","properties":{"file":{"type":"string","description":"Path to file to find context for"}},"required":["file"]}}, \\{"name":"git_history_for","description":"Return the git commit history for a specific file. Use this to understand recent changes, who modified a file, and why. Works offline with local git.","inputSchema":{"type":"object","properties":{"file":{"type":"string","description":"Path to file to get history for"},"count":{"type":"integer","description":"Number of commits to return (default 20)"}},"required":["file"]}}, \\{"name":"recently_changed","description":"Return files that were recently modified across recent commits. Use this to understand what areas of the codebase are actively being worked on. Works offline with local git.","inputSchema":{"type":"object","properties":{"count":{"type":"integer","description":"Number of recent commits to scan (default 10)"}},"required":[]}}, @@ -1808,8 +1808,6 @@ fn handleRecentlyChanged( out.appendSlice(alloc, "]}") catch {}; } - - const Symbol = struct { name: []const u8, file: ?[]const u8,