From a291f4a2f7cda657d9ec5fafc52a920a85da585f Mon Sep 17 00:00:00 2001 From: dxta Date: Fri, 10 Jul 2026 00:07:41 +0300 Subject: [PATCH 1/2] fix: route legacy-usearch notices to stderr for clean structured output Diagnostic notices ('Detected legacy usearch index...') were printed to stdout via the Rich console, polluting --format json output and breaking JSON consumers (the pi extension's result parser fell back to a raw dump when JSON.parse hit the leading warning text). Route these notices to a stderr Rich console (err_console) so stdout stays pure JSON while the notices remain visible in the terminal. The existing suppress_stdout_notices guard is preserved. Verified: sia-code search --format json -k 5 -- '...' now emits clean parseable JSON on stdout and the legacy-usearch notices on stderr. --- sia_code/cli.py | 9 +++++---- sia_code/runtime_context.py | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/sia_code/cli.py b/sia_code/cli.py index a2b7bb0..b85c3ff 100644 --- a/sia_code/cli.py +++ b/sia_code/cli.py @@ -24,6 +24,7 @@ from .indexer.coordinator import IndexingCoordinator console = Console(force_terminal=True) +err_console = Console(stderr=True, force_terminal=True) def _display_skip_summary( @@ -139,11 +140,11 @@ def create_backend( ) if is_implicit_sqlite_default_on_legacy_usearch: if not suppress_stdout_notices: - console.print( + err_console.print( "[yellow]Detected legacy usearch index with implicit storage backend.[/yellow] " "Using legacy backend for compatibility." ) - console.print( + err_console.print( "[dim]Set 'storage.backend=sqlite-vec' and run 'sia-code index --clean .' " "to migrate when ready.[/dim]" ) @@ -167,10 +168,10 @@ def create_backend( if effective_backend == "auto" and detected_backend == "usearch": if not suppress_stdout_notices: - console.print( + err_console.print( "[yellow]Detected legacy usearch index.[/yellow] Using it for compatibility." ) - console.print( + err_console.print( "[dim]Set 'storage.backend=usearch' to pin legacy mode, " "or run 'sia-code index --clean .' to migrate to sqlite-vec.[/dim]" ) diff --git a/sia_code/runtime_context.py b/sia_code/runtime_context.py index 53e10d1..9b3da58 100644 --- a/sia_code/runtime_context.py +++ b/sia_code/runtime_context.py @@ -14,6 +14,7 @@ from .config import Config console = Console() +err_console = Console(stderr=True) @dataclass(frozen=True) @@ -46,11 +47,11 @@ def create_backend( ) if is_implicit_sqlite_default_on_legacy_usearch: if not suppress_stdout_notices: - console.print( + err_console.print( "[yellow]Detected legacy usearch index with implicit storage backend.[/yellow] " "Using legacy backend for compatibility." ) - console.print( + err_console.print( "[dim]Set 'storage.backend=sqlite-vec' and run 'sia-code index --clean .' " "to migrate when ready.[/dim]" ) @@ -74,10 +75,10 @@ def create_backend( if effective_backend == "auto" and detected_backend == "usearch": if not suppress_stdout_notices: - console.print( + err_console.print( "[yellow]Detected legacy usearch index.[/yellow] Using it for compatibility." ) - console.print( + err_console.print( "[dim]Set 'storage.backend=usearch' to pin legacy mode, " "or run 'sia-code index --clean .' to migrate to sqlite-vec.[/dim]" ) From 55ba655a36ee0fadb0e8987415f00b0960f0d8df Mon Sep 17 00:00:00 2001 From: dxta Date: Tue, 14 Jul 2026 22:17:39 +0300 Subject: [PATCH 2/2] fix: align git context with resolved index - route CLI and MCP git context through shared index resolution - document and lock the complete MCP tool surface - preserve machine-readable non-TTY JSON and isolate tests --- docs/LLM_CLI_INTEGRATION.md | 2 +- docs/MCP_INTEGRATION.md | 2 +- sia_code/cli.py | 11 ++-- sia_code/mcp.py | 24 ++++---- skills/sia-code/SKILL.md | 2 +- .../test_git_dynamic_real_repos.py | 2 - tests/unit/test_cli_backend_selection.py | 28 +++++++++ tests/unit/test_index_dir_resolution.py | 2 - tests/unit/test_mcp_lock.py | 2 - tests/unit/test_mcp_server.py | 60 +++++++++++++++++-- tests/unit/test_multi_hop.py | 3 +- tests/unit/test_open_index_writable.py | 11 +++- 12 files changed, 118 insertions(+), 31 deletions(-) diff --git a/docs/LLM_CLI_INTEGRATION.md b/docs/LLM_CLI_INTEGRATION.md index f410312..86aa8f8 100644 --- a/docs/LLM_CLI_INTEGRATION.md +++ b/docs/LLM_CLI_INTEGRATION.md @@ -145,6 +145,6 @@ sia-code search "AuthService" - Prefer MCP when the client supports it; it removes the need to distribute prompt-side workflow files. - `engineering_bootstrap` is the intended portable first-call surface for engineering workflows when users only add `uvx sia-code-mcp` to their MCP config. - Keep the skill file short and practical for fallback environments. -- `memory git-context` is currently CLI-first for file history + blast radius workflows. +- Use `memory git-context` in CLI workflows or `git_context` through MCP. - Update this file when CLI behavior changes. - Keep both PyPI and local-checkout workflows documented during active development. diff --git a/docs/MCP_INTEGRATION.md b/docs/MCP_INTEGRATION.md index 545f7fc..08b1af5 100644 --- a/docs/MCP_INTEGRATION.md +++ b/docs/MCP_INTEGRATION.md @@ -48,6 +48,7 @@ Exposed in v1: - `memory_export`, `memory_import` - `config_show`, `config_path`, `config_get`, `config_set` - `embed_start`, `embed_status`, `embed_stop` +- `git_context` ## Recommended First Call @@ -61,7 +62,6 @@ For engineering work in Claude Code, OpenCode, Codex, or any MCP-aware client, p Not exposed in v1: -- `memory git-context` (CLI-only today for file history + blast radius) - interactive CLI mode - watch mode indexing - config editor launching diff --git a/sia_code/cli.py b/sia_code/cli.py index b85c3ff..258351b 100644 --- a/sia_code/cli.py +++ b/sia_code/cli.py @@ -23,8 +23,12 @@ from .config import Config from .indexer.coordinator import IndexingCoordinator -console = Console(force_terminal=True) -err_console = Console(stderr=True, force_terminal=True) +console = Console( + force_terminal=True, color_system="auto" if sys.stdout.isatty() else None +) +err_console = Console( + stderr=True, force_terminal=True, color_system="auto" if sys.stderr.isatty() else None +) def _display_skip_summary( @@ -2970,7 +2974,6 @@ def memory_git_context(file_paths, no_blast_radius, no_narrative, output_format) sia-code memory git-context src/api.py src/crud.py --format json sia-code memory git-context src/api.py --no-narrative """ - from .config import Config from .memory.blast_radius import BlastRadiusAnalyzer from .memory.diff_analyzer import DiffSemanticAnalyzer from .memory.git_dynamic import GitDynamicMemory @@ -2978,7 +2981,7 @@ def memory_git_context(file_paths, no_blast_radius, no_narrative, output_format) from .memory.recency import RecencyConfig project_dir = Path.cwd() - config = Config.load(project_dir / ".sia-code" / "config.json") + _, config = require_initialized() gc = config.git_dynamic recency_cfg = RecencyConfig( diff --git a/sia_code/mcp.py b/sia_code/mcp.py index bef212a..37f3903 100644 --- a/sia_code/mcp.py +++ b/sia_code/mcp.py @@ -834,7 +834,7 @@ def engineering_bootstrap( hit_files = list({m["file_path"] for m in search_hits["matches"] if "file_path" in m})[:3] if hit_files: git_context_payload = _compute_git_context( - context.workspace_root, hit_files, limit=3 + context.workspace_root, config, hit_files, limit=3 ) except Exception: pass # Graceful — git context is supplementary @@ -1109,8 +1109,11 @@ def memory_trace( try: if result.related_files: dynamic_git = _compute_git_context( - context.workspace_root, result.related_files[:3], - limit=3, include_blast_radius=False, + context.workspace_root, + config, + result.related_files[:3], + limit=3, + include_blast_radius=False, ) except Exception: pass @@ -1284,23 +1287,19 @@ def embed_stop(): def _compute_git_context( workspace_root: Path, + config: Config, file_paths: list[str], limit: int = 5, include_blast_radius: bool = True, include_narrative: bool = True, ) -> dict: - """Shared helper — computes git context for files. - - Used by git_context tool, research, engineering_bootstrap, memory_trace. - """ - from .config import Config + """Shared helper — computes git context for files.""" from .memory.blast_radius import BlastRadiusAnalyzer from .memory.diff_analyzer import DiffSemanticAnalyzer from .memory.git_dynamic import GitDynamicMemory from .memory.intent_classifier import IntentClassifier from .memory.recency import RecencyConfig - config = Config.load(workspace_root / ".sia-code" / "config.json") gc = config.git_dynamic if not gc.enabled: @@ -1401,6 +1400,7 @@ def _compute_git_context( def git_context( workspace_root: str, file_paths: list[str], + index_dir: str | None = None, include_blast_radius: bool = True, include_narrative: bool = True, ) -> dict: @@ -1410,13 +1410,15 @@ def git_context( co-change blast radius, and model-generated evolution narrative. Auto-uses local flan-t5 model for narrative when available. """ + context, config = _require_initialized_context(workspace_root, index_dir) result = _compute_git_context( - Path(workspace_root), + context.workspace_root, + config, file_paths, include_blast_radius=include_blast_radius, include_narrative=include_narrative, ) - return _ok(scope="project", result=result) + return _ok(context=context, result=result) return mcp diff --git a/skills/sia-code/SKILL.md b/skills/sia-code/SKILL.md index 1f97b24..a4ada6a 100644 --- a/skills/sia-code/SKILL.md +++ b/skills/sia-code/SKILL.md @@ -67,7 +67,7 @@ uvx sia-code memory working-set "auth flow" \ ``` - `memory working-set` emits stable JSON for agent handoff. -- `memory git-context` is CLI-only and useful before risky refactors. +- `memory git-context` is useful before risky refactors; MCP clients use `git_context`. ## Multi-Repo and Worktrees diff --git a/tests/integration/test_git_dynamic_real_repos.py b/tests/integration/test_git_dynamic_real_repos.py index 9ef98fb..cf60b2e 100644 --- a/tests/integration/test_git_dynamic_real_repos.py +++ b/tests/integration/test_git_dynamic_real_repos.py @@ -12,7 +12,6 @@ from sia_code.memory.git_dynamic import GitDynamicMemory from sia_code.memory.blast_radius import BlastRadiusAnalyzer from sia_code.memory.recency import RecencyConfig, RecencyScorer -from sia_code.memory.revert_detector import RevertDetector, CommitInfo from sia_code.memory.intent_classifier import IntentClassifier @@ -145,7 +144,6 @@ def test_within_window_full_weight(self): def test_halflife_gives_half_weight(self): from datetime import datetime, timedelta, timezone - import math scorer = RecencyScorer(RecencyConfig(halflife_days=30, working_window_days=14)) now = datetime.now(timezone.utc) diff --git a/tests/unit/test_cli_backend_selection.py b/tests/unit/test_cli_backend_selection.py index 9dcb153..0b70282 100644 --- a/tests/unit/test_cli_backend_selection.py +++ b/tests/unit/test_cli_backend_selection.py @@ -92,3 +92,31 @@ def fake_create_backend( assert result.exit_code == 0 assert captured["suppress_stdout_notices"] is True + + +def test_git_context_command_uses_resolved_index(monkeypatch, tmp_path): + resolved = False + + def fake_require_initialized(): + nonlocal resolved + resolved = True + return tmp_path / "custom-index", Config() + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sia_code.cli.require_initialized", fake_require_initialized) + + result = CliRunner().invoke( + main, + [ + "memory", + "git-context", + "missing.py", + "--no-blast-radius", + "--no-narrative", + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + assert resolved is True diff --git a/tests/unit/test_index_dir_resolution.py b/tests/unit/test_index_dir_resolution.py index 993ef37..33745d9 100644 --- a/tests/unit/test_index_dir_resolution.py +++ b/tests/unit/test_index_dir_resolution.py @@ -1,5 +1,3 @@ -from pathlib import Path - import pytest from sia_code.cli import resolve_index_dir diff --git a/tests/unit/test_mcp_lock.py b/tests/unit/test_mcp_lock.py index eac1f89..86364a1 100644 --- a/tests/unit/test_mcp_lock.py +++ b/tests/unit/test_mcp_lock.py @@ -1,5 +1,3 @@ -from pathlib import Path - from sia_code.mcp_lock import index_lock, lock_path_for_index diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py index 4707751..f8e8b55 100644 --- a/tests/unit/test_mcp_server.py +++ b/tests/unit/test_mcp_server.py @@ -46,10 +46,62 @@ async def test_build_server_registers_expected_tool_names(): server = build_server() tool_names = {tool.name for tool in await server.list_tools()} - assert {"init", "status", "search", "research"}.issubset(tool_names) - assert {"memory_working_set", "config_show", "embed_status"}.issubset(tool_names) - assert {"health_check", "engineering_bootstrap"}.issubset(tool_names) - assert "interactive" not in tool_names + assert tool_names == { + "compact", + "config_get", + "config_path", + "config_set", + "config_show", + "embed_start", + "embed_status", + "embed_stop", + "engineering_bootstrap", + "git_context", + "health_check", + "index", + "init", + "memory_add_decision", + "memory_approve", + "memory_changelog", + "memory_export", + "memory_import", + "memory_list", + "memory_reject", + "memory_search", + "memory_sync_git", + "memory_timeline", + "memory_trace", + "memory_working_set", + "research", + "search", + "status", + } + + +@pytest.mark.anyio +async def test_git_context_uses_explicit_index_dir(tmp_path): + workspace_root = tmp_path / "repo" + workspace_root.mkdir() + index_dir = tmp_path / "custom-index" + index_dir.mkdir() + Config().save(index_dir / "config.json") + + server = build_server() + result = decode_tool_result( + await server.call_tool( + "git_context", + { + "workspace_root": str(workspace_root), + "index_dir": str(index_dir), + "file_paths": ["missing.py"], + "include_blast_radius": False, + "include_narrative": False, + }, + ) + ) + + assert result["ok"] is True + assert result["resolved_index_dir"] == str(index_dir.resolve()) @pytest.mark.anyio diff --git a/tests/unit/test_multi_hop.py b/tests/unit/test_multi_hop.py index f2a953e..9666775 100644 --- a/tests/unit/test_multi_hop.py +++ b/tests/unit/test_multi_hop.py @@ -506,12 +506,11 @@ def test_uses_hybrid_variants_when_budgeted_embeddings_enabled(self, backend, sa backend.embedding_enabled = True backend.embedding_granularity = "budget" - original_search_hybrid = backend.search_hybrid calls = [] def mock_search_hybrid(query, *args, **kwargs): calls.append(query) - return original_search_hybrid(query, *args, **kwargs) + return [] backend.search_hybrid = mock_search_hybrid diff --git a/tests/unit/test_open_index_writable.py b/tests/unit/test_open_index_writable.py index ab54714..fb0831e 100644 --- a/tests/unit/test_open_index_writable.py +++ b/tests/unit/test_open_index_writable.py @@ -36,7 +36,16 @@ def __len__(self): def ndim(self): return 768 - monkeypatch.setattr(usearch_backend, "Index", FakeIndex) + class FakeMetricKind: + Cos = "cos" + L2sq = "l2sq" + + monkeypatch.setattr( + usearch_backend, "_lazy_usearch", lambda: (FakeIndex, FakeMetricKind) + ) + monkeypatch.setattr( + usearch_backend.UsearchSqliteBackend, "_get_embedder", lambda self: None + ) backend = usearch_backend.UsearchSqliteBackend(path=tmp_path) backend.open_index(writable=True)