diff --git a/src/detection/git-branch.ts b/src/detection/git-branch.ts index de7b7eb..5b88917 100644 --- a/src/detection/git-branch.ts +++ b/src/detection/git-branch.ts @@ -14,6 +14,9 @@ export async function getGitBranch( cwd: projectDir, timeout: GIT_TIMEOUT_MS, killSignal: 'SIGTERM', + // The OpenCode desktop background service has no console; without this + // Windows allocates a visible console window for git on every dispatch. + windowsHide: true, }; execFn( 'git', diff --git a/src/index.rules.test.ts b/src/index.rules.test.ts index 8bc3419..f8ad416 100644 --- a/src/index.rules.test.ts +++ b/src/index.rules.test.ts @@ -853,6 +853,19 @@ describe('promptMatchesKeywords', () => { expect(promptMatchesKeywords('file.test.ts', ['test.ts'])).toBe(true); expect(promptMatchesKeywords('run tests now', ['test.ts'])).toBe(false); }); + + it('should match non-ASCII (Cyrillic) keywords at the start of a word', () => { + expect(promptMatchesKeywords('сделай ревью этого модуля', ['ревью'])).toBe( + true + ); + expect(promptMatchesKeywords('поправь форму документа', ['форм'])).toBe( + true + ); + }); + + it('should not match a Cyrillic keyword mid-word', () => { + expect(promptMatchesKeywords('платформа', ['форм'])).toBe(false); + }); }); describe('toolsMatchAvailable', () => { diff --git a/src/rules/rule-filter.ts b/src/rules/rule-filter.ts index e991f48..e201f5e 100644 --- a/src/rules/rule-filter.ts +++ b/src/rules/rule-filter.ts @@ -78,9 +78,15 @@ export function promptMatchesKeywords( return keywords.some(keyword => { const lowerKeyword = keyword.toLowerCase(); const escaped = lowerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Leading word boundary only: "test" matches "testing". - const regex = new RegExp(`\\b${escaped}`, 'i'); - return regex.test(lowerPrompt); + // Unicode-aware leading boundary: JavaScript \b only knows ASCII \w, so + // it never matches before a Cyrillic letter. "test" still matches + // "testing" because only the leading boundary is anchored. + try { + const regex = new RegExp(`(? { ); }); }); + +describe('normalizeToolObservation: MCP file paths', () => { + it('extracts path= from MCP output regardless of language', () => { + const output1C = [ + '# scope=core', + 'path=Documents/Заказ покупателя/Forms/Форма/Ext/Form/Module.bsl lines=10-25', + '--- code ---', + 'Процедура Тест()', + ].join('\n'); + + expect( + normalizeObservations({ + tool: 'mcp__1c-index__read_method', + args: {}, + output: output1C, + }) + ).toEqual([ + { + path: 'Documents/Заказ покупателя/Forms/Форма/Ext/Form/Module.bsl', + tool: 'mcp__1c-index__read_method', + content: output1C, + }, + ]); + + const outputTS = 'path=src/components/Button.tsx lines=1-20'; + expect( + normalizeObservations({ + tool: 'mcp__code_indexer__read', + args: {}, + output: outputTS, + }) + ).toEqual([ + { + path: 'src/components/Button.tsx', + tool: 'mcp__code_indexer__read', + content: outputTS, + }, + ]); + }); + + it('extracts module_path= and file= attributes', () => { + expect( + normalizeObservations({ + tool: 'mcp__analyzer__object_info', + args: {}, + output: 'module_path=services/auth.py', + }) + ).toEqual([ + { + path: 'services/auth.py', + tool: 'mcp__analyzer__object_info', + content: 'module_path=services/auth.py', + }, + ]); + + expect( + normalizeObservations({ + tool: 'mcp__filesystem__get_file', + args: {}, + output: 'file="src/My File.rs"', + }) + ).toEqual([ + { + path: 'src/My File.rs', + tool: 'mcp__filesystem__get_file', + content: 'file="src/My File.rs"', + }, + ]); + }); + + it('extracts paths from grep-style output and caps at five', () => { + const output = [ + '- src/core/a.go:10 found', + '- src/core/b.go:20 found', + '- src/core/c.go:30 found', + '- src/core/d.go:40 found', + '- src/core/e.go:50 found', + '- src/core/f.go:60 found', + ].join('\n'); + + const observations = normalizeObservations({ + tool: 'mcp__ripgrep__search', + args: {}, + output, + }); + + expect(observations).toHaveLength(5); + expect(observations.map(o => o.path)).toEqual([ + 'src/core/a.go', + 'src/core/b.go', + 'src/core/c.go', + 'src/core/d.go', + 'src/core/e.go', + ]); + }); + + it('supports JSON MCP results', () => { + const output = JSON.stringify({ + path: 'src/app.ts', + results: [{ file_path: 'src/lib/a.py' }], + }); + + const observations = normalizeObservations({ + tool: 'mcp__code_indexer__search', + args: {}, + output, + }); + + expect(observations.map(o => o.path)).toEqual([ + 'src/app.ts', + 'src/lib/a.py', + ]); + }); + + it('normalizes Windows separators and keeps spaces in paths', () => { + const output = 'path=C:\\repo\\src\\My File.ts lines=1-2'; + + expect( + normalizeObservations({ + tool: 'mcp__filesystem__read', + args: {}, + output, + }) + ).toEqual([ + { + path: 'C:/repo/src/My File.ts', + tool: 'mcp__filesystem__read', + content: output, + }, + ]); + }); + + it('extracts Cyrillic grep rows and ignores URLs and plain text', () => { + expect( + normalizeObservations({ + tool: '1c-index_search_code', + args: {}, + output: '- Documents/Заказ покупателя/Module.bsl:42 found', + }).map(o => o.path) + ).toEqual(['Documents/Заказ покупателя/Module.bsl']); + + expect( + normalizeObservations({ + tool: 'mcp__search__run', + args: {}, + output: 'See https://example.com/docs.ts for details\nJust plain text', + }) + ).toEqual([]); + }); + + it('uses path-like MCP arguments when the output has no path', () => { + const output = ''; + + expect( + normalizeObservations({ + tool: 'mcp__filesystem__read_file', + args: { path: 'src/App.vue' }, + output, + }) + ).toEqual([ + { + path: 'src/App.vue', + tool: 'mcp__filesystem__read_file', + content: output, + }, + ]); + }); +}); diff --git a/src/session/file-observation.ts b/src/session/file-observation.ts index 608e2d9..bb521ad 100644 --- a/src/session/file-observation.ts +++ b/src/session/file-observation.ts @@ -148,7 +148,220 @@ function summaryPaths(output: string | undefined): FileObservation[] { return result; } +// MCP tools are not part of OBSERVATION_TOOLS. Code-navigation MCP servers +// (1c-index, ripgrep wrappers, language indexers, filesystem/memory servers) +// report workspace paths in their output. Parse those into ordinary file +// observations so glob rules auto-attach when the agent inspects code through +// MCP instead of the native read tool. +// +// OpenCode names MCP tools either "mcp____" or +// "_" (for example "1c-index_read_method"), so detect a known +// operation segment rather than hard-coding one server's full tool names. +const MCP_TOOL_SEGMENTS = new Set([ + 'read', + 'find', + 'search', + 'lookup', + 'inspect', + 'info', + 'get', + 'grep', + 'list', + 'scan', + 'query', + 'glob', +]); + +// Native tools keep their own observation path; do not parse their output here. +const NATIVE_TOOL_NAMES = new Set([ + ...OBSERVATION_TOOLS, + 'bash', + 'shell', + 'webfetch', + 'websearch', + 'glob', + 'grep', + 'task', + 'skill', +]); + +function isMcpFileTool(tool: string): boolean { + if (tool.startsWith('mcp__')) return true; + if (NATIVE_TOOL_NAMES.has(tool)) return false; + return tool + .toLowerCase() + .split(/[_-]+/) + .some(segment => MCP_TOOL_SEGMENTS.has(segment)); +} + +// path=src/app.ts, file="src/app.ts", module_path=..., filePath=... +const PATH_KEY_PATTERN = + /(?:^|[\s,;])(path|file|file_path|filePath|filepath|relative_path|relativePath|module_path|modulePath|filename|file_name)\s*=\s*/g; + +const JSON_PATH_KEY = + /^(?:path|file|file_?path|relative_?path|module_?path|filename|file_?name)$/i; + +const PATH_EXTENSION = /\.[A-Za-z0-9_-]{1,12}$/; +const URL_LIKE = /^(?:https?:|\/\/|file:|www\.)/i; +const IGNORED_PATH_PREFIXES = /^(?:node_modules\/|\.git\/)/i; +const ILLEGAL_PATH_CHARS = /[*?"<>|]/; + +function readPathValue(line: string, valueStart: number): string | undefined { + let start = valueStart; + while (line[start] === ' ' || line[start] === '\t') start++; + + const quote = line[start]; + if (quote === '"' || quote === "'") { + const end = line.indexOf(quote, start + 1); + return ( + end === -1 ? line.slice(start + 1) : line.slice(start + 1, end) + ).trim(); + } + + const rest = line.slice(start); + // A path may contain spaces; stop at the next key= pair or punctuation. + const stop = /\s+(?=[A-Za-z_][A-Za-z0-9_]*\s*=)|\s*[,;)]/.exec(rest); + const value = (stop ? rest.slice(0, stop.index) : rest).trim(); + return value || undefined; +} + +function kvPaths(line: string): string[] { + const values: string[] = []; + for (const match of line.matchAll(PATH_KEY_PATTERN)) { + const value = readPathValue(line, (match.index ?? 0) + match[0].length); + if (value) values.push(value); + } + return values; +} + +function linePath(line: string): string | undefined { + let rest = line.trim().replace(/^(?:[-*>]|→|->)\s*/, ''); + + // path/to/file.ext:42 or path/to/file.ext:42:7 + const withLine = /^(.+?\.([A-Za-z0-9_-]{1,12}))(?::\d+)(?::\d+)?\b/.exec( + rest + ); + if (withLine) return withLine[1].trim(); + + // A bare path line from an MCP file-list/glob result. + if (rest.includes('=')) return undefined; + const bare = /^(.+?\.([A-Za-z0-9_-]{1,12}))\s*$/.exec(rest); + return bare ? bare[1].trim() : undefined; +} + +function collectJsonPaths(value: unknown, out: Set, depth = 0): void { + if (depth > 6 || value === null || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const item of value) collectJsonPaths(item, out, depth + 1); + return; + } + for (const [key, child] of Object.entries(value)) { + if (JSON_PATH_KEY.test(key)) { + if (typeof child === 'string') { + out.add(child); + } else if (Array.isArray(child)) { + for (const item of child) { + if (typeof item === 'string') out.add(item); + } + } + } + collectJsonPaths(child, out, depth + 1); + } +} + +function argPaths(args: unknown): string[] { + const paths: string[] = []; + const walk = (value: unknown, depth: number): void => { + if (depth > 3 || value === null || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const item of value) walk(item, depth + 1); + return; + } + for (const [key, child] of Object.entries(value)) { + if (JSON_PATH_KEY.test(key)) { + if (typeof child === 'string') { + paths.push(child); + } else if (Array.isArray(child)) { + for (const item of child) { + if (typeof item === 'string') paths.push(item); + } + } + } + walk(child, depth + 1); + } + }; + walk(args, 0); + return paths; +} + +function normalizePathCandidate(raw: string): string | undefined { + let value = raw.trim().replace(/^[("'`]+|[)"'`,;]+$/g, ''); + if (!value) return undefined; + + // Normalize Windows separators so minimatch sees one path shape. + value = value.replace(/\\/g, '/'); + + if (URL_LIKE.test(value) || IGNORED_PATH_PREFIXES.test(value)) { + return undefined; + } + if (ILLEGAL_PATH_CHARS.test(value)) return undefined; + if (!PATH_EXTENSION.test(value)) return undefined; + + const basename = value.split('/').pop() ?? value; + const dot = basename.lastIndexOf('.'); + const stem = dot > 0 ? basename.slice(0, dot) : ''; + if (!stem) return undefined; + // Reject version-like tokens such as "1.2.3" that slipped through a line. + if (!/\p{L}/u.test(stem) && !value.includes('/')) return undefined; + + return value; +} + +function mcpObservations(event: RawToolEvent): FileObservation[] { + const tool = event.tool; + if (!isMcpFileTool(tool)) return []; + + const output = asString(event.output); + if (!output) return []; + + const paths = new Set(); + const add = (raw: string | undefined): void => { + if (!raw) return; + const normalized = normalizePathCandidate(raw); + if (normalized) paths.add(normalized); + }; + + // Some MCP servers take the path as an argument and return only content. + for (const value of argPaths(event.args)) add(value); + + for (const line of output.split(/\r?\n/)) { + const kv = kvPaths(line); + if (kv.length > 0) { + for (const value of kv) add(value); + continue; + } + add(linePath(line)); + } + + const trimmed = output.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) as unknown; + const jsonPaths = new Set(); + collectJsonPaths(parsed, jsonPaths); + for (const value of jsonPaths) add(value); + } catch { + // Not JSON; the line parser above already handled the text form. + } + } + + return [...paths].slice(0, 5).map(path => ({ path, tool, content: output })); +} + export function normalizeObservations(event: RawToolEvent): FileObservation[] { + const mcp = mcpObservations(event); + if (mcp.length > 0) return mcp; + if (!OBSERVATION_TOOLS.has(event.tool)) return []; if (!event.args || typeof event.args !== 'object') return []; const args = event.args as Record;