Files
hermes-agent/plugins/workspace/semtools/__init__.py
T
alt-glitch 62116ff3c9 feat(semtools): wire list_files, delete, and richer status
Implements list_files() via workspace file discovery, delete() via
semtools workspace prune, and enriches status() with root_dir and
total_documents from 'semtools workspace status --json'.

retrieve() intentionally left as no-op default — semtools uses
embeddings, not chunked content storage.

All 6 agent tools now route through plugin dispatch for both default
and semtools backends with zero tool-layer changes.

Also fix pyproject.toml merge: include both tui_gateway and workspace
packages.
2026-04-20 01:06:42 +05:30

245 lines
7.8 KiB
Python

"""Semtools workspace plugin — semantic search via @llamaindex/semtools.
semtools is a Rust CLI that does semantic search using model2vec.
It auto-indexes files on first search, so index() is mostly a no-op.
"""
import fnmatch
import json
import logging
import shutil
import subprocess
from workspace.base import BaseIndexer
from workspace.config import WorkspaceConfig
from workspace.types import IndexSummary, SearchResult
log = logging.getLogger(__name__)
class SemtoolsIndexer(BaseIndexer):
def __init__(self, config: WorkspaceConfig) -> None:
self._config = config
pc = config.plugin_config
self._workspace = pc.get("workspace_name", "hermes")
self._top_k = pc.get("top_k", 20)
def index(self, *, progress=None) -> IndexSummary:
"""Discover files but skip actual indexing — semtools auto-indexes on search."""
self._ensure_semtools()
from workspace.files import discover_workspace_files
discovery = discover_workspace_files(self._config)
return IndexSummary(
files_indexed=0,
files_skipped=len(discovery.files),
files_pruned=0,
files_errored=0,
chunks_created=0,
duration_seconds=0.0,
errors=[],
errors_truncated=False,
)
def search(
self,
query: str,
*,
limit: int = 20,
path_prefix: str | None = None,
file_glob: str | None = None,
) -> list[SearchResult]:
"""Run semtools search against discovered workspace files."""
self._ensure_semtools()
from workspace.files import discover_workspace_files
discovery = discover_workspace_files(self._config)
files = [str(p) for _, p in discovery.files]
if path_prefix:
files = [f for f in files if f.startswith(path_prefix)]
if file_glob:
pattern = file_glob if file_glob.startswith("*") else "*" + file_glob
files = [f for f in files if fnmatch.fnmatch(f, pattern)]
if not files:
return []
cmd = [
"semtools",
"search",
query,
*files,
"--json",
"--top-k",
str(limit),
"--workspace",
self._workspace,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
log.error("semtools search failed: %s", e.stderr)
raise RuntimeError(f"semtools search failed: {e.stderr}") from e
except FileNotFoundError as e:
raise RuntimeError(
"semtools binary not found. Install with: npm i -g @llamaindex/semtools"
) from e
return self._parse_results(result.stdout)
def status(self) -> dict:
installed = shutil.which("semtools") is not None
info: dict = {
"backend": "semtools",
"installed": installed,
"workspace_name": self._workspace,
}
if not installed:
return info
try:
result = subprocess.run(
["semtools", "workspace", "status", "--json", self._workspace],
capture_output=True,
text=True,
check=True,
)
ws_info = json.loads(result.stdout)
info["root_dir"] = ws_info.get("root_dir")
info["total_documents"] = ws_info.get("total_documents", 0)
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
log.debug("semtools workspace status failed: %s", e)
return info
def list_files(self) -> list[dict]:
"""List files discoverable under configured roots.
Semtools auto-indexes on search, so this returns the discovery set
that WOULD be indexed rather than what's actually in the embedding store.
"""
from workspace.files import discover_workspace_files
discovery = discover_workspace_files(self._config)
return [
{
"path": str(p),
"root": str(root),
"size_bytes": p.stat().st_size if p.exists() else 0,
"chunks": 0,
"modified": "",
"indexed": "",
}
for root, p in discovery.files
]
def delete(self, path: str) -> bool:
"""Semtools doesn't expose per-file delete; runs workspace prune instead.
Prune removes stale entries (files that no longer exist on disk).
Returns True if the file is gone from disk AND prune succeeded.
"""
from pathlib import Path
if Path(path).exists():
return False
try:
subprocess.run(
["semtools", "workspace", "prune", self._workspace],
capture_output=True,
text=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
log.warning("semtools workspace prune failed: %s", e.stderr)
return False
def _ensure_semtools(self) -> None:
"""Install semtools if not already present (idempotent)."""
if shutil.which("semtools"):
return
if not shutil.which("npm"):
raise RuntimeError(
"npm is required to install semtools. Install Node.js first."
)
try:
subprocess.run(
["npm", "i", "-g", "@llamaindex/semtools"],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to install semtools via npm: {e.stderr}") from e
if not shutil.which("semtools"):
raise RuntimeError(
"semtools installed but not found on PATH after npm install"
)
@staticmethod
def _parse_results(stdout: str) -> list[SearchResult]:
"""Parse semtools JSON output into SearchResult objects.
semtools outputs::
{
"results": [
{
"filename": "/path/to/file.py",
"start_line_number": 0,
"end_line_number": 7,
"match_line_number": 3,
"distance": 0.219,
"content": "..."
},
...
]
}
Distance is a dissimilarity metric (lower = better match).
We convert to a similarity score: score = 1.0 - distance.
Line numbers from semtools are 0-based; we convert to 1-based.
"""
try:
data = json.loads(stdout)
except json.JSONDecodeError:
log.warning("Failed to parse semtools JSON output")
return []
results_raw = data.get("results", [])
results: list[SearchResult] = []
for i, item in enumerate(results_raw):
distance = item.get("distance", 1.0)
score = max(0.0, 1.0 - distance)
start_line = item.get("start_line_number", 0) + 1
end_line = item.get("end_line_number", 0) + 1
content = item.get("content", "")
results.append(
SearchResult(
path=item.get("filename", ""),
line_start=start_line,
line_end=end_line,
section=None,
chunk_index=i,
score=round(score, 6),
tokens=0,
modified="",
content=content,
)
)
return results
def register(ctx):
ctx.register_workspace_indexer(SemtoolsIndexer)