henchman-ai 0.1.10__py3-none-any.whl → 0.1.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- henchman/cli/app.py +131 -22
- henchman/cli/commands/__init__.py +2 -0
- henchman/cli/commands/builtins.py +6 -0
- henchman/cli/commands/chat.py +50 -36
- henchman/cli/commands/rag.py +26 -20
- henchman/cli/console.py +11 -6
- henchman/cli/input.py +65 -0
- henchman/cli/prompts.py +171 -70
- henchman/cli/repl.py +191 -33
- henchman/core/turn.py +15 -9
- henchman/rag/concurrency.py +206 -0
- henchman/rag/repo_id.py +7 -7
- henchman/rag/store.py +45 -11
- henchman/rag/system.py +93 -7
- henchman/utils/compaction.py +4 -3
- henchman/version.py +1 -1
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/METADATA +1 -1
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/RECORD +21 -20
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/WHEEL +0 -0
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/entry_points.txt +0 -0
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/licenses/LICENSE +0 -0
henchman/cli/app.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import os
|
|
6
7
|
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
@@ -58,15 +59,18 @@ def _get_provider() -> ModelProvider:
|
|
|
58
59
|
)
|
|
59
60
|
|
|
60
61
|
|
|
61
|
-
def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
62
|
+
def _run_interactive(output_format: str, plan_mode: bool = False, yes: bool = False) -> None:
|
|
62
63
|
"""Run interactive REPL mode.
|
|
63
64
|
|
|
64
65
|
Args:
|
|
65
|
-
output_format: Output format (text, json, stream-json).
|
|
66
|
+
output_format: Output format (text, json, stream-json, json-complete).
|
|
66
67
|
plan_mode: Whether to start in plan mode.
|
|
68
|
+
yes: Auto-approve all tool executions.
|
|
67
69
|
"""
|
|
70
|
+
from pathlib import Path
|
|
68
71
|
from henchman.cli.repl import Repl, ReplConfig
|
|
69
72
|
from henchman.config import ContextLoader, load_settings
|
|
73
|
+
from henchman.core.session import SessionManager
|
|
70
74
|
from henchman.rag import initialize_rag
|
|
71
75
|
|
|
72
76
|
provider = _get_provider()
|
|
@@ -76,7 +80,7 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
|
76
80
|
context_loader = ContextLoader()
|
|
77
81
|
system_prompt = context_loader.load()
|
|
78
82
|
|
|
79
|
-
config = ReplConfig(system_prompt=system_prompt)
|
|
83
|
+
config = ReplConfig(system_prompt=system_prompt, auto_approve_tools=yes)
|
|
80
84
|
repl = Repl(
|
|
81
85
|
provider=provider,
|
|
82
86
|
console=console,
|
|
@@ -84,8 +88,16 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
|
84
88
|
settings=settings
|
|
85
89
|
)
|
|
86
90
|
|
|
91
|
+
# Initialize session management
|
|
92
|
+
session_manager = SessionManager()
|
|
93
|
+
project_hash = session_manager.compute_project_hash(Path.cwd())
|
|
94
|
+
session = session_manager.create_session(project_hash)
|
|
95
|
+
|
|
96
|
+
repl.session_manager = session_manager
|
|
97
|
+
repl.session = session
|
|
98
|
+
|
|
87
99
|
# Initialize RAG system
|
|
88
|
-
rag_system = initialize_rag(settings.rag, console=console)
|
|
100
|
+
rag_system = initialize_rag(settings.rag, console=console, index=False)
|
|
89
101
|
if rag_system:
|
|
90
102
|
repl.tool_registry.register(rag_system.search_tool)
|
|
91
103
|
repl.rag_system = rag_system
|
|
@@ -108,18 +120,21 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
|
108
120
|
anyio.run(repl.run)
|
|
109
121
|
|
|
110
122
|
|
|
111
|
-
def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> None:
|
|
123
|
+
def _run_headless(prompt: str, output_format: str, plan_mode: bool = False, yes: bool = False) -> None:
|
|
112
124
|
"""Run headless mode with a single prompt.
|
|
113
125
|
|
|
114
126
|
Args:
|
|
115
127
|
prompt: The prompt to process.
|
|
116
|
-
output_format: Output format (text, json, stream-json).
|
|
128
|
+
output_format: Output format (text, json, stream-json, json-complete).
|
|
117
129
|
plan_mode: Whether to run in plan mode.
|
|
130
|
+
yes: Auto-approve all tool executions.
|
|
118
131
|
"""
|
|
132
|
+
from pathlib import Path
|
|
119
133
|
from henchman.cli.json_output import JsonOutputRenderer
|
|
120
134
|
from henchman.cli.repl import Repl, ReplConfig
|
|
121
135
|
from henchman.config import ContextLoader, load_settings
|
|
122
136
|
from henchman.core.events import EventType
|
|
137
|
+
from henchman.core.session import SessionManager
|
|
123
138
|
from henchman.rag import initialize_rag
|
|
124
139
|
|
|
125
140
|
provider = _get_provider()
|
|
@@ -134,11 +149,19 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
134
149
|
from henchman.cli.commands.plan import PLAN_MODE_PROMPT
|
|
135
150
|
system_prompt += PLAN_MODE_PROMPT
|
|
136
151
|
|
|
137
|
-
config = ReplConfig(system_prompt=system_prompt)
|
|
152
|
+
config = ReplConfig(system_prompt=system_prompt, auto_approve_tools=yes)
|
|
138
153
|
repl = Repl(provider=provider, console=console, config=config, settings=settings)
|
|
139
154
|
|
|
155
|
+
# Initialize session management
|
|
156
|
+
session_manager = SessionManager()
|
|
157
|
+
project_hash = session_manager.compute_project_hash(Path.cwd())
|
|
158
|
+
session = session_manager.create_session(project_hash)
|
|
159
|
+
|
|
160
|
+
repl.session_manager = session_manager
|
|
161
|
+
repl.session = session
|
|
162
|
+
|
|
140
163
|
# Initialize RAG system
|
|
141
|
-
rag_system = initialize_rag(settings.rag) # No console output in headless
|
|
164
|
+
rag_system = initialize_rag(settings.rag, index=False) # No console output in headless
|
|
142
165
|
if rag_system:
|
|
143
166
|
repl.tool_registry.register(rag_system.search_tool)
|
|
144
167
|
repl.rag_system = rag_system
|
|
@@ -150,23 +173,100 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
150
173
|
|
|
151
174
|
async def run_single_prompt_text() -> None: # pragma: no cover
|
|
152
175
|
"""Process a single prompt and exit with text output."""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
elif event.type == EventType.FINISHED:
|
|
157
|
-
console.print()
|
|
176
|
+
if repl.rag_system:
|
|
177
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
178
|
+
await repl.process_input(prompt)
|
|
158
179
|
|
|
159
180
|
async def run_single_prompt_json() -> None:
|
|
160
181
|
"""Process a single prompt and exit with JSON output."""
|
|
182
|
+
from henchman.core.events import AgentEvent, EventType
|
|
183
|
+
from henchman.providers.base import ToolCall
|
|
184
|
+
from collections.abc import AsyncIterator
|
|
185
|
+
|
|
186
|
+
if repl.rag_system:
|
|
187
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
188
|
+
|
|
161
189
|
json_renderer = JsonOutputRenderer(console)
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
|
|
191
|
+
# We need to manually run the tool loop for JSON output
|
|
192
|
+
# to ensure all events are rendered as JSON
|
|
193
|
+
async def run_and_render(stream: AsyncIterator[AgentEvent]) -> None:
|
|
194
|
+
pending_tool_calls = []
|
|
195
|
+
async for event in stream:
|
|
196
|
+
json_renderer.render(event)
|
|
197
|
+
if event.type == EventType.TOOL_CALL_REQUEST:
|
|
198
|
+
pending_tool_calls.append(event.data)
|
|
199
|
+
|
|
200
|
+
if pending_tool_calls:
|
|
201
|
+
# Execute tool calls
|
|
202
|
+
for tool_call in pending_tool_calls:
|
|
203
|
+
result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
|
|
204
|
+
repl.agent.submit_tool_result(tool_call.id, result.content)
|
|
205
|
+
# Emit tool result event
|
|
206
|
+
json_renderer.render(AgentEvent(type=EventType.TOOL_RESULT, data=result))
|
|
207
|
+
|
|
208
|
+
# Continue
|
|
209
|
+
await run_and_render(repl.agent.continue_with_tool_results())
|
|
210
|
+
|
|
211
|
+
await run_and_render(repl.agent.run(prompt))
|
|
164
212
|
|
|
165
213
|
async def run_single_prompt_stream_json() -> None:
|
|
166
214
|
"""Process a single prompt and exit with streaming JSON output."""
|
|
215
|
+
from henchman.core.events import AgentEvent, EventType
|
|
216
|
+
from henchman.providers.base import ToolCall
|
|
217
|
+
from collections.abc import AsyncIterator
|
|
218
|
+
|
|
219
|
+
if repl.rag_system:
|
|
220
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
221
|
+
|
|
167
222
|
json_renderer = JsonOutputRenderer(console)
|
|
168
|
-
|
|
169
|
-
|
|
223
|
+
|
|
224
|
+
async def run_and_render_stream(stream: AsyncIterator[AgentEvent]) -> None:
|
|
225
|
+
pending_tool_calls = []
|
|
226
|
+
async for event in stream:
|
|
227
|
+
json_renderer.render_stream_json(event)
|
|
228
|
+
if event.type == EventType.TOOL_CALL_REQUEST:
|
|
229
|
+
pending_tool_calls.append(event.data)
|
|
230
|
+
|
|
231
|
+
if pending_tool_calls:
|
|
232
|
+
for tool_call in pending_tool_calls:
|
|
233
|
+
result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
|
|
234
|
+
repl.agent.submit_tool_result(tool_call.id, result.content)
|
|
235
|
+
json_renderer.render_stream_json(AgentEvent(type=EventType.TOOL_RESULT, data=result))
|
|
236
|
+
|
|
237
|
+
await run_and_render_stream(repl.agent.continue_with_tool_results())
|
|
238
|
+
|
|
239
|
+
await run_and_render_stream(repl.agent.run(prompt))
|
|
240
|
+
|
|
241
|
+
async def run_single_prompt_json_complete() -> None:
|
|
242
|
+
"""Process a single prompt and exit with complete JSON output."""
|
|
243
|
+
from henchman.core.events import AgentEvent, EventType
|
|
244
|
+
from henchman.providers.base import ToolCall
|
|
245
|
+
from collections.abc import AsyncIterator
|
|
246
|
+
|
|
247
|
+
if repl.rag_system:
|
|
248
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
249
|
+
|
|
250
|
+
json_renderer = JsonOutputRenderer(console)
|
|
251
|
+
all_events: list[AgentEvent] = []
|
|
252
|
+
|
|
253
|
+
async def run_and_collect(stream: AsyncIterator[AgentEvent]) -> None:
|
|
254
|
+
pending_tool_calls = []
|
|
255
|
+
async for event in stream:
|
|
256
|
+
all_events.append(event)
|
|
257
|
+
if event.type == EventType.TOOL_CALL_REQUEST:
|
|
258
|
+
pending_tool_calls.append(event.data)
|
|
259
|
+
|
|
260
|
+
if pending_tool_calls:
|
|
261
|
+
for tool_call in pending_tool_calls:
|
|
262
|
+
result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
|
|
263
|
+
repl.agent.submit_tool_result(tool_call.id, result.content)
|
|
264
|
+
all_events.append(AgentEvent(type=EventType.TOOL_RESULT, data=result))
|
|
265
|
+
|
|
266
|
+
await run_and_collect(repl.agent.continue_with_tool_results())
|
|
267
|
+
|
|
268
|
+
await run_and_collect(repl.agent.run(prompt))
|
|
269
|
+
json_renderer.render_final_json(all_events)
|
|
170
270
|
|
|
171
271
|
if output_format == "text":
|
|
172
272
|
anyio.run(run_single_prompt_text)
|
|
@@ -174,16 +274,18 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
174
274
|
anyio.run(run_single_prompt_json)
|
|
175
275
|
elif output_format == "stream-json":
|
|
176
276
|
anyio.run(run_single_prompt_stream_json)
|
|
277
|
+
elif output_format == "json-complete":
|
|
278
|
+
anyio.run(run_single_prompt_json_complete)
|
|
177
279
|
else: # pragma: no cover
|
|
178
280
|
pass
|
|
179
281
|
|
|
180
282
|
|
|
181
283
|
@click.command()
|
|
182
|
-
@click.version_option(version=VERSION, prog_name="
|
|
284
|
+
@click.version_option(version=VERSION, prog_name="henchman")
|
|
183
285
|
@click.option("-p", "--prompt", help="Run with a single prompt and exit")
|
|
184
286
|
@click.option(
|
|
185
287
|
"--output-format",
|
|
186
|
-
type=click.Choice(["text", "json", "stream-json"]),
|
|
288
|
+
type=click.Choice(["text", "json", "stream-json", "json-complete"]),
|
|
187
289
|
default="text",
|
|
188
290
|
help="Output format for responses",
|
|
189
291
|
)
|
|
@@ -193,15 +295,22 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
193
295
|
default=False,
|
|
194
296
|
help="Start in plan mode (read-only)",
|
|
195
297
|
)
|
|
196
|
-
|
|
298
|
+
@click.option(
|
|
299
|
+
"-y",
|
|
300
|
+
"--yes",
|
|
301
|
+
is_flag=True,
|
|
302
|
+
default=False,
|
|
303
|
+
help="Auto-approve all tool executions (non-interactive mode)",
|
|
304
|
+
)
|
|
305
|
+
def cli(prompt: str | None, output_format: str, plan: bool, yes: bool) -> None:
|
|
197
306
|
"""Henchman-AI: A model-agnostic AI agent CLI.
|
|
198
307
|
|
|
199
308
|
Start an interactive session or run with --prompt for headless mode.
|
|
200
309
|
"""
|
|
201
310
|
if prompt:
|
|
202
|
-
_run_headless(prompt, output_format, plan)
|
|
311
|
+
_run_headless(prompt, output_format, plan, yes)
|
|
203
312
|
else:
|
|
204
|
-
_run_interactive(output_format, plan)
|
|
313
|
+
_run_interactive(output_format, plan, yes)
|
|
205
314
|
|
|
206
315
|
|
|
207
316
|
def main() -> None: # pragma: no cover
|
|
@@ -49,6 +49,7 @@ class CommandContext:
|
|
|
49
49
|
agent: Agent instance if available.
|
|
50
50
|
tool_registry: ToolRegistry instance if available.
|
|
51
51
|
session: Current Session if available.
|
|
52
|
+
repl: REPL instance if available.
|
|
52
53
|
"""
|
|
53
54
|
|
|
54
55
|
console: Console
|
|
@@ -57,6 +58,7 @@ class CommandContext:
|
|
|
57
58
|
agent: Agent | None = None
|
|
58
59
|
tool_registry: ToolRegistry | None = None
|
|
59
60
|
session: Session | None = None
|
|
61
|
+
repl: object | None = None
|
|
60
62
|
|
|
61
63
|
|
|
62
64
|
class Command(ABC):
|
|
@@ -6,6 +6,8 @@ This module provides the default slash commands like /help, /quit, /clear, /tool
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
from henchman.cli.commands import Command, CommandContext
|
|
9
|
+
from henchman.cli.commands.chat import ChatCommand
|
|
10
|
+
from henchman.cli.commands.mcp import McpCommand
|
|
9
11
|
from henchman.cli.commands.plan import PlanCommand
|
|
10
12
|
from henchman.cli.commands.rag import RagCommand
|
|
11
13
|
from henchman.cli.commands.skill import SkillCommand
|
|
@@ -53,6 +55,8 @@ class HelpCommand(Command):
|
|
|
53
55
|
ctx.console.print(" /plan - Toggle Plan Mode (Read-Only)")
|
|
54
56
|
ctx.console.print(" /rag - Manage semantic search index")
|
|
55
57
|
ctx.console.print(" /skill - Manage and execute learned skills")
|
|
58
|
+
ctx.console.print(" /chat - Manage chat sessions (save, list, resume)")
|
|
59
|
+
ctx.console.print(" /mcp - Manage MCP server connections")
|
|
56
60
|
ctx.console.print(" /quit - Exit the CLI")
|
|
57
61
|
ctx.console.print(" /clear - Clear the screen")
|
|
58
62
|
ctx.console.print(" /tools - List available tools")
|
|
@@ -206,6 +210,8 @@ def get_builtin_commands() -> list[Command]:
|
|
|
206
210
|
QuitCommand(),
|
|
207
211
|
ClearCommand(),
|
|
208
212
|
ToolsCommand(),
|
|
213
|
+
ChatCommand(),
|
|
214
|
+
McpCommand(),
|
|
209
215
|
PlanCommand(),
|
|
210
216
|
RagCommand(),
|
|
211
217
|
SkillCommand(),
|
henchman/cli/commands/chat.py
CHANGED
|
@@ -67,9 +67,9 @@ class ChatCommand(Command):
|
|
|
67
67
|
async def _show_help(self, ctx: CommandContext) -> None:
|
|
68
68
|
"""Show help for /chat command."""
|
|
69
69
|
ctx.console.print("\n[bold blue]Chat Session Commands[/]\n")
|
|
70
|
-
ctx.console.print(" /chat save [tag]
|
|
71
|
-
ctx.console.print(" /chat list
|
|
72
|
-
ctx.console.print(" /chat resume <tag> - Resume a saved session")
|
|
70
|
+
ctx.console.print(" /chat save [tag] - Save current session")
|
|
71
|
+
ctx.console.print(" /chat list - List saved sessions")
|
|
72
|
+
ctx.console.print(" /chat resume <tag|id> - Resume a saved session")
|
|
73
73
|
ctx.console.print("")
|
|
74
74
|
|
|
75
75
|
async def _save(self, ctx: CommandContext) -> None:
|
|
@@ -124,46 +124,60 @@ class ChatCommand(Command):
|
|
|
124
124
|
return
|
|
125
125
|
|
|
126
126
|
if len(ctx.args) < 2:
|
|
127
|
-
ctx.console.print("[yellow]Usage: /chat resume <tag>[/]")
|
|
127
|
+
ctx.console.print("[yellow]Usage: /chat resume <tag|id>[/]")
|
|
128
128
|
return
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
tag_or_id = ctx.args[1]
|
|
131
131
|
project_hash = getattr(ctx, "project_hash", None)
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
# Try loading by tag first
|
|
134
|
+
session = manager.load_by_tag(tag_or_id, project_hash)
|
|
135
|
+
|
|
136
|
+
# If that fails, try loading by ID
|
|
134
137
|
if session is None:
|
|
135
|
-
|
|
138
|
+
try:
|
|
139
|
+
session = manager.load(tag_or_id)
|
|
140
|
+
except (ValueError, FileNotFoundError):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
if session is None:
|
|
144
|
+
ctx.console.print(f"[red]Session not found: {tag_or_id}[/]")
|
|
136
145
|
return
|
|
137
146
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
147
|
+
# Use repl.set_session if available to handle all sync logic
|
|
148
|
+
if ctx.repl and hasattr(ctx.repl, "set_session"):
|
|
149
|
+
ctx.repl.set_session(session)
|
|
150
|
+
else:
|
|
151
|
+
# Fallback for when REPL isn't available (e.g. some tests)
|
|
152
|
+
manager.set_current(session)
|
|
153
|
+
|
|
154
|
+
# Restore session messages to agent history
|
|
155
|
+
if ctx.agent is not None:
|
|
156
|
+
# Clear agent history (keeping system prompt)
|
|
157
|
+
ctx.agent.clear_history()
|
|
158
|
+
|
|
159
|
+
# Convert SessionMessage objects to Message objects
|
|
160
|
+
for session_msg in session.messages:
|
|
161
|
+
# Convert tool_calls from dicts to ToolCall objects if present
|
|
162
|
+
tool_calls = None
|
|
163
|
+
if session_msg.tool_calls:
|
|
164
|
+
tool_calls = [
|
|
165
|
+
ToolCall(
|
|
166
|
+
id=tc.get("id", ""),
|
|
167
|
+
name=tc.get("name", ""),
|
|
168
|
+
arguments=tc.get("arguments", {}),
|
|
169
|
+
)
|
|
170
|
+
for tc in session_msg.tool_calls
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
msg = Message(
|
|
174
|
+
role=session_msg.role,
|
|
175
|
+
content=session_msg.content,
|
|
176
|
+
tool_calls=tool_calls,
|
|
177
|
+
tool_call_id=session_msg.tool_call_id,
|
|
178
|
+
)
|
|
179
|
+
ctx.agent.messages.append(msg)
|
|
166
180
|
|
|
167
181
|
ctx.console.print(
|
|
168
|
-
f"[green]✓[/] Resumed session '{
|
|
182
|
+
f"[green]✓[/] Resumed session '{tag_or_id}' ({len(session.messages)} messages)"
|
|
169
183
|
)
|
henchman/cli/commands/rag.py
CHANGED
|
@@ -6,7 +6,6 @@ This module provides the /rag command for managing the RAG index.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import shutil
|
|
9
|
-
from pathlib import Path
|
|
10
9
|
from typing import TYPE_CHECKING
|
|
11
10
|
|
|
12
11
|
from henchman.cli.commands import Command, CommandContext
|
|
@@ -126,12 +125,17 @@ class RagCommand(Command):
|
|
|
126
125
|
)
|
|
127
126
|
return
|
|
128
127
|
|
|
128
|
+
if getattr(rag_system, "is_indexing", False):
|
|
129
|
+
ctx.console.print("[yellow]RAG indexing is already in progress in the background.[/]")
|
|
130
|
+
return
|
|
131
|
+
|
|
129
132
|
ctx.console.print("[dim]Forcing full reindex...[/]")
|
|
130
133
|
stats = rag_system.index(console=ctx.console, force=True)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
if stats:
|
|
135
|
+
ctx.console.print(
|
|
136
|
+
f"[green]Reindex complete: {stats.files_added} files, "
|
|
137
|
+
f"{stats.total_chunks} chunks[/]"
|
|
138
|
+
)
|
|
135
139
|
|
|
136
140
|
async def _clear(self, ctx: CommandContext) -> None:
|
|
137
141
|
"""Clear the RAG index."""
|
|
@@ -150,20 +154,22 @@ class RagCommand(Command):
|
|
|
150
154
|
async def _clear_all(self, ctx: CommandContext) -> None:
|
|
151
155
|
"""Clear ALL RAG indices from the cache directory."""
|
|
152
156
|
from henchman.rag.repo_id import get_rag_cache_dir
|
|
153
|
-
|
|
157
|
+
|
|
154
158
|
cache_dir = get_rag_cache_dir()
|
|
155
|
-
|
|
159
|
+
|
|
156
160
|
if not cache_dir.exists():
|
|
157
161
|
ctx.console.print("[yellow]No RAG cache directory found[/]")
|
|
158
162
|
return
|
|
159
|
-
|
|
160
|
-
# Ask for confirmation
|
|
163
|
+
|
|
164
|
+
# Ask for confirmation using simple input
|
|
161
165
|
ctx.console.print("[yellow]Warning: This will delete ALL RAG indices![/]")
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
ctx.console.print("Type 'yes' to confirm: ", end="")
|
|
167
|
+
try:
|
|
168
|
+
confirm = input()
|
|
169
|
+
except (EOFError, KeyboardInterrupt):
|
|
170
|
+
confirm = ""
|
|
171
|
+
|
|
172
|
+
if confirm.lower() in ("yes", "y"):
|
|
167
173
|
try:
|
|
168
174
|
shutil.rmtree(cache_dir)
|
|
169
175
|
ctx.console.print(f"[green]Cleared all RAG indices from {cache_dir}[/]")
|
|
@@ -175,32 +181,32 @@ class RagCommand(Command):
|
|
|
175
181
|
async def _cleanup(self, ctx: CommandContext) -> None:
|
|
176
182
|
"""Clean up old project-based RAG indices."""
|
|
177
183
|
from henchman.rag.system import find_git_root
|
|
178
|
-
|
|
184
|
+
|
|
179
185
|
# Find git root if we're in a repository
|
|
180
186
|
git_root = find_git_root()
|
|
181
187
|
if not git_root:
|
|
182
188
|
ctx.console.print("[yellow]Not in a git repository[/]")
|
|
183
189
|
return
|
|
184
|
-
|
|
190
|
+
|
|
185
191
|
old_index_dir = git_root / ".henchman" / "rag_index"
|
|
186
192
|
old_manifest = git_root / ".henchman" / "rag_manifest.json"
|
|
187
|
-
|
|
193
|
+
|
|
188
194
|
removed = []
|
|
189
|
-
|
|
195
|
+
|
|
190
196
|
if old_index_dir.exists():
|
|
191
197
|
try:
|
|
192
198
|
shutil.rmtree(old_index_dir)
|
|
193
199
|
removed.append(f"Index directory: {old_index_dir}")
|
|
194
200
|
except Exception as e:
|
|
195
201
|
ctx.console.print(f"[yellow]Error removing {old_index_dir}: {e}[/]")
|
|
196
|
-
|
|
202
|
+
|
|
197
203
|
if old_manifest.exists():
|
|
198
204
|
try:
|
|
199
205
|
old_manifest.unlink()
|
|
200
206
|
removed.append(f"Manifest file: {old_manifest}")
|
|
201
207
|
except Exception as e:
|
|
202
208
|
ctx.console.print(f"[yellow]Error removing {old_manifest}: {e}[/]")
|
|
203
|
-
|
|
209
|
+
|
|
204
210
|
if removed:
|
|
205
211
|
ctx.console.print("[green]Cleaned up old project-based RAG indices:[/]")
|
|
206
212
|
for item in removed:
|
henchman/cli/console.py
CHANGED
|
@@ -7,8 +7,10 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
|
|
10
|
+
import anyio
|
|
10
11
|
from rich.console import Console
|
|
11
12
|
from rich.markdown import Markdown
|
|
13
|
+
from rich.markup import escape
|
|
12
14
|
from rich.syntax import Syntax
|
|
13
15
|
|
|
14
16
|
|
|
@@ -150,7 +152,7 @@ class OutputRenderer:
|
|
|
150
152
|
Args:
|
|
151
153
|
message: Success message text.
|
|
152
154
|
"""
|
|
153
|
-
self.console.print(f"[{self.theme.success}]✓[/] {message}")
|
|
155
|
+
self.console.print(f"[{self.theme.success}]✓[/] {escape(message)}")
|
|
154
156
|
|
|
155
157
|
def info(self, message: str) -> None:
|
|
156
158
|
"""Print an info message.
|
|
@@ -158,7 +160,7 @@ class OutputRenderer:
|
|
|
158
160
|
Args:
|
|
159
161
|
message: Info message text.
|
|
160
162
|
"""
|
|
161
|
-
self.console.print(f"[{self.theme.primary}]ℹ[/] {message}")
|
|
163
|
+
self.console.print(f"[{self.theme.primary}]ℹ[/] {escape(message)}")
|
|
162
164
|
|
|
163
165
|
def warning(self, message: str) -> None:
|
|
164
166
|
"""Print a warning message.
|
|
@@ -166,7 +168,7 @@ class OutputRenderer:
|
|
|
166
168
|
Args:
|
|
167
169
|
message: Warning message text.
|
|
168
170
|
"""
|
|
169
|
-
self.console.print(f"[{self.theme.warning}]⚠[/] {message}")
|
|
171
|
+
self.console.print(f"[{self.theme.warning}]⚠[/] {escape(message)}")
|
|
170
172
|
|
|
171
173
|
def error(self, message: str) -> None:
|
|
172
174
|
"""Print an error message.
|
|
@@ -174,7 +176,7 @@ class OutputRenderer:
|
|
|
174
176
|
Args:
|
|
175
177
|
message: Error message text.
|
|
176
178
|
"""
|
|
177
|
-
self.console.print(f"[{self.theme.error}]✗[/] {message}")
|
|
179
|
+
self.console.print(f"[{self.theme.error}]✗[/] {escape(message)}")
|
|
178
180
|
|
|
179
181
|
def muted(self, text: str) -> None:
|
|
180
182
|
"""Print muted/dim text.
|
|
@@ -190,7 +192,7 @@ class OutputRenderer:
|
|
|
190
192
|
Args:
|
|
191
193
|
text: Heading text.
|
|
192
194
|
"""
|
|
193
|
-
self.console.print(f"\n[bold {self.theme.primary}]{text}[/]\n")
|
|
195
|
+
self.console.print(f"\n[bold {self.theme.primary}]{escape(text)}[/]\n")
|
|
194
196
|
|
|
195
197
|
def markdown(self, content: str) -> None:
|
|
196
198
|
"""Render markdown content.
|
|
@@ -301,4 +303,7 @@ class OutputRenderer:
|
|
|
301
303
|
True if confirmed.
|
|
302
304
|
"""
|
|
303
305
|
from rich.prompt import Confirm
|
|
304
|
-
|
|
306
|
+
import anyio
|
|
307
|
+
return await anyio.to_thread.run_sync(
|
|
308
|
+
lambda: Confirm.ask(message, console=self.console)
|
|
309
|
+
)
|
henchman/cli/input.py
CHANGED
|
@@ -5,6 +5,7 @@ This module handles user input including @ file references and ! shell commands.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import asyncio
|
|
8
9
|
import contextlib
|
|
9
10
|
import re
|
|
10
11
|
from pathlib import Path
|
|
@@ -168,3 +169,67 @@ class InputHandler:
|
|
|
168
169
|
The prompt string.
|
|
169
170
|
"""
|
|
170
171
|
return self._prompt
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class KeyMonitor:
|
|
175
|
+
"""Monitors for specific keys while an async task is running.
|
|
176
|
+
|
|
177
|
+
This is used to detect Escape or Ctrl+C while the agent is generating output.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self) -> None:
|
|
181
|
+
"""Initialize the key monitor."""
|
|
182
|
+
from prompt_toolkit.input import create_input
|
|
183
|
+
self.input = create_input()
|
|
184
|
+
self._stop_event = asyncio.Event()
|
|
185
|
+
self._exit_event = asyncio.Event()
|
|
186
|
+
self._suspended = asyncio.Event()
|
|
187
|
+
self._suspended.set() # Not suspended initially
|
|
188
|
+
self._in_raw_mode = False
|
|
189
|
+
|
|
190
|
+
async def suspend(self) -> None:
|
|
191
|
+
"""Suspend monitoring to allow other input reading."""
|
|
192
|
+
self._suspended.clear()
|
|
193
|
+
# Wait for the monitor loop to exit the raw mode block
|
|
194
|
+
while self._in_raw_mode:
|
|
195
|
+
await asyncio.sleep(0.01)
|
|
196
|
+
|
|
197
|
+
def resume(self) -> None:
|
|
198
|
+
"""Resume monitoring."""
|
|
199
|
+
self._suspended.set()
|
|
200
|
+
|
|
201
|
+
async def monitor(self) -> None:
|
|
202
|
+
"""Monitor input for keys. Run this in a background task."""
|
|
203
|
+
while not self._stop_event.is_set() and not self._exit_event.is_set():
|
|
204
|
+
if not self._suspended.is_set():
|
|
205
|
+
await asyncio.sleep(0.1)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
with self.input.raw_mode():
|
|
210
|
+
self._in_raw_mode = True
|
|
211
|
+
with self.input.attach(lambda: None):
|
|
212
|
+
while not self._stop_event.is_set() and not self._exit_event.is_set() and self._suspended.is_set():
|
|
213
|
+
# Check for keys every 100ms
|
|
214
|
+
keys = self.input.read_keys()
|
|
215
|
+
for key in keys:
|
|
216
|
+
if key.key == Keys.Escape:
|
|
217
|
+
self._stop_event.set()
|
|
218
|
+
elif key.key == Keys.ControlC:
|
|
219
|
+
self._exit_event.set()
|
|
220
|
+
await asyncio.sleep(0.1)
|
|
221
|
+
finally:
|
|
222
|
+
self._in_raw_mode = False
|
|
223
|
+
# If we were suspended, wait a bit before potentially re-entering raw mode
|
|
224
|
+
if not self._suspended.is_set():
|
|
225
|
+
await asyncio.sleep(0.1)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def stop_requested(self) -> bool:
|
|
229
|
+
"""Check if Escape was pressed."""
|
|
230
|
+
return self._stop_event.is_set()
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def exit_requested(self) -> bool:
|
|
234
|
+
"""Check if Ctrl+C was pressed."""
|
|
235
|
+
return self._exit_event.is_set()
|