@takara-ai/miru-code 1.12.0 → 1.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -200,9 +200,9 @@ When wired via `miru install`, the MCP server exposes `search`, `locate`, `expan
200
200
  ### Workflow
201
201
 
202
202
  1. **`search`** with `query` — returns compact snippets (~±15 lines) and relevance scores.
203
- 2. If a hit has **`truncated: true`**, call **`expand`** with `file_path` and `anchor_line` — not another search or a full-file read.
204
- 3. To trace similar patterns elsewhere, call **`find_related`** with the same `file_path` and `anchor_line`.
205
- 4. Use your editor's **Read** on `absolute_path` only when editing or when `expand` still lacks context.
203
+ 2. If a hit has **`truncated: true`**, its header names the symbol and its exact lines (`path:start-end Symbol`) plus `anchor_line`. Read just those lines with a ranged **Read** (`offset`/`limit`) — not another search or a full-file read.
204
+ 3. If that range lacks context, call **`expand`** with `file_path` and `anchor_line` for neighbouring chunks. To trace similar patterns elsewhere, call **`find_related`** with the same `file_path` and `anchor_line`.
205
+ 4. Use your editor's **Read** on `absolute_path` only for a hit's line range, to see a truncated body or edit it.
206
206
 
207
207
  Keep `repo` on follow-up calls when set.
208
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
4
4
  "mcpName": "ai.takara/miru",
5
5
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
6
6
  "type": "module",
@@ -14,53 +14,13 @@ args = ["${MIRU_BUNX_PACKAGE}", "mcp", "--benchmark"]
14
14
  startup_timeout_sec = 60
15
15
  `;
16
16
 
17
+ /** A string literal (kept) or a comment (dropped); a string left open runs to the end of the input. */
18
+ const STRING_OR_COMMENT =
19
+ /("(?:\\[\s\S]|[^"\\])*\\?(?:"|$)|'(?:\\[\s\S]|[^'\\])*\\?(?:'|$))|\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)/g;
20
+
17
21
  /** Strip line and block comments so JSONC configs can be parsed. */
18
22
  export function stripJsonComments(text: string): string {
19
- let out = "";
20
- let i = 0;
21
- let inString = false;
22
- let stringQuote = '"';
23
- while (i < text.length) {
24
- const ch = text[i];
25
- const next = text[i + 1];
26
- if (inString) {
27
- out += ch;
28
- if (ch === "\\" && next !== undefined) {
29
- out += next;
30
- i += 2;
31
- continue;
32
- }
33
- if (ch === stringQuote) {
34
- inString = false;
35
- }
36
- i++;
37
- continue;
38
- }
39
- if (ch === '"' || ch === "'") {
40
- inString = true;
41
- stringQuote = ch;
42
- out += ch;
43
- i++;
44
- continue;
45
- }
46
- if (ch === "/" && next === "/") {
47
- while (i < text.length && text[i] !== "\n") {
48
- i++;
49
- }
50
- continue;
51
- }
52
- if (ch === "/" && next === "*") {
53
- i += 2;
54
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
55
- i++;
56
- }
57
- i += 2;
58
- continue;
59
- }
60
- out += ch;
61
- i++;
62
- }
63
- return out;
23
+ return text.replace(STRING_OR_COMMENT, (_match, str?: string) => str ?? "");
64
24
  }
65
25
 
66
26
  function parseJsonObject(text: string): Record<string, unknown> | "error" {
@@ -285,34 +245,7 @@ function removeJsonMemberText(text: string, sectionKey: string, memberKey: strin
285
245
 
286
246
  /** True only when // or /* appear outside of strings (URLs like https:// must not match). */
287
247
  function hasJsoncSyntax(text: string): boolean {
288
- let inString = false;
289
- let quote = "";
290
- for (let i = 0; i < text.length; i++) {
291
- const ch = text[i] ?? "";
292
- const next = text[i + 1] ?? "";
293
- if (inString) {
294
- if (ch === "\\") {
295
- i++;
296
- continue;
297
- }
298
- if (ch === quote) {
299
- inString = false;
300
- }
301
- continue;
302
- }
303
- if (ch === '"' || ch === "'") {
304
- inString = true;
305
- quote = ch;
306
- continue;
307
- }
308
- if (ch === "/" && next === "/") {
309
- return true;
310
- }
311
- if (ch === "/" && next === "*") {
312
- return true;
313
- }
314
- }
315
- return false;
248
+ return stripJsonComments(text) !== text;
316
249
  }
317
250
 
318
251
  /** Locate an object section by dotted path, matching each key among direct children. */
@@ -1,8 +1,9 @@
1
1
  /** Shared code-search policy text for instructions, Cursor rules, and sub-agents. */
2
2
 
3
3
  export const SNIPPET_GUIDANCE =
4
- "Snippets are ~±15 lines. On `truncated: true`, call `expand` with `file_path` and " +
5
- "`anchor_line` — only if the snippet doesn't already answer.";
4
+ "Snippets are ~±15 lines. A `truncated: true` hit names its symbol and exact lines (`path:start-end Symbol`) plus `anchor_line`: " +
5
+ "to see the full body, read exactly those lines with a ranged read (`offset`/`limit`), never the whole file. " +
6
+ "`expand` (`file_path` + `anchor_line`) is the fallback for neighbouring chunks — only if the snippet doesn't already answer.";
6
7
 
7
8
  export const WHEN_TO_REACH_FOR_MIRU =
8
9
  "Reach for Miru FIRST, before opening files, whenever you don't know the exact path: starting in unfamiliar code, " +
@@ -23,10 +24,11 @@ export function buildSearchPolicyTable(native: NativeToolNames): string {
23
24
  | Regex over code | Miru MCP \`locate\` with \`/regex/\` (\`mode=files\` for paths only) | ${native.grep} |
24
25
  | List files by name pattern | Miru MCP \`locate\` with \`include\` globs and no literal | Glob |
25
26
  | How/where/what handles X? (conceptual) | Miru MCP \`search\` (once) | ${native.grep}, ${native.explorationDenied} |
26
- | Same file, more context | Miru MCP \`expand\` on \`truncated: true\` | Re-search, ${native.read} whole file |
27
+ | Full body of a \`truncated: true\` hit | ${native.read} the hit's \`start-end\` lines (\`offset\`/\`limit\`) | Re-search, ${native.read} whole file |
28
+ | Same file, neighbouring chunks | Miru MCP \`expand\` (fallback) | Re-search |
27
29
  | Similar code elsewhere | Miru MCP \`find_related\` | ${native.grep} chains |
28
30
  | Search docs or config | Miru \`search\` | ${native.grep} README paths |
29
- | Edit a known file:line | ${native.read} (after Miru found it) | ${native.read}-before-search |`;
31
+ | Edit a known file:line | ${native.read} the hit's \`start-end\` lines (after Miru found it) | ${native.read}-before-search |`;
30
32
  }
31
33
 
32
34
  export function buildSearchPolicyBody(native: NativeToolNames): string {
@@ -43,8 +45,8 @@ ${SNIPPET_GUIDANCE}
43
45
  Use Miru MCP tools:
44
46
  - \`locate\` — exact substring (env var, symbol, error code) or \`/regex/\`; no literal + \`include\` globs lists files; prefer \`mode=count\`, \`files\` or \`locations\`
45
47
  - \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
46
- - \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
47
- - \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
48
+ - \`expand\` — fallback: neighbouring chunks in the same file around \`anchor_line\` (\`file_path\` + \`anchor_line\`)
49
+ - \`find_related\` — similar code in other files (hits may also be snippets; read the hit's lines if truncated)
48
50
 
49
51
  Call shapes:
50
52
  - \`locate(literal="<exact substring>")\` — prefer \`mode="locations"\`/\`"count"\`
@@ -53,8 +55,8 @@ Call shapes:
53
55
  Stop rules:
54
56
  - Literal in the request → \`locate\`, not \`search\`
55
57
  - One \`search\` per question — no paraphrase retries
56
- - \`expand\` only if the snippet doesn't already answer
57
- - ${native.read} only to edit a path Miru already found, not to explore
58
+ - On \`truncated: true\`, ${native.read} only the hit's \`start-end\` lines (\`offset\`/\`limit\`); \`expand\` only if that doesn't answer
59
+ - ${native.read} only a path Miru already found, to see a truncated body or edit it — never to explore, never the whole file
58
60
 
59
61
  Native tools are allowed ONLY when:
60
62
  - reading a file you already located via Miru, to edit it
@@ -69,9 +71,9 @@ export function buildMcpWorkflow(native: NativeToolNames): string {
69
71
 
70
72
  1. Call \`search\`; add \`repo\` only for another repo.
71
73
  2. For exact literals, call \`locate\` (prefer \`mode=count\` or \`locations\`).
72
- 3. On \`truncated: true\`, call \`expand\` — only if the snippet doesn't already answer.
73
- 4. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
74
- 5. ${native.read} via \`absolute_path\` only when editing or when \`expand\` still lacks context.`;
74
+ 3. On \`truncated: true\`, ${native.read} the hit's \`start-end\` lines (\`offset\`/\`limit\`) — only if the snippet doesn't already answer.
75
+ 4. Use \`expand\` for neighbouring chunks if that range lacks context; \`find_related\` to trace similar code in other files.
76
+ 5. ${native.read} via \`absolute_path\` only for the hit's range, to see a truncated body or edit it.`;
75
77
  }
76
78
 
77
79
  const CLI_FALLBACK = `### CLI fallback (no MCP in this session)
@@ -154,13 +156,13 @@ export const MCP_SEARCH_TOOL_DESCRIPTION =
154
156
  export const MCP_LOCATE_TOOL_DESCRIPTION =
155
157
  "Exact substring locator over the Miru index, also `/regex/` and file lists (grep for identifiers, env vars, error strings, quoted text, without the noise). " +
156
158
  'Use for known literals, not meaning-based questions (`search`), even when phrased "where is X" if X is a literal. ' +
157
- "A literal in slashes (`/foo\\w+Bar/`, `/…/i`) is a regex. No literal + `include` globs lists files by name pattern; mode=files returns just matching paths. " +
159
+ "A literal wrapped in slashes is always a regex (`/foo\\w+Bar/`, `/bench/i`); omit the slashes for plain text. No literal + `include` globs lists files by name pattern; mode=files returns just matching paths. " +
158
160
  "Returns ALL matches by default — do NOT fall back to Grep/rg when n is large; prefer mode=count/files/locations unless you need line text. " +
159
161
  "`context_lines` (mode=lines) saves a follow-up `expand`. In benchmark mode, includes token savings vs Grep.";
160
162
 
161
163
  export const MCP_EXPAND_TOOL_DESCRIPTION =
162
- "More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` from the hit. " +
163
- "Use only if `truncated: true` and the snippet doesn't already answer the question — not for similar code elsewhere (use find_related).";
164
+ "Neighbouring chunks in the SAME file as a search hit (`file_path` + `anchor_line` from the hit). " +
165
+ "For a `truncated: true` hit's own full body, read its `start-end` lines instead; use this only if that doesn't answer — not for similar code elsewhere (use find_related).";
164
166
 
165
167
  export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
166
168
  "Find up to 5 chunks similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
package/src/literal.ts CHANGED
@@ -56,19 +56,22 @@ export interface LiteralLocateOptions {
56
56
  context_lines?: number;
57
57
  }
58
58
 
59
- /** A `/body/` (optionally `/body/i`) literal is a regex only if the body has regex syntax, so `/api/users/` stays a plain substring. */
60
- const SLASH_REGEX = /^\/(.+)\/(i?)$/s;
61
- const REGEX_SYNTAX = /[\\^$.*+?()[\]{}|]/;
59
+ /**
60
+ * `/body/` and `/body/i` are always regexes. Anything else is plain text, so drop the outer
61
+ * slashes to search a path such as `api/users/` as a substring.
62
+ */
63
+ const SLASH_REGEX = /^\/(?<body>.+)\/(?<flag>i?)$/s;
62
64
  /** Regex is tested per line; cap the tested prefix so a minified line cannot stall the scan. */
63
65
  const MAX_REGEX_LINE_CHARS = 4096;
64
66
 
67
+ /** The regex a slash-wrapped literal denotes, or null for a plain-text literal. */
65
68
  function parseRegexLiteral(literal: string, ignoreCase: boolean): RegExp | null {
66
- const m = SLASH_REGEX.exec(literal);
67
- if (!m || !REGEX_SYNTAX.test(m[1] ?? "")) {
69
+ const groups = SLASH_REGEX.exec(literal)?.groups;
70
+ if (!groups) {
68
71
  return null;
69
72
  }
70
73
  try {
71
- return new RegExp(m[1] ?? "", ignoreCase || m[2] === "i" ? "i" : "");
74
+ return new RegExp(groups.body ?? "", ignoreCase || groups.flag === "i" ? "i" : "");
72
75
  } catch (err) {
73
76
  throw new Error(
74
77
  `Invalid regex ${literal}: ${err instanceof Error ? err.message : String(err)}`,
@@ -100,6 +103,9 @@ function lineMatchesAny(
100
103
  needles: readonly string[],
101
104
  ignoreCase: boolean,
102
105
  ): boolean {
106
+ if (needles.length === 0) {
107
+ return false;
108
+ }
103
109
  const hay = ignoreCase ? haystack.toLowerCase() : haystack;
104
110
  for (const needle of needles) {
105
111
  const n = ignoreCase ? needle.toLowerCase() : needle;
@@ -158,6 +164,51 @@ function expandLiterals(literals: readonly string[], matchVariants: boolean): st
158
164
  return [...seen];
159
165
  }
160
166
 
167
+ interface LiteralMatcher {
168
+ /** Plain substrings actually searched, after identifier-variant expansion. */
169
+ needles: readonly string[];
170
+ /** The slash-wrapped inputs, verbatim. */
171
+ regexLiterals: readonly string[];
172
+ isEmpty: boolean;
173
+ matchesLine(line: string): boolean;
174
+ /** Cheap whole-chunk test: false means no line in the chunk can match. */
175
+ chunkMayMatch(content: string): boolean;
176
+ }
177
+
178
+ /** Splits literals into plain substrings and regexes, and builds the line and chunk tests. */
179
+ function compileMatcher(
180
+ literals: readonly string[],
181
+ ignoreCase: boolean,
182
+ matchVariants: boolean,
183
+ ): LiteralMatcher {
184
+ const regexes: RegExp[] = [];
185
+ const regexLiterals: string[] = [];
186
+ const plain: string[] = [];
187
+ for (const literal of literals) {
188
+ const re = parseRegexLiteral(literal, ignoreCase);
189
+ if (re) {
190
+ regexes.push(re);
191
+ regexLiterals.push(literal);
192
+ } else {
193
+ plain.push(literal);
194
+ }
195
+ }
196
+ const needles = expandLiterals(plain, matchVariants);
197
+ // One JIT-compiled pass per chunk rules out most chunks before any line is split or tested.
198
+ // Multiline anchors keep `^`/`$` line-relative, so this can only over-match, never miss.
199
+ const chunkRegexes = regexes.map((re) => new RegExp(re.source, `${re.flags}m`));
200
+
201
+ return {
202
+ needles,
203
+ regexLiterals,
204
+ isEmpty: needles.length === 0 && regexes.length === 0,
205
+ matchesLine: (line) =>
206
+ lineMatchesAny(line, needles, ignoreCase) || regexMatchesLine(line, regexes),
207
+ chunkMayMatch: (content) =>
208
+ lineMatchesAny(content, needles, ignoreCase) || chunkRegexes.some((re) => re.test(content)),
209
+ };
210
+ }
211
+
161
212
  export function filterChunksByGlob(
162
213
  chunks: readonly Chunk[],
163
214
  include: string[] | undefined,
@@ -242,21 +293,8 @@ export function locateLiteral(
242
293
 
243
294
  const inputLiterals = (Array.isArray(literal) ? literal : [literal]).filter((l) => l.length > 0);
244
295
  const label = inputLiterals.join(" | ");
245
- const regexes: RegExp[] = [];
246
- const plainLiterals: string[] = [];
247
- for (const lit of inputLiterals) {
248
- const re = parseRegexLiteral(lit, ignoreCase);
249
- if (re) {
250
- regexes.push(re);
251
- } else {
252
- plainLiterals.push(lit);
253
- }
254
- }
255
- const needles = expandLiterals(plainLiterals, options.match_variants === true);
256
- const matchesLine = (text: string): boolean =>
257
- lineMatchesAny(text, needles, ignoreCase) || regexMatchesLine(text, regexes);
258
-
259
- if (needles.length === 0 && regexes.length === 0) {
296
+ const matcher = compileMatcher(inputLiterals, ignoreCase, options.match_variants === true);
297
+ if (matcher.isEmpty) {
260
298
  return { literal: label, mode, n: 0, files: 0, truncated: false, hits: [] };
261
299
  }
262
300
 
@@ -270,13 +308,13 @@ export function locateLiteral(
270
308
  let n = 0;
271
309
 
272
310
  for (const chunk of scopedChunks) {
273
- if (regexes.length === 0 && !lineMatchesAny(chunk.content, needles, ignoreCase)) {
311
+ if (!matcher.chunkMayMatch(chunk.content)) {
274
312
  continue;
275
313
  }
276
314
  const lines = chunk.content.split("\n");
277
315
  for (let i = 0; i < lines.length; i++) {
278
316
  const lineText = lines[i] ?? "";
279
- if (!matchesLine(lineText)) {
317
+ if (!matcher.matchesLine(lineText)) {
280
318
  continue;
281
319
  }
282
320
  const line = chunk.start_line + i;
@@ -318,7 +356,7 @@ export function locateLiteral(
318
356
  if (mode === "files") {
319
357
  result.file_list = [...fileSet].sort();
320
358
  }
321
- const searched = [...needles, ...inputLiterals.filter((l) => !plainLiterals.includes(l))];
359
+ const searched = [...matcher.needles, ...matcher.regexLiterals];
322
360
  if (searched.length > 1) {
323
361
  result.literals = searched;
324
362
  }
@@ -12,10 +12,19 @@ export function formatResultsText(payload: {
12
12
 
13
13
  const blocks = payload.results.map((result) => {
14
14
  const chunk = result.chunk as Record<string, unknown>;
15
- const startLine = chunk.truncated ? chunk.full_start_line : chunk.start_line;
16
- const endLine = chunk.truncated ? chunk.full_end_line : chunk.end_line;
17
- const truncatedNote = chunk.truncated ? " [truncated: call expand for full chunk]" : "";
18
- const header = `${chunk.file_path}:${startLine}-${endLine}${truncatedNote}`;
15
+ // A truncated hit names the matched symbol's exact range (not the source file's) so the full
16
+ // body is one ranged read away; `anchor_line` is the matched line for `expand`/`find_related`.
17
+ const startLine = chunk.truncated
18
+ ? (chunk.match_start_line ?? chunk.full_start_line)
19
+ : chunk.start_line;
20
+ const endLine = chunk.truncated
21
+ ? (chunk.match_end_line ?? chunk.full_end_line)
22
+ : chunk.end_line;
23
+ const symbol = chunk.truncated && chunk.symbol ? ` ${chunk.symbol}` : "";
24
+ const truncatedNote = chunk.truncated
25
+ ? ` [truncated: anchor_line ${chunk.anchor_line}; read lines ${startLine}-${endLine} for the full symbol, or call expand]`
26
+ : "";
27
+ const header = `${chunk.file_path}:${startLine}-${endLine}${symbol}${truncatedNote}`;
19
28
  return `${header}\n${chunk.content}`;
20
29
  });
21
30
 
package/src/mcp/server.ts CHANGED
@@ -250,7 +250,7 @@ export function createMcpServer(
250
250
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
251
251
  .optional()
252
252
  .describe(
253
- "Exact substring, or a regex in slashes like `/foo\\w+Bar/` (`/…/i` ignores case). An array ORs several. Omit and pass `include` to list files.",
253
+ "Exact substring, or a regex wrapped in slashes like `/foo\\w+Bar/` or `/bench/i` (ignores case). An array ORs several. Omit and pass `include` to list files.",
254
254
  ),
255
255
  query: z
256
256
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
package/src/snippet.ts CHANGED
@@ -13,6 +13,11 @@ export interface SnippetMeta {
13
13
  anchor_line: number;
14
14
  full_start_line: number;
15
15
  full_end_line: number;
16
+ /** Exact lines of the matched (ranked) chunk — the symbol-sized range to read, not the source file. */
17
+ match_start_line: number;
18
+ match_end_line: number;
19
+ /** Name of the symbol the matched chunk declares, when one is recognizable. */
20
+ symbol: string | null;
16
21
  }
17
22
 
18
23
  export interface SnippetResult {
@@ -73,6 +78,24 @@ function scoreLine(line: string, terms: Set<string>): number {
73
78
 
74
79
  const DECLARATION_RE =
75
80
  /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|enum|type|struct|trait|impl|record|namespace|contract|library|object)\b|^\s*(?:export\s+)?(?:const|let|var)\s+[$\w]+\s*=\s*[{[(]|^\s*(?:async\s+)?def\s+\w+|^\s*def\s+\w+|^\s*fn\s+\w+|^\s*func\s+\w+|^\s*defmodule\b|^\s*def\w*\b|^\s*(?:(?:public|private|protected|internal|static|final|abstract|virtual|override|sealed|partial|unsafe|extern)\s+)+(?:[A-Za-z_$][\w:<>[\]?.,\s]*\s+)+[A-Za-z_$]\w*\s*\(|^\s*(?:void|bool|boolean|char|short|int|long|float|double|string|[A-Z][\w:<>[\]?.,]*)\s+[A-Za-z_$]\w*\s*\(|^\s*(?:function\s+)?[A-Za-z_]\w*\s*\(\)\s*\{/;
81
+ const DECLARED_NAME_RE =
82
+ /\b(?:function\*?|class|interface|enum|type|struct|trait|impl|record|namespace|contract|library|object|def|fn|func|defmodule)\s+([A-Za-z_$][\w$]*)|\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/;
83
+
84
+ /** Name declared by the first declaration line in `content`, or null when none is recognizable. */
85
+ export function declaredSymbol(content: string): string | null {
86
+ for (const line of content.split("\n").slice(0, MAX_DECLARATION_LOOKBACK)) {
87
+ if (!DECLARATION_RE.test(line)) {
88
+ continue;
89
+ }
90
+ const match = DECLARED_NAME_RE.exec(line);
91
+ const name = match?.[1] ?? match?.[2];
92
+ if (name) {
93
+ return name;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
76
99
  const BRACE_BODY_RE =
77
100
  /\b(?:function|class|interface|enum|struct|trait|impl|record|namespace|contract|library|object|fn|func)\b|=\s*[{[(]|[A-Za-z_$]\w*\s*\([^)]*\)\s*\{/;
78
101
  const RUBY_OR_ELIXIR_BODY_RE = /^\s*def\w*\b|^\s*defmodule\b/;
@@ -419,6 +442,9 @@ export function trimChunkToSnippet(
419
442
  anchor_line: chunk.start_line + anchorOffset,
420
443
  full_start_line: chunk.start_line,
421
444
  full_end_line: chunk.end_line,
445
+ match_start_line: chunk.start_line,
446
+ match_end_line: chunk.end_line,
447
+ symbol: declaredSymbol(chunk.content),
422
448
  },
423
449
  };
424
450
  }
@@ -436,6 +462,9 @@ export function trimChunkToSnippet(
436
462
  anchor_line: chunk.start_line + anchorOffset,
437
463
  full_start_line: chunk.start_line,
438
464
  full_end_line: chunk.end_line,
465
+ match_start_line: chunk.start_line,
466
+ match_end_line: chunk.end_line,
467
+ symbol: declaredSymbol(chunk.content),
439
468
  },
440
469
  };
441
470
  }
@@ -460,8 +489,20 @@ export function applySnippetsToResults(
460
489
  });
461
490
  return contextualResults.map(({ result, anchorLine }, rank) => {
462
491
  const { chunk, meta } = trimChunkToSnippet(result.chunk, query, radius, anchorLine);
492
+ // `result.chunk` may be the whole source file; the ranked chunk is the symbol-sized match.
493
+ const ranked = results[rank]?.chunk;
463
494
  const snippet = compactSupportingSnippet(
464
- { chunk, meta },
495
+ {
496
+ chunk,
497
+ meta: ranked
498
+ ? {
499
+ ...meta,
500
+ match_start_line: ranked.start_line,
501
+ match_end_line: ranked.end_line,
502
+ symbol: declaredSymbol(ranked.content) ?? meta.symbol,
503
+ }
504
+ : meta,
505
+ },
465
506
  rank === 0 ? primaryMaxLines : supportingMaxLines,
466
507
  );
467
508
  return { result: { chunk: snippet.chunk, score: result.score }, meta: snippet.meta };
package/src/utils.ts CHANGED
@@ -210,6 +210,9 @@ export function formatResults(
210
210
  anchor_line: entry.meta.anchor_line,
211
211
  full_start_line: entry.meta.full_start_line,
212
212
  full_end_line: entry.meta.full_end_line,
213
+ match_start_line: entry.meta.match_start_line,
214
+ match_end_line: entry.meta.match_end_line,
215
+ ...(entry.meta.symbol ? { symbol: entry.meta.symbol } : {}),
213
216
  },
214
217
  score,
215
218
  };