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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ Semble runs as an MCP server so agents can search any codebase directly as a nat

| Tool | Description |
|------|-------------|
| `search` | Search a codebase with a natural-language or code query. Pass `repo` as a local path or an https:// git URL. |
| `search` | Search a codebase with a natural-language or code query. Pass `repo` as a local path or an https:// git URL and `content` as `code`, `docs`, `config`, or `all` (default: `code`). |
| `find_related` | Given a file path and line number, return chunks semantically similar to the code at that location. |

For per-agent setup instructions, see the [installation docs](docs/installation.md#mcp-server).
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ Add to `~/.zcode/cli/config.json` under the nested `mcp.servers` key (or use Set

</details>

By default the MCP server indexes only code files. To also index documentation, config, or everything, append `--content docs`, `--content config`, or `--content all` to the server command. For example, in Claude Code:
The MCP server indexes each requested content selection on first use and caches it separately. Searches default to code; append `--content docs`, `--content config`, or `--content all` to the server command to change that default. The `content` argument on an individual MCP search overrides it. For example, in Claude Code:

```bash
claude mcp add semble -s user -- uvx --from "semble[mcp]" semble --content all
Expand Down
19 changes: 9 additions & 10 deletions src/semble/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ def cache_key(path: str) -> str:
return hashlib.new("sha256", data).hexdigest()


def find_index_from_cache_folder(path: str) -> Path:
"""Finds an index from a cache folder and a project path."""
def find_index_from_cache_folder(path: str, content: Sequence[ContentType] = (ContentType.CODE,)) -> Path:
"""Find an exact content index in the cache for a project path."""
cache_dir = resolve_cache_folder() / cache_key(path)
return cache_dir / "index"
scope = "-".join(sorted({content_type.value for content_type in content}))
return cache_dir / ("index" if scope == ContentType.CODE.value else f"index-{scope}")


def _windows_cache_dir(name: str) -> Path:
Expand Down Expand Up @@ -89,16 +90,14 @@ def resolve_cache_folder() -> Path:


def clear_cache(path: str) -> None:
"""Clears the cache for the given path."""
index_path = find_index_from_cache_folder(path)
if index_path.exists():
shutil.rmtree(index_path)
"""Clear all exact content indexes for the given path."""
shutil.rmtree(find_index_from_cache_folder(path).parent, ignore_errors=True)


def save_index_to_cache(index: "SembleIndex", path: str) -> None:
"""Save an index to the cache folder if it was freshly built."""
if not index.loaded_from_disk:
index.save(find_index_from_cache_folder(path))
index.save(find_index_from_cache_folder(path, index.content))


def _metadata_matches(metadata: dict, model_path: str, content: Sequence[ContentType]) -> bool:
Expand All @@ -120,7 +119,7 @@ def _metadata_matches(metadata: dict, model_path: str, content: Sequence[Content

def get_validated_cache(path: str, model_path: str | None, content: Sequence[ContentType]) -> Path | None:
"""Validates the cache folder and returns the index path."""
index_path = find_index_from_cache_folder(path)
index_path = find_index_from_cache_folder(path, content)
if not index_path.exists():
return None

Expand Down Expand Up @@ -169,7 +168,7 @@ def load_previous_for_incremental(
:return: Previous index state, or None if the cache is unavailable or invalid.
"""
try:
index_path = find_index_from_cache_folder(path)
index_path = find_index_from_cache_folder(path, content)
persistence_path = PersistencePath.from_path(index_path)
if persistence_path.non_existing():
return None
Expand Down
31 changes: 14 additions & 17 deletions src/semble/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from model2vec.utils import get_package_extras

from semble.cache import cache_key, find_index_from_cache_folder, resolve_cache_folder
from semble.cache import cache_key, resolve_cache_folder, save_index_to_cache
from semble.index import SembleIndex
from semble.index.types import PersistencePath
from semble.installer.agents import AGENTS, IntegrationType
Expand Down Expand Up @@ -40,12 +40,10 @@ def _build_index(path: str, content: list[ContentType]) -> SembleIndex:

def _maybe_save_index(index: SembleIndex, path: str) -> None:
"""Save the index to the cache folder if it was not loaded from disk."""
if not index.loaded_from_disk:
try:
cache_folder = find_index_from_cache_folder(path)
index.save(cache_folder)
except Exception as e:
print(f"Error saving index: {e}", file=sys.stderr)
try:
save_index_to_cache(index, path)
except Exception as e:
print(f"Error saving index: {e}", file=sys.stderr)


def _add_content_args(p: argparse.ArgumentParser) -> None:
Expand All @@ -56,7 +54,7 @@ def _add_content_args(p: argparse.ArgumentParser) -> None:
default=["code"],
choices=[ct.value for ct in ContentType] + ["all"],
metavar="TYPE",
help="Content types to index (space-separated, e.g. --content code docs). Choices: code, docs, config, all. Default: code.",
help="Content types to search (space-separated, e.g. --content code docs). Choices: code, docs, config, all. Default: code.",
)
p.add_argument(
"--include-text-files",
Expand Down Expand Up @@ -146,19 +144,18 @@ def _run_find_related(

def _clear_indexes(cache_folder: Path) -> None:
"""Remove all valid index entries from the cache folder."""
indexes = []
for path in cache_folder.glob("*/index"):
indexes: set[Path] = set()
for path in cache_folder.glob("*/index*"):
if not _SHA_256_REGEX.match(path.parent.name):
continue
if PersistencePath.from_path(path).non_existing():
continue
indexes.append(path)
indexes.add(path.parent)

if not indexes:
print(f"No indexes found to clear in `{cache_folder}`")
else:
for path in indexes:
index_folder = path.parent
for index_folder in indexes:
rmtree(index_folder)
print(f"Cleared index at `{index_folder}`")

Expand All @@ -175,8 +172,8 @@ def _clear_savings(cache_folder: Path) -> None:

def _clear_orphans(cache_folder: Path) -> None:
"""Remove index entries whose local root_path no longer exists."""
orphans = []
for path in cache_folder.glob("*/index"):
orphans: dict[Path, str] = {}
for path in cache_folder.glob("*/index*"):
if not _SHA_256_REGEX.match(path.parent.name):
continue
try:
Expand All @@ -189,12 +186,12 @@ def _clear_orphans(cache_folder: Path) -> None:
if not isinstance(root_path, str) or not root_path or cache_key(root_path) != path.parent.name:
continue
if not Path(root_path).exists():
orphans.append((path.parent, root_path))
orphans[path.parent] = root_path

if not orphans:
print("No orphaned indexes found")
else:
for index_folder, root_path in orphans:
for index_folder, root_path in orphans.items():
rmtree(index_folder)
print(f"Cleared orphaned index for `{root_path}`")

Expand Down
5 changes: 5 additions & 0 deletions src/semble/index/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ def stats(self) -> IndexStats:
languages=dict(language_counts),
)

@property
def content(self) -> tuple[ContentType, ...]:
"""Return the content types covered by this index."""
return self._content

@classmethod
def from_path(
cls,
Expand Down
6 changes: 3 additions & 3 deletions src/semble/installer/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def semble_pin() -> str:

Use `mcp__semble__search` to find where something is implemented — instead of using Grep or Glob to discover files. After semble returns the file and line, navigate there directly and read that file. Do not grep for the same content again.

Pass `--content docs` to search documentation and prose, `--content config` for config files, or `--content all` to search code, docs, and config together.
Pass `content="docs"` to the MCP search tool for documentation and prose, `content="config"` for config files, or `content="all"` for everything. On the CLI, use `--content docs`, `--content config`, or `--content all` instead.

For CLI fallback or sub-agents without MCP access, use:

Expand All @@ -111,8 +111,8 @@ def semble_pin() -> str:
1. Call `mcp__semble__search` with a query describing what the code does or its name. The tool returns results with 10 lines of context each (function/class signature + first body lines, enough to confirm the location).
2. Navigate directly to the top result's file and line. Read only the function or class at that location.
3. Make the edit. Do not re-search or grep for the same content.
4. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything.
5. Optionally use `mcp__semble__find_related` with `file_path` and `line` to discover similar code elsewhere.
4. Set the MCP search tool's `content` field to `docs`, `config`, or `all` when searching beyond code.
5. Optionally use `mcp__semble__find_related` with `file_path`, `line`, and the same `content` selection to discover similar code elsewhere.
6. Use Grep only when you need every occurrence of a literal string across the whole repo (e.g., all callers of a renamed function).
{SEMBLE_END}
"""
Expand Down
Loading
Loading