henchman-ai 0.1.11__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 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
- async for event in repl.agent.run(prompt):
154
- if event.type == EventType.CONTENT:
155
- console.print(event.data, end="")
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
- async for event in repl.agent.run(prompt):
163
- json_renderer.render(event)
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
- async for event in repl.agent.run(prompt):
169
- json_renderer.render_stream_json(event)
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="mlg")
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
- def cli(prompt: str | None, output_format: str, plan: bool) -> None:
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
@@ -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(),
@@ -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] - Save current session")
71
- ctx.console.print(" /chat list - List saved sessions")
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
- tag = ctx.args[1]
130
+ tag_or_id = ctx.args[1]
131
131
  project_hash = getattr(ctx, "project_hash", None)
132
132
 
133
- session = manager.load_by_tag(tag, project_hash)
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
- ctx.console.print(f"[red]Session not found: {tag}[/]")
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
- manager.set_current(session)
139
-
140
- # Restore session messages to agent history
141
- if ctx.agent is not None:
142
- # Clear agent history (keeping system prompt)
143
- ctx.agent.clear_history()
144
-
145
- # Convert SessionMessage objects to Message objects
146
- for session_msg in session.messages:
147
- # Convert tool_calls from dicts to ToolCall objects if present
148
- tool_calls = None
149
- if session_msg.tool_calls:
150
- tool_calls = [
151
- ToolCall(
152
- id=tc.get("id", ""),
153
- name=tc.get("name", ""),
154
- arguments=tc.get("arguments", {}),
155
- )
156
- for tc in session_msg.tool_calls
157
- ]
158
-
159
- msg = Message(
160
- role=session_msg.role,
161
- content=session_msg.content,
162
- tool_calls=tool_calls,
163
- tool_call_id=session_msg.tool_call_id,
164
- )
165
- ctx.agent.messages.append(msg)
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 '{tag}' ({len(session.messages)} messages)"
182
+ f"[green]✓[/] Resumed session '{tag_or_id}' ({len(session.messages)} messages)"
169
183
  )
@@ -125,12 +125,17 @@ class RagCommand(Command):
125
125
  )
126
126
  return
127
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
+
128
132
  ctx.console.print("[dim]Forcing full reindex...[/]")
129
133
  stats = rag_system.index(console=ctx.console, force=True)
130
- ctx.console.print(
131
- f"[green]Reindex complete: {stats.files_added} files, "
132
- f"{stats.total_chunks} chunks[/]"
133
- )
134
+ if stats:
135
+ ctx.console.print(
136
+ f"[green]Reindex complete: {stats.files_added} files, "
137
+ f"{stats.total_chunks} chunks[/]"
138
+ )
134
139
 
135
140
  async def _clear(self, ctx: CommandContext) -> None:
136
141
  """Clear the RAG index."""
henchman/cli/console.py CHANGED
@@ -7,6 +7,7 @@ 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
12
13
  from rich.markup import escape
@@ -302,4 +303,7 @@ class OutputRenderer:
302
303
  True if confirmed.
303
304
  """
304
305
  from rich.prompt import Confirm
305
- return Confirm.ask(message, console=self.console)
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()
henchman/cli/repl.py CHANGED
@@ -5,6 +5,7 @@ This module provides the main interactive loop for the CLI.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
8
9
  from collections.abc import AsyncIterator
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
@@ -19,7 +20,8 @@ from henchman.cli.input import create_session, expand_at_references, is_slash_co
19
20
  from henchman.core.agent import Agent
20
21
  from henchman.core.events import AgentEvent, EventType
21
22
  from henchman.core.session import Session, SessionManager, SessionMessage
22
- from henchman.providers.base import ModelProvider, ToolCall
23
+ from henchman.providers.base import Message, ModelProvider, ToolCall
24
+ from henchman.tools.base import ConfirmationRequest, ToolKind
23
25
  from henchman.tools.registry import ToolRegistry
24
26
 
25
27
  if TYPE_CHECKING:
@@ -37,6 +39,7 @@ class ReplConfig:
37
39
  history_file: Path to history file.
38
40
  base_tool_iterations: Base limit for tool iterations per turn.
39
41
  max_tool_calls_per_turn: Maximum tool calls allowed per turn.
42
+ auto_approve_tools: Auto-approve all tool executions (non-interactive mode).
40
43
  """
41
44
 
42
45
  prompt: str = "❯ "
@@ -45,6 +48,7 @@ class ReplConfig:
45
48
  history_file: Path | None = None
46
49
  base_tool_iterations: int = 25
47
50
  max_tool_calls_per_turn: int = 100
51
+ auto_approve_tools: bool = False
48
52
 
49
53
 
50
54
  class Repl:
@@ -82,6 +86,7 @@ class Repl:
82
86
 
83
87
  # Initialize tool registry with built-in tools
84
88
  self.tool_registry = ToolRegistry()
89
+ self.tool_registry.set_confirmation_handler(self._handle_confirmation)
85
90
  self._register_builtin_tools()
86
91
 
87
92
  # Determine max_tokens from settings
@@ -128,6 +133,43 @@ class Repl:
128
133
 
129
134
  # RAG system (set externally by app.py)
130
135
  self.rag_system: object | None = None
136
+ self.current_monitor: object | None = None
137
+
138
+ def set_session(self, session: Session) -> None:
139
+ """Set the current session and sync with agent history.
140
+
141
+ Args:
142
+ session: The session to activate.
143
+ """
144
+ self.session = session
145
+ if self.session_manager:
146
+ self.session_manager.set_current(session)
147
+
148
+ # Restore session messages to agent history
149
+ # Clear agent history (keeping current system prompt)
150
+ self.agent.clear_history()
151
+
152
+ # Convert SessionMessage objects to Message objects
153
+ for session_msg in session.messages:
154
+ # Convert tool_calls from dicts to ToolCall objects if present
155
+ tool_calls = None
156
+ if session_msg.tool_calls:
157
+ tool_calls = [
158
+ ToolCall(
159
+ id=tc.get("id", ""),
160
+ name=tc.get("name", ""),
161
+ arguments=tc.get("arguments", {}),
162
+ )
163
+ for tc in session_msg.tool_calls
164
+ ]
165
+
166
+ msg = Message(
167
+ role=session_msg.role,
168
+ content=session_msg.content,
169
+ tool_calls=tool_calls,
170
+ tool_call_id=session_msg.tool_call_id,
171
+ )
172
+ self.agent.messages.append(msg)
131
173
 
132
174
  def _get_toolbar_status(self) -> list[tuple[str, str]]:
133
175
  """Get status bar content."""
@@ -150,6 +192,10 @@ class Repl:
150
192
  except Exception:
151
193
  pass
152
194
 
195
+ # RAG Status
196
+ if self.rag_system and getattr(self.rag_system, "is_indexing", False):
197
+ status.append(("bg:cyan fg:black", " RAG: Indexing... "))
198
+
153
199
  return status
154
200
 
155
201
  def _register_builtin_tools(self) -> None:
@@ -180,6 +226,40 @@ class Repl:
180
226
  for tool in tools:
181
227
  self.tool_registry.register(tool)
182
228
 
229
+ async def _handle_confirmation(self, request: ConfirmationRequest) -> bool:
230
+ """Handle a tool confirmation request from the registry.
231
+
232
+ Args:
233
+ request: The confirmation request data.
234
+
235
+ Returns:
236
+ True if approved, False otherwise.
237
+ """
238
+ # Auto-approve if configured
239
+ if self.config.auto_approve_tools:
240
+ return True
241
+
242
+ # Formulate a clear message for the user
243
+ import json
244
+
245
+ msg = f"Allow tool [bold cyan]{request.tool_name}[/] to "
246
+
247
+ if request.tool_name == "shell":
248
+ command = request.params.get("command", "unknown command") if request.params else "unknown command"
249
+ msg += f"run command: [yellow]{command}[/]"
250
+ elif request.tool_name == "write_file":
251
+ path = request.params.get("path", "unknown path") if request.params else "unknown path"
252
+ msg += f"write to file: [yellow]{path}[/]"
253
+ elif request.tool_name == "edit_file":
254
+ path = request.params.get("path", "unknown path") if request.params else "unknown path"
255
+ msg += f"edit file: [yellow]{path}[/]"
256
+ else:
257
+ msg += f"execute: {request.description}"
258
+ if request.params:
259
+ msg += f"\nParams: [dim]{json.dumps(request.params)}[/]"
260
+
261
+ return await self.renderer.confirm_tool_execution(msg)
262
+
183
263
  async def run(self) -> None:
184
264
  """Run the main REPL loop.
185
265
 
@@ -188,6 +268,10 @@ class Repl:
188
268
  self.running = True
189
269
  self._print_welcome()
190
270
 
271
+ # Start background indexing if RAG is available
272
+ if self.rag_system and hasattr(self.rag_system, "index_async"):
273
+ asyncio.create_task(self.rag_system.index_async())
274
+
191
275
  try:
192
276
  while self.running:
193
277
  try:
@@ -196,9 +280,9 @@ class Repl:
196
280
  if not should_continue:
197
281
  break
198
282
  except KeyboardInterrupt:
199
- # In PromptSession, Ctrl-C raises KeyboardInterrupt
200
- # We treat it as clearing the line or exiting if repeated
201
- continue
283
+ # Ctrl-C instantly ends the session
284
+ self.running = False
285
+ break
202
286
  except EOFError:
203
287
  self.console.print()
204
288
  break
@@ -306,6 +390,12 @@ class Repl:
306
390
  session=self.session,
307
391
  repl=self,
308
392
  )
393
+ # Add session_manager and project_hash for /chat command
394
+ if self.session_manager:
395
+ setattr(ctx, "session_manager", self.session_manager)
396
+ from pathlib import Path
397
+ setattr(ctx, "project_hash", self.session_manager.compute_project_hash(Path.cwd()))
398
+
309
399
  await cmd.execute(ctx)
310
400
  return True
311
401
 
@@ -322,16 +412,48 @@ class Repl:
322
412
  # Collect assistant response - now also tracks tool calls for session
323
413
  assistant_content: list[str] = []
324
414
 
325
- try:
326
- await self._process_agent_stream(
415
+ from henchman.cli.input import KeyMonitor
416
+ monitor = KeyMonitor()
417
+ self.current_monitor = monitor
418
+ monitor_task = asyncio.create_task(monitor.monitor())
419
+
420
+ # Run the agent stream processing as a separate task so we can cancel it
421
+ agent_task = asyncio.create_task(
422
+ self._process_agent_stream(
327
423
  self.agent.run(user_input),
328
424
  assistant_content
329
425
  )
426
+ )
427
+
428
+ try:
429
+ while not agent_task.done():
430
+ if monitor.exit_requested:
431
+ self.renderer.warning("\n[Exit requested by Ctrl+C]")
432
+ self.running = False
433
+ agent_task.cancel()
434
+ break
435
+ if monitor.stop_requested:
436
+ self.renderer.warning("\n[Interrupted by Esc]")
437
+ agent_task.cancel()
438
+ break
439
+ # Small sleep to keep the loop responsive
440
+ await asyncio.sleep(0.05)
441
+
442
+ if not agent_task.done():
443
+ try:
444
+ await agent_task
445
+ except asyncio.CancelledError:
446
+ pass
447
+ else:
448
+ # Task finished normally, await it to raise any exceptions
449
+ await agent_task
450
+
330
451
  except Exception as e:
331
452
  self.renderer.error(f"Error: {e}")
332
-
333
- # Session recording is now handled within _process_agent_stream
334
- # and _execute_tool_calls to properly capture tool calls and results
453
+ finally:
454
+ # Ensure monitor task is cleaned up
455
+ monitor._stop_event.set()
456
+ await monitor_task
335
457
 
336
458
  async def _process_agent_stream(
337
459
  self,
@@ -445,35 +567,38 @@ class Repl:
445
567
  # Increment iteration counter (one batch of tool calls = one iteration)
446
568
  self.agent.turn.increment_iteration()
447
569
 
448
- # Execute all tool calls and submit results
449
- for tool_call in tool_calls:
450
- if not isinstance(tool_call, ToolCall):
451
- continue
452
-
453
- self.renderer.muted(f"\n[tool] {tool_call.name}({tool_call.arguments})")
454
-
455
- # Execute the tool
456
- result = await self.tool_registry.execute(tool_call.name, tool_call.arguments)
457
-
458
- # Record tool call in turn state for loop detection
570
+ responded_ids = set()
571
+
572
+ # Split tool calls into those that need confirmation and those that don't
573
+ # to allow parallel execution of "safe" tools.
574
+ to_parallel: list[ToolCall] = []
575
+ to_sequential: list[ToolCall] = []
576
+
577
+ for tc in tool_calls:
578
+ tool = self.tool_registry.get(tc.name)
579
+ # Use same logic as ToolRegistry.execute for confirmation check
580
+ if tool and (tc.name in self.tool_registry._auto_approve_policies or
581
+ tool.needs_confirmation(tc.arguments) is None):
582
+ to_parallel.append(tc)
583
+ else:
584
+ to_sequential.append(tc)
585
+
586
+ async def execute_and_record(tc: ToolCall) -> None:
587
+ self.renderer.muted(f"\n[tool] {tc.name}({tc.arguments})")
588
+ result = await self.tool_registry.execute(tc.name, tc.arguments)
589
+
590
+ # Record results (thread-safe for agent/session lists)
591
+ responded_ids.add(tc.id)
459
592
  self.agent.turn.record_tool_call(
460
- tool_call_id=tool_call.id,
461
- tool_name=tool_call.name,
462
- arguments=tool_call.arguments,
593
+ tool_call_id=tc.id,
594
+ tool_name=tc.name,
595
+ arguments=tc.arguments,
463
596
  result=result,
464
597
  )
465
-
466
- # Submit result to agent
467
- self.agent.submit_tool_result(tool_call.id, result.content)
468
-
469
- # Record tool result to session
598
+ self.agent.submit_tool_result(tc.id, result.content)
470
599
  if self.session is not None:
471
600
  self.session.messages.append(
472
- SessionMessage(
473
- role="tool",
474
- content=result.content,
475
- tool_call_id=tool_call.id,
476
- )
601
+ SessionMessage(role="tool", content=result.content, tool_call_id=tc.id)
477
602
  )
478
603
 
479
604
  # Show result
@@ -482,6 +607,38 @@ class Repl:
482
607
  else:
483
608
  self.renderer.error(f"[error] {result.error}")
484
609
 
610
+ try:
611
+ # 1. Execute parallel group (tools not needing confirmation)
612
+ if to_parallel:
613
+ await asyncio.gather(*(execute_and_record(tc) for tc in to_parallel))
614
+
615
+ # 2. Execute sequential group (tools needing confirmation)
616
+ if to_sequential:
617
+ # Suspend key monitor while we might be showing confirmation prompts
618
+ if hasattr(self, "current_monitor") and self.current_monitor:
619
+ await self.current_monitor.suspend()
620
+
621
+ try:
622
+ for tc in to_sequential:
623
+ await execute_and_record(tc)
624
+ finally:
625
+ if hasattr(self, "current_monitor") and self.current_monitor:
626
+ self.current_monitor.resume()
627
+ finally:
628
+ # Ensure all tool calls have a response, even if interrupted
629
+ for tool_call in tool_calls:
630
+ if tool_call.id not in responded_ids:
631
+ cancel_msg = "Tool execution was interrupted or cancelled."
632
+ self.agent.submit_tool_result(tool_call.id, cancel_msg)
633
+ if self.session is not None:
634
+ self.session.messages.append(
635
+ SessionMessage(
636
+ role="tool",
637
+ content=cancel_msg,
638
+ tool_call_id=tool_call.id,
639
+ )
640
+ )
641
+
485
642
  # Show turn status after tool execution
486
643
  self._show_turn_status()
487
644
 
henchman/core/turn.py CHANGED
@@ -68,9 +68,15 @@ class TurnState:
68
68
  self.tool_count += 1
69
69
 
70
70
  # Track for duplicate detection
71
+ # Be more lenient with read-only operations
72
+ is_read_only = tool_name in ("read_file", "ls", "glob", "grep", "rag_search")
71
73
  call_sig = f"{tool_name}:{_hash_content(str(sorted(arguments.items())))}"
72
74
  if call_sig == self._last_call_signature:
73
- self._consecutive_duplicates += 1
75
+ # Only count as duplicate if not a read operation or if it's excessive
76
+ if not is_read_only:
77
+ self._consecutive_duplicates += 1
78
+ elif self._consecutive_duplicates >= 5: # Allow more reads before flagging
79
+ self._consecutive_duplicates += 1
74
80
  else:
75
81
  self._consecutive_duplicates = 0
76
82
  self._last_call_signature = call_sig
@@ -119,19 +125,19 @@ class TurnState:
119
125
  Returns:
120
126
  True if loop indicators are detected.
121
127
  """
122
- # Same tool+args called 3+ times consecutively
123
- if self._consecutive_duplicates >= 2: # 0-indexed, so 2 = 3 calls
128
+ # Same tool+args called 4+ times consecutively (increased from 3)
129
+ if self._consecutive_duplicates >= 3: # 0-indexed, so 3 = 4 calls
124
130
  return True
125
131
 
126
- # Same result hash repeated 3+ times in last 5 results
127
- if len(self.recent_result_hashes) >= 5:
128
- recent = self.recent_result_hashes[-5:]
132
+ # Same result hash repeated 4+ times in last 6 results (more lenient)
133
+ if len(self.recent_result_hashes) >= 6:
134
+ recent = self.recent_result_hashes[-6:]
129
135
  for h in set(recent):
130
- if recent.count(h) >= 3:
136
+ if recent.count(h) >= 4:
131
137
  return True
132
138
 
133
- # No new files touched in 5+ iterations with many tool calls
134
- return bool(self.iteration >= 5 and not self.files_modified and self.tool_count > 10)
139
+ # No new files touched in 7+ iterations with many tool calls (increased threshold)
140
+ return bool(self.iteration >= 7 and not self.files_modified and self.tool_count > 15)
135
141
 
136
142
  def get_adaptive_limit(self, base_limit: int = 25) -> int:
137
143
  """Get the adaptive iteration limit based on progress.
henchman/rag/system.py CHANGED
@@ -6,6 +6,7 @@ the RAG system in the CLI.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  from pathlib import Path
10
11
  from typing import TYPE_CHECKING
11
12
 
@@ -78,6 +79,7 @@ class RagSystem:
78
79
  self.git_root = git_root
79
80
  self.settings = settings
80
81
  self.read_only = read_only
82
+ self.is_indexing = False
81
83
 
82
84
  # Get cache directory
83
85
  cache_dir = Path(settings.cache_dir) if settings.cache_dir else None
@@ -193,12 +195,37 @@ class RagSystem:
193
195
  f"Could not acquire RAG lock at {self._lock.lock_path}"
194
196
  )
195
197
 
198
+ self.is_indexing = True
196
199
  try:
197
200
  # Run indexing with lock held
198
201
  return self._indexer.index(console=console, force=force)
199
202
  finally:
200
203
  # Always release the lock
201
204
  self._lock.release()
205
+ self.is_indexing = False
206
+
207
+ async def index_async(
208
+ self,
209
+ console: Console | None = None,
210
+ force: bool = False,
211
+ skip_if_locked: bool = True,
212
+ ) -> IndexStats | None:
213
+ """Run indexing operation asynchronously in a separate thread.
214
+
215
+ Args:
216
+ console: Rich console for progress display.
217
+ force: If True, force full reindex.
218
+ skip_if_locked: If True and lock cannot be acquired,
219
+ skip indexing and return None.
220
+
221
+ Returns:
222
+ Statistics about the indexing operation.
223
+ """
224
+ loop = asyncio.get_running_loop()
225
+ return await loop.run_in_executor(
226
+ None,
227
+ lambda: self.index(console=console, force=force, skip_if_locked=skip_if_locked)
228
+ )
202
229
 
203
230
  def get_stats(self) -> IndexStats:
204
231
  """Get current index statistics.
@@ -220,6 +247,7 @@ def initialize_rag(
220
247
  settings: RagSettings,
221
248
  console: Console | None = None,
222
249
  git_root: Path | None = None,
250
+ index: bool = True,
223
251
  ) -> RagSystem | None:
224
252
  """Initialize the RAG system if in a git repository.
225
253
 
@@ -227,6 +255,7 @@ def initialize_rag(
227
255
  settings: RAG settings from configuration.
228
256
  console: Rich console for output.
229
257
  git_root: Optional pre-computed git root.
258
+ index: Whether to run indexing immediately (blocking).
230
259
 
231
260
  Returns:
232
261
  RagSystem instance if successful, None if not in a git repo
@@ -253,7 +282,10 @@ def initialize_rag(
253
282
 
254
283
  rag_system = RagSystem(git_root=root, settings=settings)
255
284
 
256
- # Run indexing
285
+ if not index:
286
+ return rag_system
287
+
288
+ # Run indexing (blocking)
257
289
  stats = rag_system.index(console=console)
258
290
 
259
291
  # Show summary
@@ -259,13 +259,14 @@ class ContextCompactor:
259
259
  )
260
260
  protected_tokens = TokenCounter.count_messages(protected_msgs)
261
261
 
262
- # Group unprotected messages into atomic sequences
263
- sequences = self._group_into_sequences(unprotected_msgs)
264
-
265
262
  # Separate system messages (always kept)
266
263
  system_msgs = [msg for msg in unprotected_msgs if msg.role == "system"]
267
264
  system_tokens = TokenCounter.count_messages(system_msgs)
268
265
 
266
+ # Group non-system unprotected messages into atomic sequences
267
+ non_system_unprotected = [msg for msg in unprotected_msgs if msg.role != "system"]
268
+ sequences = self._group_into_sequences(non_system_unprotected)
269
+
269
270
  # Calculate budget for unprotected sequences
270
271
  # Must fit: system + kept sequences + protected zone
271
272
  budget = self.max_tokens - system_tokens - protected_tokens
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: henchman-ai
3
- Version: 0.1.11
3
+ Version: 0.1.12
4
4
  Summary: A model-agnostic AI agent CLI - your AI henchman for the terminal
5
5
  Project-URL: Homepage, https://github.com/MGPowerlytics/henchman-ai
6
6
  Project-URL: Repository, https://github.com/MGPowerlytics/henchman-ai
@@ -2,21 +2,21 @@ henchman/__init__.py,sha256=P_jCbtgAVbk2hn6uMum2UYkE7ptT361mWRkUZz0xKvk,148
2
2
  henchman/__main__.py,sha256=3oRWZvoWON5ErlJFYOOSU5p1PERRyK6MkT2LGEnbb2o,131
3
3
  henchman/version.py,sha256=UFJFO9ixJBEALb9BGtb2TE9cid8MpfI03n3BvBeWoiA,161
4
4
  henchman/cli/__init__.py,sha256=Gv86a_heuBLqUd-y46JZUyzUaDl5H-9RtcWGr3rMwBw,673
5
- henchman/cli/app.py,sha256=7fZI6ta4h6FT-EixItDrje4fKUHYc2hpQgL8UZs9Hpk,6682
6
- henchman/cli/console.py,sha256=BeF-XAS6REn0HzjAvdaM6GBI4XtlVxRY_-FuxoWwcoQ,7921
7
- henchman/cli/input.py,sha256=0qW36f7f06ct4XXca7ooxkTShID-QXkLtmROh_xso04,4632
5
+ henchman/cli/app.py,sha256=ausKDDrRJ5KgiespK2P9vhX1yn-DxdJhYyBJ6tB5sb4,11507
6
+ henchman/cli/console.py,sha256=S4Jvq0UTmu9KtOkLNsIsvG_8X9eg1Guc6NAh8T_JeNI,8017
7
+ henchman/cli/input.py,sha256=oMKMF1CQCZrON5gqy8mtbYqIoGUvXcBEiDZeTxC9B6s,7129
8
8
  henchman/cli/json_output.py,sha256=9kP9S5q0xBgP4HQGTT4P6DDT76F9VVTdEY_KiEpoZnI,2669
9
9
  henchman/cli/prompts.py,sha256=m3Velzi2tXBIHinN9jIpU9kDMYL80ngYQsv2EYo7IZU,6647
10
- henchman/cli/repl.py,sha256=QZ6H4yWkr73dKQeIXihrus1ep6yJQwg1w5X-gRjAYkY,19866
10
+ henchman/cli/repl.py,sha256=fkeaMEGnFaFZ-HIjBLI1DUfos9ebGkHrPUUNMWS_LLU,26535
11
11
  henchman/cli/repl.py.backup,sha256=3iagruUgsvtcfpDv1mTAYg4I14X4CaNSEeMQjj91src,15638
12
12
  henchman/cli/repl.py.backup2,sha256=-zgSUrnobd_sHq3jG-8NbwPTVlPc3FaqSkv32gAFdPo,11328
13
13
  henchman/cli/commands/__init__.py,sha256=8s6NBCPlc4jKTCdvnKJCmdLwRCQ4QLCARjQbr7ICipw,3828
14
- henchman/cli/commands/builtins.py,sha256=d4wgb3VeWwaWmKtk0MKr5NAvo-OWVgfxAQKpWkJGBFU,5136
15
- henchman/cli/commands/chat.py,sha256=rrw1ZGVDdfJiNiPSSow2Q2v6I1uU4wnrfFHj9mZOACc,5550
14
+ henchman/cli/commands/builtins.py,sha256=-XOAY0EzvyHYnoOc6QMwVve7aMEWPYiMUUjor4OzBqg,5439
15
+ henchman/cli/commands/chat.py,sha256=ePPRh68ZHHS_l1Uj7fUtjBQrVKOx6WvZQsuIzXdxgjY,6204
16
16
  henchman/cli/commands/extensions.py,sha256=r7PfvbBjwBr5WhF8G49p29z7FKx6geRJiR-R67pj6i0,1758
17
17
  henchman/cli/commands/mcp.py,sha256=bbW1J9-fIpvDBIba3L1MAkNqCjFBTZnZLNIgf6LjJEA,3554
18
18
  henchman/cli/commands/plan.py,sha256=5ZXePoMVIKBxugSnDB6N2TEDpl2xZszQDz9wTQffzpY,2486
19
- henchman/cli/commands/rag.py,sha256=gG0KJ_ildFB76448hbPEMfsZNhY6RKWrCe0IDPyLsuM,7101
19
+ henchman/cli/commands/rag.py,sha256=sXY7MCZ4UMVzNX2ALVM8wt7q82PZovwVHOSMDfot8jQ,7308
20
20
  henchman/cli/commands/skill.py,sha256=azXb6-KXjtZKwHiBV-Ppk6CdJQKZhetr46hNgZ_r45Q,8096
21
21
  henchman/cli/commands/unlimited.py,sha256=eFMTwrcUFWbfJnXpwBcRqviYt66tDz4xAYBDcton50Y,2101
22
22
  henchman/config/__init__.py,sha256=28UtrhPye0MEmbdvi1jCqO3uIXfmqSAZVWvnpJv-qTo,637
@@ -28,7 +28,7 @@ henchman/core/agent.py,sha256=l9BJO8Zw4bMdUyTDjcZKG84WdZ1Kndm3Y09oUAZFYp0,13475
28
28
  henchman/core/agent.py.backup,sha256=Tq0IhWAPMRQTxjETeH7WTosEmzuUVz7um0YbCnuNbLQ,7417
29
29
  henchman/core/events.py,sha256=Uijv3NGNV8yJnQfY48u0pBBvEauAEczAbkGARJy8mfI,1423
30
30
  henchman/core/session.py,sha256=NkwEG2ZS2uh2ZeX8_LkSN7MwQlBxwhTXjx0BoNUZLDw,12475
31
- henchman/core/turn.py,sha256=DoSWqIifrrkhzr4KD3zmA1Sq-csNCBflGGEKOc7PfOs,8165
31
+ henchman/core/turn.py,sha256=iRaTr_8hGSGQUt0vqmUE8w5D-drvkLeZlNEHmNUfX0M,8617
32
32
  henchman/extensions/__init__.py,sha256=C7LrK50uiHwmLlOGkQyngbFvuUYdCcZEb6ucOklY_ws,310
33
33
  henchman/extensions/base.py,sha256=cHUzWu4OGFju9Wr1xAiGHZOOW7eQbcC1-dqEd2Oe3QM,2290
34
34
  henchman/extensions/manager.py,sha256=xHxMo0-BxzyFfD3fZgvsiovORYpMM6sPnDPOTfULZSU,6666
@@ -52,7 +52,7 @@ henchman/rag/embedder.py,sha256=J2-cIEIoS2iUh4k6PM-rgl7wkTOXSG1NrOQvXHTQPho,4080
52
52
  henchman/rag/indexer.py,sha256=6oVOkv4lD_elACivPL9Noe5zgpterYDZ3f1XlLyyULc,11806
53
53
  henchman/rag/repo_id.py,sha256=ZRPKM8fzwmETgrOYwE1PGjRp3c8XQFrR493BrDZlbd8,5755
54
54
  henchman/rag/store.py,sha256=eN0Rj2Lo6zJp2iWCXsJ-q24l2T_pnlTF3Oeea60gnfs,8826
55
- henchman/rag/system.py,sha256=TklAKf3EjsnKDP-C7G5kE6XauQCdHd4uEJbVIkLgZ38,8835
55
+ henchman/rag/system.py,sha256=uEftMJJCy0wlHvt09j2YP7xDdN0if8eCYD4MnS7_Xvc,9869
56
56
  henchman/skills/__init__.py,sha256=cvCl6HRxsUdag-RTpMP__Ww_hee37ggpAXQ41wXemEU,149
57
57
  henchman/skills/executor.py,sha256=sYss_83zduFLB_AACTSXMZHLA_lv-T1iKHSxelpv13U,1105
58
58
  henchman/skills/learner.py,sha256=lzIrLU5_oLbqDYF673F-rwb1IaWeeOqjzcsBGC-IKlM,1644
@@ -73,12 +73,12 @@ henchman/tools/builtins/rag_search.py,sha256=yk0z0mIVRH-yl47uteNXTy76aXP8PLxBq51
73
73
  henchman/tools/builtins/shell.py,sha256=Gx8x1jBq1NvERFnc-kUNMovFoWg_i4IrV_askSECfEM,4134
74
74
  henchman/tools/builtins/web_fetch.py,sha256=uwgZm0ye3yDuS2U2DPV4D-8bjviYDTKN-cNi7mCMRpw,3370
75
75
  henchman/utils/__init__.py,sha256=ayu2XRNx3Fw0z8vbIne63A3gBjxu779QE8sUQsjNnm4,240
76
- henchman/utils/compaction.py,sha256=jPpJ5tQm-IBn4YChiGrKy8u_K4OJ23lk3Jvq8sNbQYc,22763
76
+ henchman/utils/compaction.py,sha256=ARS0jUDI2adsoCTfJjygRom31N16QtWbRzNXDKzX6cA,22871
77
77
  henchman/utils/retry.py,sha256=sobZk9LLGxglSJw_jeNaBYCrvH14YNFrBVyp_OwLWcw,4993
78
78
  henchman/utils/tokens.py,sha256=D9H4ciFNH7l1b05IGbw0U0tmy2yF5aItFZyDufGF53k,5665
79
79
  henchman/utils/validation.py,sha256=moj4LQXVXt2J-3_pWVH_0-EabyRYApOU2Oh5JSTIua8,4146
80
- henchman_ai-0.1.11.dist-info/METADATA,sha256=jhhpjwZJDMJW2gdY1PXT8dULA0z9MKeqTcyqbd17Aos,3552
81
- henchman_ai-0.1.11.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
82
- henchman_ai-0.1.11.dist-info/entry_points.txt,sha256=dtPyd6BzK3A8lmrj1KXTFlHBplIWcWMdryjtR0jw5iU,51
83
- henchman_ai-0.1.11.dist-info/licenses/LICENSE,sha256=TMoSCCG1I1vCMK-Bjtvxe80E8kIdSdrtuQXYHc_ahqg,1064
84
- henchman_ai-0.1.11.dist-info/RECORD,,
80
+ henchman_ai-0.1.12.dist-info/METADATA,sha256=Ht1dV7MGoqu7E2XAubPT9sZbNxFepLvA9jGQxw2KTas,3552
81
+ henchman_ai-0.1.12.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
82
+ henchman_ai-0.1.12.dist-info/entry_points.txt,sha256=dtPyd6BzK3A8lmrj1KXTFlHBplIWcWMdryjtR0jw5iU,51
83
+ henchman_ai-0.1.12.dist-info/licenses/LICENSE,sha256=TMoSCCG1I1vCMK-Bjtvxe80E8kIdSdrtuQXYHc_ahqg,1064
84
+ henchman_ai-0.1.12.dist-info/RECORD,,