Skip to content

Commit 1801766

Browse files
committed
v1.3: /code-history command and history tools
1 parent fe51c3b commit 1801766

8 files changed

Lines changed: 271 additions & 6 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Works in Hermes CLI, Telegram, and Hermes Desktop.
3232
| `/code-search <repo> "query"` | Hybrid semantic + literal search |
3333
| `/code-context <repo> "query"` | Retrieve a compact context pack |
3434
| `/code-ask <repo> "question"` | Retrieve + answer with citations (add an optional `"query"` before the question to steer retrieval) |
35+
| `/code-history <repo> "question"` | Search indexed git commit history (SourceVault v1.8+; index every commit via the dashboard's Full git history setting) |
3536

3637
Telegram uses underscore forms (`/code_ask`, …).
3738

@@ -41,8 +42,9 @@ Telegram uses underscore forms (`/code_ask`, …).
4142
> `/code-repos` to see the exact names.
4243
4344
**LLM-callable tools** (for models that handle structured tool use):
44-
`code_search`, `code_read_file`, plus `sourcevault_search` / `sourcevault_read`
45-
aliases for Hermes Tool Search discoverability.
45+
`code_search`, `code_read_file`, and `code_history`, plus `sourcevault_search` /
46+
`sourcevault_read` / `sourcevault_history` aliases for Hermes Tool Search
47+
discoverability.
4648

4749
## Requires a SourceVault backend
4850

@@ -97,6 +99,7 @@ Point the gateway at your SourceVault instance
9799
```env
98100
CODE_SEARCH_URL=http://127.0.0.1:9000/api/search-codebase
99101
CODE_READ_FILE_URL=http://127.0.0.1:9000/api/read-file
102+
CODE_HISTORY_URL=http://127.0.0.1:9000/api/history-search
100103
CODE_SEARCH_HMAC_SECRET=<same value as the SourceVault server>
101104
```
102105

@@ -147,6 +150,7 @@ Usage notes:
147150
|---|---|---|
148151
| `CODE_SEARCH_URL` | `http://127.0.0.1:9000/api/search-codebase` | SourceVault search endpoint |
149152
| `CODE_READ_FILE_URL` | `http://127.0.0.1:9000/api/read-file` | SourceVault file-read endpoint |
153+
| `CODE_HISTORY_URL` | `http://127.0.0.1:9000/api/history-search` | SourceVault history-search endpoint (v1.8+) |
150154
| `CODE_SEARCH_HMAC_SECRET` | — | Request-signing secret (required) |
151155
| `REPO_ROOT` | `~/.hermes/repos` | Local repo mirrors for `/code-repos`, `/code-sync` |
152156
| `SOURCEVAULT_CODE_TOOLS_DEBUG` | off | Log argument shapes (no secrets) |

__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
_handle_code_context_command,
1616
_handle_code_help_command,
1717
_handle_code_read_command,
18+
_handle_code_history_command,
1819
_handle_code_repos_command,
1920
_handle_code_search_command,
2021
_handle_code_status_command,
@@ -43,8 +44,10 @@
4344
)
4445
from .transport import DEFAULT_READ_FILE_URL, DEFAULT_SEARCH_URL, _debug, _post_signed_json
4546
from .tools import (
47+
_register_code_history_tool,
4648
_register_code_read_tool,
4749
_register_code_search_tool,
50+
handle_code_history,
4851
handle_code_read_file,
4952
handle_code_search,
5053
)
@@ -62,6 +65,8 @@ def register(ctx):
6265
_register_code_read_tool(ctx, "code_read_file")
6366
_register_code_read_tool(ctx, "code_read")
6467
_register_code_read_tool(ctx, "sourcevault_read")
68+
_register_code_history_tool(ctx, "code_history")
69+
_register_code_history_tool(ctx, "sourcevault_history")
6570

6671
_register_code_command(
6772
ctx,
@@ -87,6 +92,12 @@ def register(ctx):
8792
lambda raw_args: _handle_code_ask_command(raw_args, ctx),
8893
"Build a SourceVault code context prompt for a repo question.",
8994
)
95+
_register_code_command(
96+
ctx,
97+
("code-history", "code_history"),
98+
_handle_code_history_command,
99+
"Search an indexed repo's git commit history.",
100+
)
90101
_register_code_command(
91102
ctx,
92103
("code-status", "code_status"),

commands.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
_extract_json_object,
1313
_format_ask_command_output,
1414
_format_context_command_output,
15+
_format_history_command_output,
1516
_format_search_command_output,
1617
_merge_search_results,
1718
_parse_successful_search_result,
1819
_read_file_command_output,
1920
)
2021
from .helpers import _clean_repo_name
2122
from .transport import DEFAULT_READ_FILE_URL, DEFAULT_SEARCH_URL, _debug
22-
from .tools import handle_code_read_file, handle_code_search
23+
from .tools import handle_code_history, handle_code_read_file, handle_code_search
2324

2425

2526
def _register_code_command(ctx, names, handler, description):
@@ -49,6 +50,8 @@ def _handle_code_help_command(raw_args):
4950
"/code-ask <repo_name> \"question\" [n_results]",
5051
"/code_ask <repo_name> \"question\" [n_results]",
5152
"/code-ask <repo_name> \"retrieval query\" \"question\" [n_results]",
53+
"/code-history <repo_name> \"question\" [n_results]",
54+
"/code_history <repo_name> \"question\" [n_results]",
5255
"/code-read <repo_name> <relative_path> [max_bytes]",
5356
"/code_read <repo_name> <relative_path> [max_bytes]",
5457
"",
@@ -80,6 +83,26 @@ def _handle_code_read_command(raw_args):
8083
return _read_file_command_output(result)
8184

8285

86+
def _handle_code_history_command(raw_args):
87+
try:
88+
args = shlex.split(raw_args or "")
89+
except ValueError as error:
90+
return f'Usage: /code-history <repo_name> "question" [n_results]\nError: {error}'
91+
92+
if len(args) < 2:
93+
return 'Usage: /code-history <repo_name> "question" [n_results]'
94+
95+
result = handle_code_history(
96+
{
97+
"repo_name": args[0],
98+
"question": args[1],
99+
"n_results": args[2] if len(args) > 2 else 5,
100+
}
101+
)
102+
103+
return _format_history_command_output(result)
104+
105+
83106
def _handle_code_search_command(raw_args):
84107
try:
85108
args = shlex.split(raw_args or "")

formatting.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,44 @@ def _read_file_command_output(result):
6969
return result
7070

7171

72+
def _format_history_command_output(result):
73+
try:
74+
parsed = json.loads(result)
75+
except (TypeError, json.JSONDecodeError):
76+
return result
77+
78+
if not isinstance(parsed, dict):
79+
return result
80+
81+
if parsed.get("ok") is False or parsed.get("success") is False:
82+
return result
83+
84+
results = parsed.get("results") or []
85+
lines = [
86+
parsed.get("summary")
87+
or f"Found {len(results)} matching commit(s)",
88+
]
89+
90+
for index, item in enumerate(results, start=1):
91+
short = item.get("short") or str(item.get("commit") or "")[:7] or "<unknown>"
92+
meta = f"#{index} {short} ({item.get('date') or '?'}) {item.get('author') or ''}".rstrip()
93+
if item.get("ai_authored"):
94+
meta += " [ai]"
95+
lines.append(meta)
96+
97+
subject = " ".join(str(item.get("subject") or "").split())
98+
if subject:
99+
lines.append(f" {subject}")
100+
101+
preview = " ".join(str(item.get("preview") or "").split())
102+
if preview and preview != subject:
103+
if len(preview) > 180:
104+
preview = f"{preview[:177]}..."
105+
lines.append(f" {preview}")
106+
107+
return "\n".join(lines)
108+
109+
72110
def _format_search_command_output(result):
73111
try:
74112
parsed = json.loads(result)

plugin.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
name: sourcevault-code-tools
2-
version: "1.2"
2+
version: "1.3"
33
description: Adds signed sourcevault code tools and slash commands.

test_plugin.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_registers_expected_commands_and_tools(self):
5353

5454
base_commands = [
5555
"code-help", "code-read", "code-search", "code-context",
56-
"code-ask", "code-status", "code-repos", "code-sync",
56+
"code-ask", "code-history", "code-status", "code-repos", "code-sync",
5757
]
5858
for name in base_commands:
5959
self.assertIn(name, ctx.commands, f"missing hyphen command {name}")
@@ -293,3 +293,118 @@ def test_numeric_question_is_not_eaten_as_count(self):
293293

294294
if __name__ == "__main__":
295295
unittest.main(verbosity=2)
296+
297+
298+
class HistoryToolTests(unittest.TestCase):
299+
def test_history_tools_registered(self):
300+
ctx = StubCtx()
301+
plugin.register(ctx)
302+
for name in ("code_history", "sourcevault_history"):
303+
self.assertIn(name, ctx.tools, f"missing history tool {name}")
304+
schema = ctx.tools[name]["schema"]
305+
self.assertEqual(schema["parameters"]["required"], ["repo_name", "question"])
306+
307+
def test_handle_code_history_posts_expected_body(self):
308+
captured = {}
309+
310+
def fake_post(url, body):
311+
captured["url"] = url
312+
captured["body"] = body
313+
return json.dumps({"success": True, "ok": True, "results": []})
314+
315+
original = plugin.tools._post_signed_json
316+
plugin.tools._post_signed_json = fake_post
317+
try:
318+
plugin.handle_code_history(
319+
{"repo_name": "myrepo", "question": "when did auth change", "n_results": "3"}
320+
)
321+
finally:
322+
plugin.tools._post_signed_json = original
323+
324+
self.assertTrue(captured["url"].endswith("/api/history-search"))
325+
self.assertEqual(
326+
captured["body"],
327+
{"repo_name": "myrepo", "question": "when did auth change", "n_results": 3},
328+
)
329+
330+
def test_handle_code_history_accepts_query_alias(self):
331+
captured = {}
332+
333+
def fake_post(url, body):
334+
captured["body"] = body
335+
return json.dumps({"success": True, "ok": True, "results": []})
336+
337+
original = plugin.tools._post_signed_json
338+
plugin.tools._post_signed_json = fake_post
339+
try:
340+
plugin.handle_code_history({"repo_name": "myrepo", "query": "why refactor"})
341+
finally:
342+
plugin.tools._post_signed_json = original
343+
344+
self.assertEqual(captured["body"]["question"], "why refactor")
345+
346+
def test_handle_code_history_translates_missing_route(self):
347+
original = plugin.tools._post_signed_json
348+
plugin.tools._post_signed_json = lambda url, body: "<html>Cannot POST /api/history-search</html>"
349+
try:
350+
result = json.loads(plugin.handle_code_history({"repo_name": "r", "question": "q"}))
351+
finally:
352+
plugin.tools._post_signed_json = original
353+
354+
self.assertFalse(result["success"])
355+
self.assertEqual(result["error"], "history_search_unsupported")
356+
self.assertIn("v1.8", result["detail"])
357+
358+
def test_format_history_command_output(self):
359+
payload = json.dumps({
360+
"success": True,
361+
"ok": True,
362+
"summary": "Found 2 matching commits",
363+
"results": [
364+
{
365+
"short": "abc1234",
366+
"date": "2026-01-02",
367+
"author": "Dev One",
368+
"subject": "fix: tighten header parsing",
369+
"preview": "commit abc1234 (2026-01-02) by Dev One fix: tighten header parsing",
370+
"ai_authored": True,
371+
},
372+
{
373+
"commit": "def5678901234",
374+
"date": "2026-01-01",
375+
"author": "Dev Two",
376+
"subject": "refactor router",
377+
"preview": "",
378+
},
379+
],
380+
})
381+
text = plugin.formatting._format_history_command_output(payload)
382+
self.assertIn("Found 2 matching commits", text)
383+
self.assertIn("#1 abc1234 (2026-01-02) Dev One [ai]", text)
384+
self.assertIn(" fix: tighten header parsing", text)
385+
self.assertIn("#2 def5678 (2026-01-01) Dev Two", text)
386+
self.assertNotIn("def5678 (2026-01-01) Dev Two [ai]", text)
387+
388+
def test_history_command_usage_and_wiring(self):
389+
usage = plugin.commands._handle_code_history_command("")
390+
self.assertIn("Usage: /code-history", usage)
391+
392+
captured = {}
393+
394+
def fake_post(url, body):
395+
captured["body"] = body
396+
return json.dumps({
397+
"success": True, "ok": True, "summary": "Found 1 matching commit",
398+
"results": [{"short": "aaa1111", "date": "2026-02-03", "author": "A", "subject": "s"}],
399+
})
400+
401+
original = plugin.tools._post_signed_json
402+
plugin.tools._post_signed_json = fake_post
403+
try:
404+
out = plugin.commands._handle_code_history_command('myrepo "when did tests move" 2')
405+
finally:
406+
plugin.tools._post_signed_json = original
407+
408+
self.assertEqual(captured["body"]["n_results"], 2)
409+
self.assertIn("Found 1 matching commit", out)
410+
self.assertIn("#1 aaa1111", out)

tools.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55

66
from .formatting import _read_file_content_or_result
77
from .helpers import _params, _positive_int, _relative_path, _repo_name
8-
from .transport import DEFAULT_READ_FILE_URL, DEFAULT_SEARCH_URL, _debug, _post_signed_json
8+
from .transport import (
9+
DEFAULT_HISTORY_URL,
10+
DEFAULT_READ_FILE_URL,
11+
DEFAULT_SEARCH_URL,
12+
_debug,
13+
_post_signed_json,
14+
)
915

1016

1117
def _register_code_search_tool(ctx, name):
@@ -110,6 +116,73 @@ def _register_code_read_tool(ctx, name):
110116
)
111117

112118

119+
def _register_code_history_tool(ctx, name):
120+
description = (
121+
"SourceVault git-history search. Answers questions about a repo's commit history "
122+
"(when something changed, why, by whom) from the locally indexed history. "
123+
"Requires SourceVault v1.8 or newer."
124+
)
125+
ctx.register_tool(
126+
name=name,
127+
toolset="sourcevault_code_tools",
128+
schema={
129+
"name": name,
130+
"description": description,
131+
"parameters": {
132+
"type": "object",
133+
"properties": {
134+
"repo_name": {
135+
"type": "string",
136+
"description": "SourceVault repository name under REPO_ROOT, for example hello-world.",
137+
},
138+
"question": {
139+
"type": "string",
140+
"description": "Natural-language question about the repo's commit history.",
141+
},
142+
"n_results": {
143+
"type": "integer",
144+
"description": "Maximum number of matching commits to return.",
145+
"default": 5,
146+
},
147+
},
148+
"required": ["repo_name", "question"],
149+
},
150+
},
151+
handler=handle_code_history,
152+
description=description,
153+
)
154+
155+
156+
def handle_code_history(params=None, **kwargs):
157+
_debug("code_history raw params=", params, " kwargs=", kwargs)
158+
params = _params(params, kwargs)
159+
_debug("code_history normalized params=", params)
160+
161+
body = {
162+
"repo_name": _repo_name(params),
163+
"question": str(params.get("question") or params.get("query") or "").strip(),
164+
"n_results": _positive_int(params.get("n_results") or params.get("max_results"), 5),
165+
}
166+
167+
result = _post_signed_json(
168+
os.environ.get("CODE_HISTORY_URL", DEFAULT_HISTORY_URL),
169+
body,
170+
)
171+
# An older SourceVault has no /api/history-search; express answers with a
172+
# "Cannot POST" page instead of JSON. Translate that into advice.
173+
if isinstance(result, str) and "Cannot POST" in result:
174+
return json.dumps(
175+
{
176+
"success": False,
177+
"ok": False,
178+
"error": "history_search_unsupported",
179+
"detail": "This SourceVault does not serve /api/history-search. Upgrade to v1.8 or newer.",
180+
},
181+
separators=(",", ":"),
182+
)
183+
return result
184+
185+
113186
def handle_code_search(params=None, **kwargs):
114187
_debug("code_search raw params=", params, " kwargs=", kwargs)
115188
params = _params(params, kwargs)

0 commit comments

Comments
 (0)