Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "GitHits plugins for Claude Code - code examples from global open source",
"version": "0.2.2"
"version": "0.2.3"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "githits",
"version": "0.2.2",
"version": "0.2.3",
"description": "Code examples from global open source for developers and AI assistants",
"author": {
"name": "GitHits"
Expand Down
2 changes: 1 addition & 1 deletion .plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "githits",
"version": "0.2.2",
"version": "0.2.3",
"description": "Code examples from global open source for developers and AI assistants",
"author": {
"name": "GitHits"
Expand Down
94 changes: 82 additions & 12 deletions docs/implementation/tools.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "githits",
"version": "0.2.2",
"version": "0.2.3",
"description": "Code examples from global open source for developers and AI assistants.",
"mcpServers": {
"githits": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "githits",
"description": "CLI companion for GitHits - code examples from global open source for developers and AI assistants",
"version": "0.2.2",
"version": "0.2.3",
"type": "module",
"files": [
"dist",
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "githits",
"version": "0.2.2",
"version": "0.2.3",
"description": "Code examples from global open source for developers and AI assistants",
"author": {
"name": "GitHits"
Expand Down
31 changes: 31 additions & 0 deletions src/commands/mcp-instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,37 @@ describe("buildMcpInstructions", () => {
expect(instructions).toContain("`search_status`");
expect(instructions).toContain("allow_partial_results");
expect(instructions).toContain("partial hits");
// Reference-first and multi-turn strategy tips appear when
// code-navigation is wired so agents do not pull raw source into
// the main conversation by default.
expect(instructions).toContain("reference-first");
expect(instructions).toContain("Delegate multi-call work to a sub-agent");
expect(instructions).toContain("inherently multi-call");
// Delegation framing leads the gated section so it lands before
// the per-tool bullets.
const multiTurnIdx = instructions.indexOf(
"Delegate multi-call work to a sub-agent",
);
const firstBulletIdx = instructions.indexOf("- `");
expect(multiTurnIdx).toBeGreaterThan(0);
expect(firstBulletIdx).toBeGreaterThan(multiTurnIdx);
});

it("expands core trigger criteria to cover comparative cross-OSS questions", () => {
const deps = createTestDeps();
const instructions = buildMcpInstructions(deps);
expect(instructions).toContain("comparative across OSS projects");
expect(instructions).toContain("how a real codebase implements");
});

it("does not surface the strategy tips when code-navigation is not wired", () => {
const deps = createTestDeps({
codeNavigationCapability: "enabled",
packageIntelligenceService: createMockPackageIntelligenceService(),
});
const instructions = buildMcpInstructions(deps);
expect(instructions).not.toContain("reference-first");
expect(instructions).not.toContain("Delegate multi-call work");
});

it("keeps the core block first when both sections are present", () => {
Expand Down
33 changes: 24 additions & 9 deletions src/commands/mcp-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { Dependencies } from "../container.js";
* current session.
*/

const CORE_BLOCK = `GitHits surfaces verified, canonical code examples from global open source. Use it when you're stuck, the user is frustrated by repeated failed attempts, you need up-to-date API usage, or the user mentions GitHits.
const CORE_BLOCK = `GitHits surfaces verified, canonical code examples from global open source. Use it when you're stuck, the user is frustrated by repeated failed attempts, you need up-to-date API usage, the question is comparative across OSS projects (e.g. "how does X vs Y handle Z"), the answer requires reading how a real codebase implements a feature, or the user mentions GitHits.

Workflow: call \`get_example\` with one focused question, optionally passing \`language\` when the desired language is known; call \`search_language\` first only if you need to force a language and the exact name is uncertain. Send \`feedback\` on the returned solution_id so quality improves. Each search addresses a single issue; reuse context from prior results before re-searching.`;

Expand All @@ -42,22 +42,28 @@ const PKG_CHANGELOG_BULLET =
"- `pkg_changelog` — release notes for a package or GitHub repo, newest-first. Default latest mode returns the 10 most recent entries with full markdown bodies; `from_version` switches to range mode between two versions. Addressable via `registry` + `package_name` or `repo_url`. Set `include_bodies: false` for a version / date / URL timeline when bodies aren't needed.";

const SEARCH_BULLET =
"- `search` — unified search across indexed dependency code, docs, and explicit symbols. Structured fields are the primary UX; omit `sources` for AUTO. Returns only trustworthy complete results by default; opt into partial hits with `allow_partial_results: true`. If indexing is still in progress, the response carries a `searchRef`.";
'- `search` — unified search across indexed dependency code, docs, and explicit symbols. Structured fields are the primary UX; omit `sources` for AUTO. Default response is a compact text listing (`text-v1`); pass `format: "json"` for the structured envelope with full locator fields, highlights, and source status. Returns only trustworthy complete results by default; opt into partial hits with `allow_partial_results: true`. If indexing is still in progress, the response carries a `searchRef`.';

const SEARCH_STATUS_BULLET =
"- `search_status` — follow up a prior unified search by `searchRef`. Use it after `search` returns incomplete state to check progress, fetch partial hits when the original request used `allow_partial_results: true`, or fetch final results.";

const CODE_FILES_BULLET =
"- `code_files` — discover what files a dependency ships. Use `path_prefix` to scope to a subdirectory; the response includes each file's language, type, and byte size. Returned `path` values feed directly into `code_read` and help scope `code_grep`.";
'- `code_files` — discover what files a dependency ships. Use `path_prefix` to scope to a subdirectory. Default response is a paths-only listing (`text-v1`); pass `format: "json"` for the full envelope with each file\'s language, type, and byte size. Returned paths feed directly into `code_read` and help scope `code_grep`.';

const CODE_READ_BULLET =
"- `code_read` — fetch a file's contents from a dependency. Pass the same `path` emitted by `code_files`. Default returns the full file; pass `start_line` / `end_line` for a bounded range. Binary files set `isBinary: true` and omit `content` — branch on the flag. A `FILE_NOT_FOUND` (or `NOT_FOUND`) response is the signal to call `code_files` for the actual path.";
"- `code_read` — fetch a file's contents from a dependency. Pass the same `path` emitted by `code_files`. **MCP cap: 150 lines per call** — broader requests (or no range) silently truncate to the first 150 lines from your start, with a `hint` describing what was returned vs. requested. Pick a focused window from a `search` / `code_grep` match. Binary files set `isBinary: true` and omit `content`. A `FILE_NOT_FOUND` (or `NOT_FOUND`) response is the signal to call `code_files` for the actual path.";

const CODE_GREP_BULLET =
"- `code_grep` — deterministic text grep over indexed source files. Use it when you know the exact text or regex to match; use `search` for discovery. Whole-target grep is the default; narrow with `path`, `path_prefix`, `globs`, or `extensions`. Returned `matches[].filePath` feeds directly into `code_read`.";
'- `code_grep` — deterministic text grep over indexed source files. Use it when you know the exact text or regex to match; use `search` for discovery. Whole-target grep is the default; narrow with `path`, `path_prefix`, `globs`, or `extensions`. Default response groups matches by file with line numbers (`text-v1`); pass `format: "json"` for the `matches[]` array with byte offsets and symbol metadata. Each match\'s `path:line` chains directly into `code_read`.';

const SEARCH_VS_SYMBOLS_TIP =
'Prefer `get_example` for canonical example retrieval; prefer unified `search` for indexed dependency and repository discovery; use `sources:["symbol"]` when you want symbol-shaped results. Use `code_grep` for deterministic text matching and `code_read` for full-file inspection.';
'Prefer `get_example` for canonical example retrieval; prefer unified `search` for indexed dependency and repository discovery; use `sources:["symbol"]` when you want symbol-shaped results. Use `code_grep` for deterministic text matching and `code_read` for focused-window inspection of a known file.';

const REFERENCE_FIRST_TIP =
"Strategy — reference-first, content-second. Locate symbols and lines with `search` / `code_grep` first, then read only the lines you actually need with `code_read` using explicit `start_line` / `end_line` windows around the match (typically 80-150 lines). The MCP `code_read` surface caps each call at 150 lines; bigger requests are silently truncated. Each turn — including retries to widen or re-narrow — costs context, so pick a focused window the first time rather than starting wide and trimming.";

const MULTI_TURN_TIP =
'**Delegate multi-call work to a sub-agent.** Code-navigation tools (`search`, `code_grep`, `code_read`, `code_files`) are inherently multi-call — answering even simple questions usually takes 3-10 tool calls, and reading raw source into the main conversation compounds quickly. The default approach for any cross-project comparison, codebase mapping, pattern survey, or "how does X actually work" investigation is to spawn a sub-task / sub-agent that does the digging and returns only a compact synthesis. Do the work in the main conversation only when the result genuinely belongs there (e.g., the user asked for a specific snippet to paste into their own code). When in doubt, delegate.';

/**
* Whether the MCP session should register and describe package tools.
Expand Down Expand Up @@ -116,10 +122,19 @@ export function buildMcpInstructions(deps: Dependencies): string {
return sections.join("\n\n");
}

const parts = [PACKAGE_TOOLS_PREAMBLE, bullets.join("\n")];
// The decision tip contrasts `get_example` with unified `search`, so
// it is only meaningful when unified search is actually registered.
const parts = [PACKAGE_TOOLS_PREAMBLE];
// The multi-turn tip leads when code-navigation is wired — it is
// the highest-leverage decision the agent makes (delegate vs.
// run inline) and reading it before the per-tool bullets means
// the framing arrives before any specific tool fires. The
// reference-first / decision tips follow the bullets where they
// act as workflow guidance for the chosen tool.
if (deps.codeNavigationService) {
parts.push(MULTI_TURN_TIP);
}
parts.push(bullets.join("\n"));
if (deps.codeNavigationService) {
parts.push(REFERENCE_FIRST_TIP);
parts.push(SEARCH_VS_SYMBOLS_TIP);
}

Expand Down
216 changes: 216 additions & 0 deletions src/shared/grep-repo-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it } from "bun:test";
import type {
LeanGrepRepoEnvelope,
LeanGrepRepoMatch,
} from "./grep-repo-response.js";
import { renderGrepRepoText } from "./grep-repo-text.js";

function envelope(
overrides: Partial<LeanGrepRepoEnvelope> = {},
): LeanGrepRepoEnvelope {
return {
pattern: "applyEdit",
matches: [],
hasMore: false,
filesScanned: 120,
filesInScope: 120,
totalMatches: 0,
uniqueFilesMatched: 0,
...overrides,
};
}

function match(overrides: Partial<LeanGrepRepoMatch> = {}): LeanGrepRepoMatch {
return {
filePath: "src/diff/foo.ts",
line: 142,
matchStartByte: 16,
matchEndByte: 25,
lineContent: "export function applyEdit(input: string): string {",
...overrides,
};
}

describe("renderGrepRepoText", () => {
it("renders zero-match header and empty body", () => {
const text = renderGrepRepoText(envelope());
expect(text).toContain("code_grep | 0 matches in 0 files");
expect(text).toContain('pattern="applyEdit"');
expect(text).toContain("No matches.");
});

it("renders single-file matches grouped under the file with line gutter", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 2,
uniqueFilesMatched: 1,
matches: [
match({ line: 142 }),
match({
line: 287,
lineContent: " const result = applyEdit(input, hunk);",
}),
],
}),
);
expect(text).toContain("src/diff/foo.ts (2)");
expect(text).toContain(
" 142: export function applyEdit(input: string): string {",
);
expect(text).toContain(" 287: const result = applyEdit(input, hunk);");
});

it("renders matches across multiple files with blank-line separators", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 2,
uniqueFilesMatched: 2,
matches: [
match({ filePath: "src/a.ts", line: 10, lineContent: "a-line" }),
match({ filePath: "src/b.ts", line: 20, lineContent: "b-line" }),
],
}),
);
expect(text).toContain("src/a.ts (1)");
expect(text).toContain("src/b.ts (1)");
const aIdx = text.indexOf("src/a.ts");
const bIdx = text.indexOf("src/b.ts");
expect(aIdx).toBeLessThan(bIdx);
// Two file blocks separated by a blank line.
const between = text.slice(aIdx, bIdx);
expect(between).toContain("\n\n");
});

it("renders context lines with grep -A/-B separators", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 1,
uniqueFilesMatched: 1,
matches: [
match({
line: 142,
contextBefore: [
"// Apply a unified diff patch",
"// Returns the new content",
],
contextAfter: [' const lines = file.split("\\n");'],
}),
],
}),
);
expect(text).toContain(" 140- // Apply a unified diff patch");
expect(text).toContain(" 141- // Returns the new content");
expect(text).toContain(
" 142: export function applyEdit(input: string): string {",
);
expect(text).toContain(' 143- const lines = file.split("\\n");');
});

it("inserts a separator between non-adjacent blocks in the same file", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 2,
uniqueFilesMatched: 1,
matches: [
match({
line: 50,
contextBefore: ["before-50"],
contextAfter: ["after-50"],
}),
match({
line: 200,
contextBefore: ["before-200"],
contextAfter: ["after-200"],
}),
],
}),
);
expect(text).toContain(" --");
});

it("renders truncation notice when truncatedReason is set", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 50,
uniqueFilesMatched: 7,
truncatedReason: "limit",
hasMore: true,
matches: [match()],
}),
);
expect(text).toContain("Truncated: limit.");
expect(text).toContain("max_matches");
});

it("renders next-cursor note when hasMore", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 50,
uniqueFilesMatched: 7,
hasMore: true,
nextCursor: "ABC123",
matches: [match()],
}),
);
expect(text).toContain("More matches available. Pass cursor=ABC123");
});

it("renders pattern-type and case-sensitive flags in header when set", () => {
const text = renderGrepRepoText(
envelope({
patternType: "regex",
caseSensitive: true,
totalMatches: 1,
uniqueFilesMatched: 1,
matches: [match()],
}),
);
expect(text).toContain("regex,case-sensitive");
});

it("echoes filter inputs in header when set", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 1,
uniqueFilesMatched: 1,
matches: [match()],
filter: {
pathPrefix: "src/integrations",
extensions: ["ts", "tsx"],
maxMatches: 30,
},
}),
);
expect(text).toContain('path_prefix="src/integrations"');
expect(text).toContain("exts=ts,tsx");
expect(text).toContain("max_matches=30");
});

it("uses ASCII separators only", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 1,
uniqueFilesMatched: 1,
truncatedReason: "limit",
hasMore: true,
nextCursor: "X",
matches: [match()],
}),
);
expect(text).not.toMatch(/[·…—–]/);
});

it("notes binary/oversized skips when present", () => {
const text = renderGrepRepoText(
envelope({
totalMatches: 1,
uniqueFilesMatched: 1,
binaryFilesSkipped: 4,
filesTooLargeSkipped: 2,
matches: [match()],
}),
);
expect(text).toContain("4 binary file(s) skipped");
expect(text).toContain("2 oversized file(s) skipped");
});
});
Loading
Loading